@@ -6,10 +6,21 @@ import {
66 PROTOCOL_VERSION ,
77} from "../../../packages/gateway-protocol/src/version.js" ;
88import type { DeviceIdentity } from "../lib/nodes/index.ts" ;
9- import { loadDeviceAuthToken , storeDeviceAuthToken } from "../lib/nodes/index.ts" ;
9+ import {
10+ loadDeviceAuthToken as loadScopedDeviceAuthToken ,
11+ storeDeviceAuthToken as storeScopedDeviceAuthToken ,
12+ } from "../lib/nodes/index.ts" ;
1013import { createStorageMock } from "../test-helpers/storage.ts" ;
1114
1215const wsInstances = vi . hoisted ( ( ) : MockWebSocket [ ] => [ ] ) ;
16+ const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789" ;
17+ const LEGACY_DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1" ;
18+ const DEFAULT_DEVICE_AUTH_STORAGE_KEY = `${ LEGACY_DEVICE_AUTH_STORAGE_KEY } :${ DEFAULT_GATEWAY_URL } ` ;
19+ const STORED_CRED = "stored-device-token" ;
20+ const ROSITA_CRED = "rosita-device-token" ;
21+ const WILFRED_CRED = "wilfred-device-token" ;
22+ const TENANT_A_CRED = "tenant-a-device-token" ;
23+ const TENANT_B_CRED = "tenant-b-device-token" ;
1324const loadOrCreateDeviceIdentityMock = vi . hoisted ( ( ) =>
1425 vi . fn (
1526 async ( ) : Promise < DeviceIdentity > => ( {
@@ -23,6 +34,19 @@ const signDevicePayloadMock = vi.hoisted(() =>
2334 vi . fn ( async ( _privateKeyBase64Url : string , _payload : string ) => "signature" ) ,
2435) ;
2536
37+ function loadDeviceAuthToken ( params : { deviceId : string ; role : string } ) {
38+ return loadScopedDeviceAuthToken ( { ...params , gatewayUrl : DEFAULT_GATEWAY_URL } ) ;
39+ }
40+
41+ function storeDeviceAuthToken ( params : {
42+ deviceId : string ;
43+ role : string ;
44+ token : string ;
45+ scopes ?: string [ ] ;
46+ } ) {
47+ return storeScopedDeviceAuthToken ( { ...params , gatewayUrl : DEFAULT_GATEWAY_URL } ) ;
48+ }
49+
2650type HandlerMap = {
2751 close : MockWebSocketHandler [ ] ;
2852 error : MockWebSocketHandler [ ] ;
@@ -313,6 +337,13 @@ async function expectRetriedDeviceTokenConnect(params: {
313337 token : string ;
314338 retryNonce ?: string ;
315339} ) {
340+ storeScopedDeviceAuthToken ( {
341+ deviceId : "device-1" ,
342+ gatewayUrl : params . url ,
343+ role : "operator" ,
344+ token : STORED_CRED ,
345+ scopes : [ ...CONTROL_UI_OPERATOR_SCOPES ] ,
346+ } ) ;
316347 const client = new GatewayBrowserClient ( {
317348 url : params . url ,
318349 token : params . token ,
@@ -333,7 +364,7 @@ async function expectRetriedDeviceTokenConnect(params: {
333364 params . retryNonce ?? "nonce-2" ,
334365 ) ;
335366 expect ( secondConnect . params ?. auth ?. token ) . toBe ( params . token ) ;
336- expect ( secondConnect . params ?. auth ?. deviceToken ) . toBe ( "stored-device-token" ) ;
367+ expect ( secondConnect . params ?. auth ?. deviceToken ) . toBe ( STORED_CRED ) ;
337368
338369 return { client, firstWs, secondWs, firstConnect, secondConnect } ;
339370}
@@ -930,6 +961,99 @@ describe("GatewayBrowserClient", () => {
930961 } ) ;
931962 } ) ;
932963
964+ it ( "uses a scoped device token when legacy cleanup fails" , async ( ) => {
965+ vi . spyOn ( localStorage , "removeItem" ) . mockImplementation ( ( ) => {
966+ throw new Error ( "storage cleanup blocked" ) ;
967+ } ) ;
968+ const client = new GatewayBrowserClient ( {
969+ url : DEFAULT_GATEWAY_URL ,
970+ } ) ;
971+
972+ const { connectFrame } = await startConnect ( client ) ;
973+
974+ expect ( connectFrame . params ?. auth ?. token ) . toBe ( STORED_CRED ) ;
975+ } ) ;
976+
977+ it ( "migrates the legacy device token store to the first gateway opened after upgrade" , async ( ) => {
978+ const legacyStore = localStorage . getItem ( DEFAULT_DEVICE_AUTH_STORAGE_KEY ) ;
979+ expect ( legacyStore ) . not . toBeNull ( ) ;
980+ localStorage . clear ( ) ;
981+ localStorage . setItem ( LEGACY_DEVICE_AUTH_STORAGE_KEY , legacyStore ?? "" ) ;
982+
983+ const client = new GatewayBrowserClient ( {
984+ url : DEFAULT_GATEWAY_URL ,
985+ } ) ;
986+ const { connectFrame } = await startConnect ( client ) ;
987+
988+ expect ( connectFrame . params ?. auth ?. token ) . toBe ( STORED_CRED ) ;
989+ expect ( localStorage . getItem ( LEGACY_DEVICE_AUTH_STORAGE_KEY ) ) . toBeNull ( ) ;
990+ expect ( localStorage . getItem ( DEFAULT_DEVICE_AUTH_STORAGE_KEY ) ) . toBe ( legacyStore ) ;
991+ } ) ;
992+
993+ it ( "keeps cached device tokens separate for gateways on the same origin" , async ( ) => {
994+ localStorage . clear ( ) ;
995+ storeScopedDeviceAuthToken ( {
996+ deviceId : "device-1" ,
997+ gatewayUrl : "wss://gateway.example/rosita/" ,
998+ role : "operator" ,
999+ token : ROSITA_CRED ,
1000+ scopes : [ ...CONTROL_UI_OPERATOR_SCOPES ] ,
1001+ } ) ;
1002+ storeScopedDeviceAuthToken ( {
1003+ deviceId : "device-1" ,
1004+ gatewayUrl : "wss://gateway.example/wilfred" ,
1005+ role : "operator" ,
1006+ token : WILFRED_CRED ,
1007+ scopes : [ ...CONTROL_UI_OPERATOR_SCOPES ] ,
1008+ } ) ;
1009+
1010+ const rositaClient = new GatewayBrowserClient ( {
1011+ url : "wss://gateway.example/rosita" ,
1012+ } ) ;
1013+ const { connectFrame : rositaConnect } = await startConnect ( rositaClient ) ;
1014+ expect ( rositaConnect . params ?. auth ?. token ) . toBe ( ROSITA_CRED ) ;
1015+ rositaClient . stop ( ) ;
1016+
1017+ const wilfredClient = new GatewayBrowserClient ( {
1018+ url : "wss://gateway.example/wilfred" ,
1019+ } ) ;
1020+ const { connectFrame : wilfredConnect } = await startConnect ( wilfredClient , "nonce-2" ) ;
1021+ expect ( wilfredConnect . params ?. auth ?. token ) . toBe ( WILFRED_CRED ) ;
1022+ wilfredClient . stop ( ) ;
1023+ } ) ;
1024+
1025+ it ( "keeps cached device tokens separate for gateway query routes" , async ( ) => {
1026+ localStorage . clear ( ) ;
1027+ storeScopedDeviceAuthToken ( {
1028+ deviceId : "device-1" ,
1029+ gatewayUrl : "wss://gateway.example/control?tenant=a" ,
1030+ role : "operator" ,
1031+ token : TENANT_A_CRED ,
1032+ scopes : [ ...CONTROL_UI_OPERATOR_SCOPES ] ,
1033+ } ) ;
1034+ storeScopedDeviceAuthToken ( {
1035+ deviceId : "device-1" ,
1036+ gatewayUrl : "wss://gateway.example/control?tenant=b" ,
1037+ role : "operator" ,
1038+ token : TENANT_B_CRED ,
1039+ scopes : [ ...CONTROL_UI_OPERATOR_SCOPES ] ,
1040+ } ) ;
1041+
1042+ const tenantAClient = new GatewayBrowserClient ( {
1043+ url : "wss://gateway.example/control?tenant=a" ,
1044+ } ) ;
1045+ const { connectFrame : tenantAConnect } = await startConnect ( tenantAClient ) ;
1046+ expect ( tenantAConnect . params ?. auth ?. token ) . toBe ( TENANT_A_CRED ) ;
1047+ tenantAClient . stop ( ) ;
1048+
1049+ const tenantBClient = new GatewayBrowserClient ( {
1050+ url : "wss://gateway.example/control?tenant=b" ,
1051+ } ) ;
1052+ const { connectFrame : tenantBConnect } = await startConnect ( tenantBClient , "nonce-2" ) ;
1053+ expect ( tenantBConnect . params ?. auth ?. token ) . toBe ( TENANT_B_CRED ) ;
1054+ tenantBClient . stop ( ) ;
1055+ } ) ;
1056+
9331057 it ( "ignores cached operator device tokens that do not include read access" , async ( ) => {
9341058 localStorage . clear ( ) ;
9351059 storeDeviceAuthToken ( {
@@ -974,9 +1098,12 @@ describe("GatewayBrowserClient", () => {
9741098 } ) ;
9751099 await expectSocketClosed ( secondWs ) ;
9761100 secondWs . emitClose ( 4008 , "connect failed" ) ;
977- expect ( loadDeviceAuthToken ( { deviceId : "device-1" , role : "operator" } ) ?. token ) . toBe (
978- "stored-device-token" ,
979- ) ;
1101+ expect (
1102+ loadDeviceAuthToken ( {
1103+ deviceId : "device-1" ,
1104+ role : "operator" ,
1105+ } ) ?. token ,
1106+ ) . toBe ( "stored-device-token" ) ;
9801107 await vi . advanceTimersByTimeAsync ( 30_000 ) ;
9811108 expect ( wsInstances ) . toHaveLength ( 2 ) ;
9821109
@@ -1362,7 +1489,12 @@ describe("GatewayBrowserClient", () => {
13621489 await expectSocketClosed ( ws ) ;
13631490 ws . emitClose ( 4008 , "connect failed" ) ;
13641491
1365- expect ( loadDeviceAuthToken ( { deviceId : "device-1" , role : "operator" } ) ) . toBeNull ( ) ;
1492+ expect (
1493+ loadDeviceAuthToken ( {
1494+ deviceId : "device-1" ,
1495+ role : "operator" ,
1496+ } ) ,
1497+ ) . toBeNull ( ) ;
13661498 await vi . advanceTimersByTimeAsync ( 30_000 ) ;
13671499 expect ( wsInstances ) . toHaveLength ( 1 ) ;
13681500
@@ -1392,9 +1524,12 @@ describe("GatewayBrowserClient", () => {
13921524 await expectSocketClosed ( ws ) ;
13931525 ws . emitClose ( 4008 , "connect failed" ) ;
13941526
1395- expect ( loadDeviceAuthToken ( { deviceId : "device-1" , role : "operator" } ) ?. token ) . toBe (
1396- "stored-device-token" ,
1397- ) ;
1527+ expect (
1528+ loadDeviceAuthToken ( {
1529+ deviceId : "device-1" ,
1530+ role : "operator" ,
1531+ } ) ?. token ,
1532+ ) . toBe ( "stored-device-token" ) ;
13981533 await vi . advanceTimersByTimeAsync ( 30_000 ) ;
13991534 expect ( wsInstances ) . toHaveLength ( 1 ) ;
14001535
0 commit comments