mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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.
|
||||
|
||||
154
ui/src/ui/gateway.node.test.ts
Normal file
154
ui/src/ui/gateway.node.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user