mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createCopilotTokenStore,
|
|
loadOrCreateCopilotIdentity,
|
|
resolveCopilotClose,
|
|
} from "./copilot-gateway-lifecycle.js";
|
|
import { CopilotGatewayClient, isDefinitiveGatewayRejection } from "./copilot-gateway.js";
|
|
import { GatewayProtocolRequestError } from "./copilot-runtime.js";
|
|
|
|
function storageArea() {
|
|
const values: Record<string, unknown> = {};
|
|
return {
|
|
async get(keys: string[]) {
|
|
return Object.fromEntries(keys.map((key) => [key, values[key]]));
|
|
},
|
|
async set(update: Record<string, unknown>) {
|
|
Object.assign(values, update);
|
|
},
|
|
};
|
|
}
|
|
|
|
function controllableStorageArea() {
|
|
const values: Record<string, unknown> = {};
|
|
let nextWrite: { release: Promise<void>; started: () => void } | undefined;
|
|
const storage = {
|
|
get: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key]]))),
|
|
set: vi.fn(async (update: Record<string, unknown>) => {
|
|
const blocked = nextWrite;
|
|
nextWrite = undefined;
|
|
if (blocked) {
|
|
blocked.started();
|
|
await blocked.release;
|
|
}
|
|
Object.assign(values, update);
|
|
}),
|
|
};
|
|
return {
|
|
storage,
|
|
blockNextWrite() {
|
|
let markStarted: (() => void) | undefined;
|
|
let releaseWrite: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const release = new Promise<void>((resolve) => {
|
|
releaseWrite = resolve;
|
|
});
|
|
nextWrite = { release, started: () => markStarted?.() };
|
|
return { started, release: () => releaseWrite?.() };
|
|
},
|
|
};
|
|
}
|
|
|
|
class FakeWebSocket {
|
|
static OPEN = 1;
|
|
static instances: FakeWebSocket[] = [];
|
|
static autoOpen = true;
|
|
|
|
readyState = 0;
|
|
sent: Array<Record<string, unknown>> = [];
|
|
closeCalls: Array<{ code: number; reason: string }> = [];
|
|
private listeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
|
|
|
|
constructor() {
|
|
FakeWebSocket.instances.push(this);
|
|
queueMicrotask(() => {
|
|
if (!FakeWebSocket.autoOpen || this.readyState === 3) {
|
|
return;
|
|
}
|
|
this.readyState = FakeWebSocket.OPEN;
|
|
this.emit("open", {});
|
|
});
|
|
}
|
|
|
|
addEventListener(name: string, listener: (event: Record<string, unknown>) => void) {
|
|
const listeners = this.listeners.get(name) ?? new Set();
|
|
listeners.add(listener);
|
|
this.listeners.set(name, listeners);
|
|
}
|
|
|
|
send(data: string) {
|
|
this.sent.push(JSON.parse(data) as Record<string, unknown>);
|
|
}
|
|
|
|
close(code = 1000, reason = "") {
|
|
if (code !== 1000 && (code < 3000 || code > 4999)) {
|
|
throw new DOMException("Invalid WebSocket close code", "InvalidAccessError");
|
|
}
|
|
if (this.readyState === 3) {
|
|
return;
|
|
}
|
|
this.closeCalls.push({ code, reason });
|
|
this.readyState = 3;
|
|
queueMicrotask(() => this.emit("close", { code, reason }));
|
|
}
|
|
|
|
message(frame: Record<string, unknown>) {
|
|
this.emit("message", { data: JSON.stringify(frame) });
|
|
}
|
|
|
|
private emit(name: string, event: Record<string, unknown>) {
|
|
for (const listener of this.listeners.get(name) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("browser copilot Gateway custody", () => {
|
|
it("scopes device identities and issued tokens to one Gateway", async () => {
|
|
const storage = storageArea();
|
|
const gatewayA = "ws://127.0.0.1:18789/";
|
|
const gatewayB = "ws://127.0.0.1:28789/";
|
|
const identityA = await loadOrCreateCopilotIdentity(storage, gatewayA);
|
|
const identityAAgain = await loadOrCreateCopilotIdentity(storage, gatewayA);
|
|
const identityB = await loadOrCreateCopilotIdentity(storage, gatewayB);
|
|
|
|
expect(identityAAgain.deviceId).toBe(identityA.deviceId);
|
|
expect(identityB.deviceId).not.toBe(identityA.deviceId);
|
|
|
|
const tokenParams = {
|
|
clientId: "openclaw-browser-copilot",
|
|
deviceId: identityA.deviceId,
|
|
role: "operator",
|
|
};
|
|
const tokenA = createCopilotTokenStore(storage, gatewayA);
|
|
const tokenB = createCopilotTokenStore(storage, gatewayB);
|
|
await tokenA.store({ ...tokenParams, token: "test-token", scopes: ["operator.read"] });
|
|
|
|
await expect(tokenA.load(tokenParams)).resolves.toEqual({
|
|
token: "test-token",
|
|
scopes: ["operator.read"],
|
|
});
|
|
await expect(tokenB.load(tokenParams)).resolves.toBeNull();
|
|
});
|
|
|
|
it("serializes shared credential maps across concurrent Gateway clients", async () => {
|
|
const controlled = controllableStorageArea();
|
|
const gatewayA = "ws://127.0.0.1:18789/";
|
|
const gatewayB = "ws://127.0.0.1:28789/";
|
|
|
|
const identityWrite = controlled.blockNextWrite();
|
|
const firstIdentity = loadOrCreateCopilotIdentity(controlled.storage, gatewayA);
|
|
await identityWrite.started;
|
|
const secondIdentity = loadOrCreateCopilotIdentity(controlled.storage, gatewayB);
|
|
identityWrite.release();
|
|
const [identityA, identityB] = await Promise.all([firstIdentity, secondIdentity]);
|
|
await expect(loadOrCreateCopilotIdentity(controlled.storage, gatewayA)).resolves.toMatchObject({
|
|
deviceId: identityA.deviceId,
|
|
});
|
|
await expect(loadOrCreateCopilotIdentity(controlled.storage, gatewayB)).resolves.toMatchObject({
|
|
deviceId: identityB.deviceId,
|
|
});
|
|
|
|
const tokenParams = (deviceId: string) => ({
|
|
clientId: "openclaw-browser-copilot",
|
|
deviceId,
|
|
role: "operator",
|
|
});
|
|
const tokenA = createCopilotTokenStore(controlled.storage, gatewayA);
|
|
const tokenB = createCopilotTokenStore(controlled.storage, gatewayB);
|
|
const storeGate = controlled.blockNextWrite();
|
|
const firstStore = tokenA.store({
|
|
...tokenParams(identityA.deviceId),
|
|
token: "test-token-placeholder",
|
|
scopes: ["operator.read"],
|
|
});
|
|
await storeGate.started;
|
|
const secondStore = tokenB.store({
|
|
...tokenParams(identityB.deviceId),
|
|
token: "test-token-placeholder",
|
|
scopes: ["operator.write"],
|
|
});
|
|
storeGate.release();
|
|
await Promise.all([firstStore, secondStore]);
|
|
await expect(tokenA.load(tokenParams(identityA.deviceId))).resolves.toMatchObject({
|
|
token: "test-token-placeholder",
|
|
});
|
|
await expect(tokenB.load(tokenParams(identityB.deviceId))).resolves.toMatchObject({
|
|
token: "test-token-placeholder",
|
|
});
|
|
|
|
const replacementWrite = controlled.blockNextWrite();
|
|
const replacing = tokenA.store({
|
|
...tokenParams(identityA.deviceId),
|
|
token: "test-token-placeholder",
|
|
scopes: ["operator.read", "operator.write"],
|
|
});
|
|
await replacementWrite.started;
|
|
const clearing = tokenA.clear(tokenParams(identityA.deviceId));
|
|
replacementWrite.release();
|
|
await Promise.all([replacing, clearing]);
|
|
await expect(tokenA.load(tokenParams(identityA.deviceId))).resolves.toBeNull();
|
|
});
|
|
|
|
it("keeps the pairing approval state when the failed socket closes", () => {
|
|
const error = { details: { code: "PAIRING_REQUIRED", pauseReconnect: true } };
|
|
|
|
expect(resolveCopilotClose({ connectFailure: { error } })).toEqual({
|
|
retry: true,
|
|
notify: false,
|
|
pendingError: error,
|
|
});
|
|
expect(
|
|
resolveCopilotClose({
|
|
connectFailure: { error: { details: { pauseReconnect: true } } },
|
|
}).retry,
|
|
).toBe(false);
|
|
expect(
|
|
resolveCopilotClose({
|
|
connectFailure: {
|
|
error: {
|
|
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH", pauseReconnect: false },
|
|
},
|
|
},
|
|
}).retry,
|
|
).toBe(false);
|
|
expect(resolveCopilotClose({})).toEqual({
|
|
retry: true,
|
|
notify: true,
|
|
pendingError: undefined,
|
|
});
|
|
});
|
|
|
|
it("clears a rejected device token before starting a fresh connection", async () => {
|
|
const values: Record<string, unknown> = {};
|
|
let releaseClear: (() => void) | undefined;
|
|
let markClearStarted: (() => void) | undefined;
|
|
const clearStarted = new Promise<void>((resolve) => {
|
|
markClearStarted = resolve;
|
|
});
|
|
let blockNextSet = false;
|
|
const storage = {
|
|
async get(keys: string[]) {
|
|
return Object.fromEntries(keys.map((key) => [key, values[key]]));
|
|
},
|
|
async set(update: Record<string, unknown>) {
|
|
if (blockNextSet) {
|
|
blockNextSet = false;
|
|
markClearStarted?.();
|
|
await new Promise<void>((resolve) => {
|
|
releaseClear = resolve;
|
|
});
|
|
}
|
|
Object.assign(values, update);
|
|
},
|
|
};
|
|
const gatewayScope = "ws://127.0.0.1:18789/";
|
|
const identity = await loadOrCreateCopilotIdentity(storage, gatewayScope);
|
|
const tokenStore = createCopilotTokenStore(storage, gatewayScope);
|
|
const tokenParams = {
|
|
clientId: "openclaw-browser-copilot",
|
|
deviceId: identity.deviceId,
|
|
role: "operator",
|
|
};
|
|
await tokenStore.store({
|
|
...tokenParams,
|
|
token: "test-token",
|
|
scopes: ["operator.read", "operator.write"],
|
|
});
|
|
blockNextSet = true;
|
|
FakeWebSocket.instances = [];
|
|
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
|
|
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
|
|
const client = new CopilotGatewayClient({
|
|
storage,
|
|
WebSocketImpl: FakeWebSocket as never,
|
|
});
|
|
|
|
try {
|
|
client.start(gatewayScope);
|
|
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
|
const first = FakeWebSocket.instances[0];
|
|
first?.message({
|
|
type: "event",
|
|
event: "connect.challenge",
|
|
payload: { nonce: "first-nonce", ts: 1_777_777_777_000 },
|
|
});
|
|
await vi.waitFor(() => expect(first?.sent).toHaveLength(1));
|
|
const firstConnect = first?.sent[0] as {
|
|
id?: string;
|
|
params?: { auth?: { token?: string } };
|
|
};
|
|
expect(firstConnect.params?.auth?.token).toBe("test-token");
|
|
first?.message({
|
|
type: "res",
|
|
id: firstConnect.id,
|
|
ok: false,
|
|
error: {
|
|
code: "UNAVAILABLE",
|
|
message: "device token rejected",
|
|
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH", pauseReconnect: true },
|
|
},
|
|
});
|
|
await clearStarted;
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
|
|
releaseClear?.();
|
|
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2));
|
|
const second = FakeWebSocket.instances[1];
|
|
second?.message({
|
|
type: "event",
|
|
event: "connect.challenge",
|
|
payload: { nonce: "second-nonce", ts: 1_777_777_778_000 },
|
|
});
|
|
await vi.waitFor(() => expect(second?.sent).toHaveLength(1));
|
|
const secondConnect = second?.sent[0] as { params?: { auth?: { token?: string } } };
|
|
expect(secondConnect.params?.auth?.token).toBeUndefined();
|
|
await expect(tokenStore.load(tokenParams)).resolves.toBeNull();
|
|
} finally {
|
|
releaseClear?.();
|
|
client.stop();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("reconnects after a malformed challenge with a browser-valid policy close", async () => {
|
|
vi.useFakeTimers();
|
|
FakeWebSocket.instances = [];
|
|
FakeWebSocket.autoOpen = true;
|
|
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
|
|
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
|
|
const client = new CopilotGatewayClient({
|
|
storage: storageArea(),
|
|
WebSocketImpl: FakeWebSocket as never,
|
|
});
|
|
|
|
try {
|
|
client.start("ws://127.0.0.1:28789/");
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
const first = FakeWebSocket.instances[0];
|
|
expect(first).toBeDefined();
|
|
|
|
first?.message({ type: "event", event: "connect.challenge", payload: {} });
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
expect(first?.closeCalls).toContainEqual({
|
|
code: 4008,
|
|
reason: "connect challenge missing nonce",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
} finally {
|
|
client.stop();
|
|
FakeWebSocket.autoOpen = true;
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("rejects a device challenge with a malformed Gateway timestamp", async () => {
|
|
FakeWebSocket.instances = [];
|
|
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
|
|
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
|
|
const client = new CopilotGatewayClient({
|
|
storage: storageArea(),
|
|
WebSocketImpl: FakeWebSocket as never,
|
|
});
|
|
|
|
try {
|
|
client.start("ws://127.0.0.1:28789/");
|
|
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
|
|
const socket = FakeWebSocket.instances[0];
|
|
socket?.message({
|
|
type: "event",
|
|
event: "connect.challenge",
|
|
payload: { nonce: "invalid-time", ts: "not-a-number" },
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(socket?.closeCalls).toContainEqual({
|
|
code: 4008,
|
|
reason: "connect failed",
|
|
}),
|
|
);
|
|
expect(socket?.sent).toHaveLength(0);
|
|
} finally {
|
|
client.stop();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("closes and reconnects when the browser socket never opens", async () => {
|
|
vi.useFakeTimers();
|
|
FakeWebSocket.instances = [];
|
|
FakeWebSocket.autoOpen = false;
|
|
vi.stubGlobal("chrome", { runtime: { getManifest: () => ({ version: "test" }) } });
|
|
vi.stubGlobal("navigator", { language: "en", userAgent: "copilot-test" });
|
|
const client = new CopilotGatewayClient({
|
|
storage: storageArea(),
|
|
WebSocketImpl: FakeWebSocket as never,
|
|
});
|
|
const statuses: Array<Record<string, unknown>> = [];
|
|
client.onStatus((status) => {
|
|
statuses.push(status);
|
|
});
|
|
|
|
try {
|
|
client.start("ws://127.0.0.1:28789/");
|
|
await vi.advanceTimersByTimeAsync(15_000);
|
|
|
|
expect(statuses).toContainEqual(
|
|
expect.objectContaining({
|
|
state: "error",
|
|
label: "Gateway WebSocket opening timed out after 15000ms",
|
|
}),
|
|
);
|
|
expect(FakeWebSocket.instances[0]?.closeCalls).toEqual([{ code: 1000, reason: "" }]);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
} finally {
|
|
client.stop();
|
|
FakeWebSocket.autoOpen = true;
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("distinguishes server rejection from ambiguous transport failure", () => {
|
|
expect(
|
|
isDefinitiveGatewayRejection(
|
|
new GatewayProtocolRequestError({ code: "INVALID_REQUEST", message: "fixture rejection" }),
|
|
),
|
|
).toBe(true);
|
|
expect(isDefinitiveGatewayRejection(new Error("fixture socket closed"))).toBe(false);
|
|
});
|
|
});
|