diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb511e4c64..54eb59255e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt. - Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker. - Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai. +- Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false `device token mismatch` disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer. - Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin. - Agents/transcript policy: set `preserveSignatures` to Anthropic-only handling in `resolveTranscriptPolicy` so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin. - Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin. diff --git a/ui/src/ui/gateway.node.test.ts b/ui/src/ui/gateway.node.test.ts new file mode 100644 index 00000000000..df921e8102b --- /dev/null +++ b/ui/src/ui/gateway.node.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const wsInstances = vi.hoisted((): MockWebSocket[] => []); +const buildDeviceAuthPayloadMock = vi.hoisted(() => vi.fn(() => "signed-payload")); +const clearDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); +const loadDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); +const storeDeviceAuthTokenMock = vi.hoisted(() => vi.fn()); +const loadOrCreateDeviceIdentityMock = vi.hoisted(() => vi.fn()); +const signDevicePayloadMock = vi.hoisted(() => vi.fn(async () => "signature")); +const generateUUIDMock = vi.hoisted(() => vi.fn(() => "req-1")); + +type HandlerMap = { + close: ((ev: { code: number; reason: string }) => void)[]; + error: (() => void)[]; + message: ((ev: { data: string }) => void)[]; + open: (() => void)[]; +}; + +class MockWebSocket { + static OPEN = 1; + + readonly handlers: HandlerMap = { + close: [], + error: [], + message: [], + open: [], + }; + + readonly sent: string[] = []; + readyState = MockWebSocket.OPEN; + + constructor(_url: string) { + wsInstances.push(this); + } + + addEventListener(type: K, handler: HandlerMap[K][number]) { + this.handlers[type].push(handler); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.readyState = 3; + } + + emitOpen() { + for (const handler of this.handlers.open) { + handler(); + } + } + + emitMessage(data: unknown) { + const payload = typeof data === "string" ? data : JSON.stringify(data); + for (const handler of this.handlers.message) { + handler({ data: payload }); + } + } +} + +vi.mock("../../../src/gateway/device-auth.js", () => ({ + buildDeviceAuthPayload: (...args: unknown[]) => buildDeviceAuthPayloadMock(...args), +})); + +vi.mock("./device-auth.ts", () => ({ + clearDeviceAuthToken: (...args: unknown[]) => clearDeviceAuthTokenMock(...args), + loadDeviceAuthToken: (...args: unknown[]) => loadDeviceAuthTokenMock(...args), + storeDeviceAuthToken: (...args: unknown[]) => storeDeviceAuthTokenMock(...args), +})); + +vi.mock("./device-identity.ts", () => ({ + loadOrCreateDeviceIdentity: (...args: unknown[]) => loadOrCreateDeviceIdentityMock(...args), + signDevicePayload: (...args: unknown[]) => signDevicePayloadMock(...args), +})); + +vi.mock("./uuid.ts", () => ({ + generateUUID: (...args: unknown[]) => generateUUIDMock(...args), +})); + +const { GatewayBrowserClient } = await import("./gateway.ts"); + +function getLatestWebSocket(): MockWebSocket { + const ws = wsInstances.at(-1); + if (!ws) { + throw new Error("missing websocket instance"); + } + return ws; +} + +describe("GatewayBrowserClient", () => { + beforeEach(() => { + wsInstances.length = 0; + buildDeviceAuthPayloadMock.mockClear(); + clearDeviceAuthTokenMock.mockClear(); + loadDeviceAuthTokenMock.mockReset(); + storeDeviceAuthTokenMock.mockClear(); + loadOrCreateDeviceIdentityMock.mockReset(); + signDevicePayloadMock.mockClear(); + generateUUIDMock.mockClear(); + + loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); + loadOrCreateDeviceIdentityMock.mockResolvedValue({ + deviceId: "device-1", + privateKey: "private-key", + publicKey: "public-key", + }); + + vi.stubGlobal("WebSocket", MockWebSocket); + vi.stubGlobal("crypto", { subtle: {} }); + vi.stubGlobal("navigator", { + language: "en-GB", + platform: "test-platform", + userAgent: "test-agent", + }); + vi.stubGlobal("window", { + clearTimeout: vi.fn(), + setTimeout: vi.fn(() => 1), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps shared auth token separate from cached device token", async () => { + const client = new GatewayBrowserClient({ + url: "ws://127.0.0.1:18789", + token: "shared-auth-token", + }); + + client.start(); + const ws = getLatestWebSocket(); + ws.emitOpen(); + ws.emitMessage({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-1" }, + }); + await Promise.resolve(); + + const connectFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { + method?: string; + params?: { auth?: { token?: string } }; + }; + expect(connectFrame.method).toBe("connect"); + expect(connectFrame.params?.auth?.token).toBe("shared-auth-token"); + expect(buildDeviceAuthPayloadMock).toHaveBeenCalledWith( + expect.objectContaining({ + token: "stored-device-token", + }), + ); + }); +}); diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index 43cfcf7ec71..01e5ec971d2 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -178,15 +178,15 @@ export class GatewayBrowserClient { let deviceIdentity: Awaited> | null = null; let canFallbackToShared = false; let authToken = this.opts.token; + let deviceToken: string | undefined; if (isSecureContext) { deviceIdentity = await loadOrCreateDeviceIdentity(); - const storedToken = loadDeviceAuthToken({ + deviceToken = loadDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, })?.token; - authToken = storedToken ?? this.opts.token; - canFallbackToShared = Boolean(storedToken && this.opts.token); + canFallbackToShared = Boolean(deviceToken && this.opts.token); } const auth = authToken || this.opts.password @@ -216,7 +216,7 @@ export class GatewayBrowserClient { role, scopes, signedAtMs, - token: authToken ?? null, + token: deviceToken ?? null, nonce, }); const signature = await signDevicePayload(deviceIdentity.privateKey, payload);