From be7a415eb09609408087d9d2737b6f02f36586ab Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:20:48 -0500 Subject: [PATCH] fix: preserve hello-ok scopes for reused device tokens (#68039) --- .../server.auth.default-token.suite.ts | 63 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 3 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index d9295db68cf..9c61af09857 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -270,6 +270,69 @@ export function registerDefaultAuthTokenSuite(): void { } }); + test("hello-ok reports persisted token scopes when reusing an existing device token", async () => { + const { randomUUID } = await import("node:crypto"); + const os = await import("node:os"); + const path = await import("node:path"); + const token = resolveGatewayTokenOrEnv(); + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-shared-auth-scope-reuse-${randomUUID()}.json`, + ); + const wsInitial = await openWs(port); + let pairedDeviceToken: string | undefined; + let pairedDeviceScopes: unknown; + try { + const initial = await connectReq(wsInitial, { + token, + scopes: ["operator.admin"], + deviceIdentityPath, + }); + expect(initial.ok).toBe(true); + const helloOk = initial.payload as + | { + auth?: { + role?: unknown; + scopes?: unknown; + deviceToken?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.role).toBe("operator"); + expect(Array.isArray(helloOk?.auth?.scopes)).toBe(true); + expect(typeof helloOk?.auth?.deviceToken).toBe("string"); + pairedDeviceToken = helloOk?.auth?.deviceToken as string | undefined; + pairedDeviceScopes = helloOk?.auth?.scopes; + } finally { + wsInitial.close(); + } + + const wsReconnect = await openWs(port); + try { + const reconnect = await connectReq(wsReconnect, { + token, + scopes: ["operator.read"], + deviceIdentityPath, + }); + expect(reconnect.ok).toBe(true); + const helloOk = reconnect.payload as + | { + auth?: { + role?: unknown; + scopes?: unknown; + deviceToken?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.role).toBe("operator"); + expect(helloOk?.auth?.deviceToken).toBe(pairedDeviceToken); + expect(helloOk?.auth?.scopes).toEqual(pairedDeviceScopes); + expect(helloOk?.auth?.scopes).not.toEqual(["operator.read"]); + } finally { + wsReconnect.close(); + } + }); + test("does not grant admin when scopes are omitted", async () => { const ws = await openWs(port); const token = resolveGatewayTokenOrEnv(); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index aba91e72161..c8ae46f176d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1217,6 +1217,7 @@ export function attachGatewayWsMessageHandler(params: { canvasHostUrl && canvasCapability ? (buildCanvasScopedHostUrl(canvasHostUrl, canvasCapability) ?? canvasHostUrl) : canvasHostUrl; + const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes; const helloOk = { type: "hello-ok", protocol: PROTOCOL_VERSION, @@ -1229,7 +1230,7 @@ export function attachGatewayWsMessageHandler(params: { canvasHostUrl: scopedCanvasHostUrl, auth: { role, - scopes, + scopes: helloOkAuthScopes, ...(deviceToken ? { deviceToken: deviceToken.token,