@@ -903,7 +903,7 @@ describe("gateway server auth/connect", () => {
903903 }
904904 } ) ;
905905
906- test ( "requires pairing for scope upgrades" , async ( ) => {
906+ test ( "requires pairing for scope upgrades from non-local host " , async ( ) => {
907907 const { mkdtemp } = await import ( "node:fs/promises" ) ;
908908 const { tmpdir } = await import ( "node:os" ) ;
909909 const { join } = await import ( "node:path" ) ;
@@ -953,7 +953,9 @@ describe("gateway server auth/connect", () => {
953953
954954 ws . close ( ) ;
955955
956- const ws2 = new WebSocket ( `ws://127.0.0.1:${ port } ` ) ;
956+ const ws2 = new WebSocket ( `ws://127.0.0.1:${ port } ` , {
957+ headers : { host : "gateway.public.example" } ,
958+ } ) ;
957959 await new Promise < void > ( ( resolve ) => ws2 . once ( "open" , resolve ) ) ;
958960 const res = await connectReq ( ws2 , {
959961 token : "secret" ,
@@ -984,6 +986,75 @@ describe("gateway server auth/connect", () => {
984986 restoreGatewayToken ( prevToken ) ;
985987 } ) ;
986988
989+ test ( "allows loopback paired-device scope upgrades without interactive pairing" , async ( ) => {
990+ const { mkdtemp } = await import ( "node:fs/promises" ) ;
991+ const { tmpdir } = await import ( "node:os" ) ;
992+ const { join } = await import ( "node:path" ) ;
993+ const { buildDeviceAuthPayload } = await import ( "./device-auth.js" ) ;
994+ const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
995+ await import ( "../infra/device-identity.js" ) ;
996+ const { getPairedDevice } = await import ( "../infra/device-pairing.js" ) ;
997+ const { server, ws, port, prevToken } = await startServerWithClient ( "secret" ) ;
998+ const identityDir = await mkdtemp ( join ( tmpdir ( ) , "openclaw-device-scope-loopback-" ) ) ;
999+ const identity = loadOrCreateDeviceIdentity ( join ( identityDir , "device.json" ) ) ;
1000+ const client = {
1001+ id : GATEWAY_CLIENT_NAMES . TEST ,
1002+ version : "1.0.0" ,
1003+ platform : "test" ,
1004+ mode : GATEWAY_CLIENT_MODES . TEST ,
1005+ } ;
1006+ const buildDevice = ( scopes : string [ ] ) => {
1007+ const signedAtMs = Date . now ( ) ;
1008+ const payload = buildDeviceAuthPayload ( {
1009+ deviceId : identity . deviceId ,
1010+ clientId : client . id ,
1011+ clientMode : client . mode ,
1012+ role : "operator" ,
1013+ scopes,
1014+ signedAtMs,
1015+ token : "secret" ,
1016+ } ) ;
1017+ return {
1018+ id : identity . deviceId ,
1019+ publicKey : publicKeyRawBase64UrlFromPem ( identity . publicKeyPem ) ,
1020+ signature : signDevicePayload ( identity . privateKeyPem , payload ) ,
1021+ signedAt : signedAtMs ,
1022+ } ;
1023+ } ;
1024+
1025+ const initial = await connectReq ( ws , {
1026+ token : "secret" ,
1027+ scopes : [ "operator.read" ] ,
1028+ client,
1029+ device : buildDevice ( [ "operator.read" ] ) ,
1030+ } ) ;
1031+ if ( ! initial . ok ) {
1032+ await approvePendingPairingIfNeeded ( ) ;
1033+ }
1034+
1035+ let paired = await getPairedDevice ( identity . deviceId ) ;
1036+ expect ( paired ?. scopes ) . toContain ( "operator.read" ) ;
1037+
1038+ ws . close ( ) ;
1039+
1040+ const ws2 = new WebSocket ( `ws://127.0.0.1:${ port } ` ) ;
1041+ await new Promise < void > ( ( resolve ) => ws2 . once ( "open" , resolve ) ) ;
1042+ const upgrade = await connectReq ( ws2 , {
1043+ token : "secret" ,
1044+ scopes : [ "operator.admin" ] ,
1045+ client,
1046+ device : buildDevice ( [ "operator.admin" ] ) ,
1047+ } ) ;
1048+ expect ( upgrade . ok ) . toBe ( true ) ;
1049+
1050+ paired = await getPairedDevice ( identity . deviceId ) ;
1051+ expect ( paired ?. scopes ) . toContain ( "operator.admin" ) ;
1052+
1053+ ws2 . close ( ) ;
1054+ await server . close ( ) ;
1055+ restoreGatewayToken ( prevToken ) ;
1056+ } ) ;
1057+
9871058 test ( "single approval captures pending node and operator roles for the same device" , async ( ) => {
9881059 const { mkdtemp } = await import ( "node:fs/promises" ) ;
9891060 const { tmpdir } = await import ( "node:os" ) ;
0 commit comments