fix(ui): land #37382 from @FradSer

Separate shared gateway auth from cached device-token signing in Control UI browser auth. Preserves shared-token validation while keeping cached device tokens scoped to signed device payloads.

Co-authored-by: Frad LEE <fradser@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 22:33:24 +00:00
parent b4bac484e3
commit 8ca326caa9
3 changed files with 159 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<K extends keyof HandlerMap>(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",
}),
);
});
});

View File

@@ -178,15 +178,15 @@ export class GatewayBrowserClient {
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | 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);