test: share gateway authz and watchdog fixtures

This commit is contained in:
Peter Steinberger
2026-03-26 16:36:03 +00:00
parent d9a7dcec4b
commit 03ea6953e0
6 changed files with 279 additions and 453 deletions

View File

@@ -21,6 +21,43 @@ async function getFreePort(): Promise<number> {
});
}
function createOpenGatewayClient(requestTimeoutMs: number): {
client: GatewayClient;
send: ReturnType<typeof vi.fn>;
} {
const client = new GatewayClient({
requestTimeoutMs,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
return { client, send };
}
function getPendingCount(client: GatewayClient): number {
return (client as unknown as { pending: Map<string, unknown> }).pending.size;
}
function trackSettlement(promise: Promise<unknown>): () => boolean {
let settled = false;
void promise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
return () => settled;
}
describe("GatewayClient", () => {
let wss: WebSocketServer | null = null;
let httpsServer: ReturnType<typeof createHttpsServer> | null = null;
@@ -111,31 +148,19 @@ describe("GatewayClient", () => {
test("times out unresolved requests and clears pending state", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const { client, send } = createOpenGatewayClient(25);
const requestPromise = client.request("status");
const requestExpectation = expect(requestPromise).rejects.toThrow(
"gateway request timeout for status",
);
expect(send).toHaveBeenCalledTimes(1);
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
expect(getPendingCount(client)).toBe(1);
await vi.advanceTimersByTimeAsync(25);
await requestExpectation;
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
expect(getPendingCount(client)).toBe(0);
} finally {
vi.useRealTimers();
}
@@ -144,36 +169,16 @@ describe("GatewayClient", () => {
test("does not auto-timeout expectFinal requests", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const { client, send } = createOpenGatewayClient(25);
let settled = false;
const requestPromise = client.request("chat.send", undefined, { expectFinal: true });
void requestPromise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
const isSettled = trackSettlement(requestPromise);
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(25);
expect(settled).toBe(false);
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
@@ -185,35 +190,15 @@ describe("GatewayClient", () => {
test("clamps oversized explicit request timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const { client } = createOpenGatewayClient(25);
let settled = false;
const requestPromise = client.request("status", undefined, { timeoutMs: 2_592_010_000 });
void requestPromise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
const isSettled = trackSettlement(requestPromise);
await vi.advanceTimersByTimeAsync(1);
expect(settled).toBe(false);
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
@@ -225,35 +210,15 @@ describe("GatewayClient", () => {
test("clamps oversized default request timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
requestTimeoutMs: 2_592_010_000,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const { client } = createOpenGatewayClient(2_592_010_000);
let settled = false;
const requestPromise = client.request("status");
void requestPromise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
const isSettled = trackSettlement(requestPromise);
await vi.advanceTimersByTimeAsync(1);
expect(settled).toBe(false);
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");

View File

@@ -0,0 +1,121 @@
import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { trackConnectChallengeNonce } from "./test-helpers.js";
export function resolveDeviceIdentityPath(name: string): string {
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(root, "test-device-identities", `${name}.json`);
}
export function loadDeviceIdentity(name: string): {
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
} {
const identityPath = resolveDeviceIdentityPath(name);
const identity = loadOrCreateDeviceIdentity(identityPath);
return {
identityPath,
identity,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
};
}
export async function pairDeviceIdentity(params: {
name: string;
role: "node" | "operator";
scopes: string[];
clientId?: string;
clientMode?: string;
}): Promise<{
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
}> {
const loaded = loadDeviceIdentity(params.name);
const request = await requestDevicePairing({
deviceId: loaded.identity.deviceId,
publicKey: loaded.publicKey,
role: params.role,
scopes: params.scopes,
clientId: params.clientId,
clientMode: params.clientMode,
});
await approveDevicePairing(request.request.requestId);
return loaded;
}
export async function issueOperatorToken(params: {
name: string;
approvedScopes: string[];
tokenScopes?: string[];
clientId?: string;
clientMode?: string;
}): Promise<{
deviceId: string;
identityPath: string;
token: string;
}> {
const paired = await pairDeviceIdentity({
name: params.name,
role: "operator",
scopes: params.approvedScopes,
clientId: params.clientId,
clientMode: params.clientMode,
});
if (params.tokenScopes) {
const rotated = await rotateDeviceToken({
deviceId: paired.identity.deviceId,
role: "operator",
scopes: params.tokenScopes,
});
expect(rotated.ok).toBe(true);
const token = rotated.ok ? rotated.entry.token : "";
expect(token).toBeTruthy();
return {
deviceId: paired.identity.deviceId,
identityPath: paired.identityPath,
token,
};
}
const device = await getPairedDevice(paired.identity.deviceId);
const token = device?.tokens?.operator?.token ?? "";
expect(token).toBeTruthy();
expect(device?.approvedScopes).toEqual(params.approvedScopes);
return {
deviceId: paired.identity.deviceId,
identityPath: paired.identityPath,
token,
};
}
export async function openTrackedWs(port: number): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
ws.once("open", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
return ws;
}

View File

@@ -83,6 +83,15 @@ let runtimeModule: PluginRuntimeModule;
let gatewayRequestScopeModule: GatewayRequestScopeModule;
let methodScopesModule: MethodScopesModule;
function createTestLog() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
}
function createTestContext(label: string): GatewayRequestContext {
return { label } as unknown as GatewayRequestContext;
}
@@ -115,12 +124,7 @@ async function createSubagentRuntime(
_serverPlugins: ServerPluginsModule,
cfg: Record<string, unknown> = {},
): Promise<PluginRuntime["subagent"]> {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const log = createTestLog();
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
serverPluginBootstrapModule.loadGatewayStartupPlugins({
cfg,
@@ -144,6 +148,36 @@ async function reloadServerPluginsModule(): Promise<ServerPluginsModule> {
return serverPluginsModule;
}
function loadGatewayPluginsForTest(
overrides: Partial<Parameters<ServerPluginsModule["loadGatewayPlugins"]>[0]> = {},
) {
const log = createTestLog();
serverPluginsModule.loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
...overrides,
});
return log;
}
function loadGatewayStartupPluginsForTest(
overrides: Partial<Parameters<ServerPluginBootstrapModule["loadGatewayStartupPlugins"]>[0]> = {},
) {
const log = createTestLog();
serverPluginBootstrapModule.loadGatewayStartupPlugins({
cfg: {},
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
...overrides,
});
return log;
}
beforeAll(async () => {
await loadTestModules();
});
@@ -180,7 +214,6 @@ afterEach(() => {
describe("loadGatewayPlugins", () => {
test("logs plugin errors with details", async () => {
const { loadGatewayStartupPlugins } = serverPluginBootstrapModule;
const diagnostics: PluginDiagnostic[] = [
{
level: "error",
@@ -190,21 +223,7 @@ describe("loadGatewayPlugins", () => {
},
];
loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
loadGatewayStartupPlugins({
cfg: {},
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
});
const log = loadGatewayStartupPluginsForTest();
expect(log.error).toHaveBeenCalledWith(
"[plugins] failed to load plugin: boom (plugin=telegram, source=/tmp/telegram/index.ts)",
@@ -213,23 +232,8 @@ describe("loadGatewayPlugins", () => {
});
test("loads only gateway startup plugin ids", async () => {
const { loadGatewayPlugins } = serverPluginsModule;
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
});
loadGatewayPluginsForTest();
expect(resolveGatewayStartupPluginIds).toHaveBeenCalledWith({
config: {},
@@ -244,23 +248,8 @@ describe("loadGatewayPlugins", () => {
});
test("provides subagent runtime with sessions.get method aliases", async () => {
const { loadGatewayPlugins } = serverPluginsModule;
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
});
loadGatewayPluginsForTest();
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
@@ -495,22 +484,8 @@ describe("loadGatewayPlugins", () => {
});
test("can prefer setup-runtime channel plugins during startup loads", async () => {
const { loadGatewayPlugins } = serverPluginsModule;
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
loadGatewayPlugins({
cfg: {},
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
loadGatewayPluginsForTest({
preferSetupRuntimeForChannelPlugins: true,
});
@@ -522,24 +497,9 @@ describe("loadGatewayPlugins", () => {
});
test("primes configured bindings during gateway startup", async () => {
const { loadGatewayStartupPlugins } = serverPluginBootstrapModule;
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const cfg = {};
loadGatewayStartupPlugins({
cfg,
workspaceDir: "/tmp",
log,
coreGatewayHandlers: {},
baseMethods: [],
});
loadGatewayStartupPluginsForTest({ cfg });
expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg });
});
@@ -555,13 +515,7 @@ describe("loadGatewayPlugins", () => {
},
];
loadOpenClawPlugins.mockReturnValue(createRegistry(diagnostics));
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const log = createTestLog();
reloadDeferredGatewayPlugins({
cfg: {},
@@ -590,10 +544,7 @@ describe("loadGatewayPlugins", () => {
cfg: {},
workspaceDir: "/tmp",
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
...createTestLog(),
},
coreGatewayHandlers: {},
baseMethods: [],

View File

@@ -1,97 +1,31 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
issueOperatorToken,
loadDeviceIdentity,
openTrackedWs,
} from "./device-authz.test-helpers.js";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
function resolveDeviceIdentityPath(name: string): string {
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(root, "test-device-identities", `${name}.json`);
}
function loadDeviceIdentity(name: string): {
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
} {
const identityPath = resolveDeviceIdentityPath(name);
const identity = loadOrCreateDeviceIdentity(identityPath);
return {
identityPath,
identity,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
};
}
async function issuePairingScopedOperator(name: string): Promise<{
identityPath: string;
deviceId: string;
token: string;
}> {
const loaded = loadDeviceIdentity(name);
const request = await requestDevicePairing({
deviceId: loaded.identity.deviceId,
publicKey: loaded.publicKey,
role: "operator",
scopes: ["operator.admin"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
await approveDevicePairing(request.request.requestId);
const rotated = await rotateDeviceToken({
deviceId: loaded.identity.deviceId,
role: "operator",
scopes: ["operator.pairing"],
});
expect(rotated.ok ? rotated.entry.token : "").toBeTruthy();
return {
identityPath: loaded.identityPath,
deviceId: loaded.identity.deviceId,
token: rotated.ok ? rotated.entry.token : "",
};
}
async function openTrackedWs(port: number): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
ws.once("open", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
return ws;
}
describe("gateway device.pair.approve caller scope guard", () => {
test("rejects approving device scopes above the caller session scopes", async () => {
const started = await startServerWithClient("secret");
const approver = await issuePairingScopedOperator("approve-attacker");
const approver = await issueOperatorToken({
name: "approve-attacker",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
const pending = loadDeviceIdentity("approve-target");
let pairingWs: WebSocket | undefined;

View File

@@ -1,119 +1,22 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
rotateDeviceToken,
} from "../infra/device-pairing.js";
import { getPairedDevice } from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import {
issueOperatorToken,
openTrackedWs,
pairDeviceIdentity,
} from "./device-authz.test-helpers.js";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
function resolveDeviceIdentityPath(name: string): string {
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(root, "test-device-identities", `${name}.json`);
}
function loadDeviceIdentity(name: string): {
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
} {
const identityPath = resolveDeviceIdentityPath(name);
const identity = loadOrCreateDeviceIdentity(identityPath);
return {
identityPath,
identity,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
};
}
async function pairDevice(params: {
name: string;
role: "node" | "operator";
scopes: string[];
clientId?: string;
clientMode?: string;
}): Promise<{
identityPath: string;
identity: DeviceIdentity;
}> {
const loaded = loadDeviceIdentity(params.name);
const request = await requestDevicePairing({
deviceId: loaded.identity.deviceId,
publicKey: loaded.publicKey,
role: params.role,
scopes: params.scopes,
clientId: params.clientId,
clientMode: params.clientMode,
});
await approveDevicePairing(request.request.requestId);
return {
identityPath: loaded.identityPath,
identity: loaded.identity,
};
}
async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Promise<{
deviceId: string;
identityPath: string;
pairingToken: string;
}> {
const paired = await pairDevice({
name,
role: "operator",
scopes: ["operator.admin"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
const rotated = await rotateDeviceToken({
deviceId: paired.identity.deviceId,
role: "operator",
scopes: ["operator.pairing"],
});
expect(rotated.ok).toBe(true);
const pairingToken = rotated.ok ? rotated.entry.token : "";
expect(pairingToken).toBeTruthy();
return {
deviceId: paired.identity.deviceId,
identityPath: paired.identityPath,
pairingToken,
};
}
async function openTrackedWs(port: number): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
ws.once("open", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
return ws;
}
async function connectPairingScopedOperator(params: {
port: number;
identityPath: string;
@@ -134,7 +37,7 @@ async function connectApprovedNode(params: {
name: string;
onInvoke: (payload: unknown) => void;
}): Promise<GatewayClient> {
const paired = await pairDevice({
const paired = await pairDeviceIdentity({
name: params.name,
role: "node",
scopes: [],
@@ -207,14 +110,20 @@ async function waitForMacrotasks(): Promise<void> {
describe("gateway device.token.rotate caller scope guard", () => {
test("rejects rotating an admin-approved device token above the caller session scopes", async () => {
const started = await startServerWithClient("secret");
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-attacker");
const attacker = await issueOperatorToken({
name: "rotate-attacker",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
let pairingWs: WebSocket | undefined;
try {
pairingWs = await connectPairingScopedOperator({
port: started.port,
identityPath: attacker.identityPath,
deviceToken: attacker.pairingToken,
deviceToken: attacker.token,
});
const rotate = await rpcReq(pairingWs, "device.token.rotate", {
@@ -238,7 +147,13 @@ describe("gateway device.token.rotate caller scope guard", () => {
test("blocks the pairing-token to admin-node-invoke escalation chain", async () => {
const started = await startServerWithClient("secret");
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-rce-attacker");
const attacker = await issueOperatorToken({
name: "rotate-rce-attacker",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
let sawInvoke = false;
let pairingWs: WebSocket | undefined;
@@ -258,7 +173,7 @@ describe("gateway device.token.rotate caller scope guard", () => {
pairingWs = await connectPairingScopedOperator({
port: started.port,
identityPath: attacker.identityPath,
deviceToken: attacker.pairingToken,
deviceToken: attacker.token,
});
const rotate = await rpcReq<{ token?: string }>(pairingWs, "device.token.rotate", {
@@ -274,7 +189,7 @@ describe("gateway device.token.rotate caller scope guard", () => {
const paired = await getPairedDevice(attacker.deviceId);
expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]);
expect(paired?.tokens?.operator?.token).toBe(attacker.pairingToken);
expect(paired?.tokens?.operator?.token).toBe(attacker.token);
} finally {
pairingWs?.close();
nodeClient?.stop();
@@ -286,14 +201,20 @@ describe("gateway device.token.rotate caller scope guard", () => {
test("returns the same public deny for unknown devices and caller scope failures", async () => {
const started = await startServerWithClient("secret");
const attacker = await issuePairingScopedTokenForAdminApprovedDevice("rotate-deny-shape");
const attacker = await issueOperatorToken({
name: "rotate-deny-shape",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
let pairingWs: WebSocket | undefined;
try {
pairingWs = await connectPairingScopedOperator({
port: started.port,
identityPath: attacker.identityPath,
deviceToken: attacker.pairingToken,
deviceToken: attacker.token,
});
const missingScope = await rpcReq(pairingWs, "device.token.rotate", {

View File

@@ -1,98 +1,32 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
type DeviceIdentity,
} from "../infra/device-identity.js";
import * as devicePairingModule from "../infra/device-pairing.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { getPairedDevice } from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
issueOperatorToken,
loadDeviceIdentity,
openTrackedWs,
} from "./device-authz.test-helpers.js";
import {
connectOk,
connectReq,
installGatewayTestHooks,
onceMessage,
startServerWithClient,
trackConnectChallengeNonce,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
function resolveDeviceIdentityPath(name: string): string {
const root = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(root, "test-device-identities", `${name}.json`);
}
function loadDeviceIdentity(name: string): {
identityPath: string;
identity: DeviceIdentity;
publicKey: string;
} {
const identityPath = resolveDeviceIdentityPath(name);
const identity = loadOrCreateDeviceIdentity(identityPath);
return {
identityPath,
identity,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
};
}
async function pairReadScopedOperator(name: string): Promise<{
deviceId: string;
identityPath: string;
deviceToken: string;
}> {
const loaded = loadDeviceIdentity(name);
const request = await requestDevicePairing({
deviceId: loaded.identity.deviceId,
publicKey: loaded.publicKey,
role: "operator",
scopes: ["operator.read"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
await approveDevicePairing(request.request.requestId);
const paired = await getPairedDevice(loaded.identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token ?? "";
expect(deviceToken).toBeTruthy();
expect(paired?.approvedScopes).toEqual(["operator.read"]);
return {
deviceId: loaded.identity.deviceId,
identityPath: loaded.identityPath,
deviceToken,
};
}
async function openTrackedWs(port: number): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 5_000);
ws.once("open", () => {
clearTimeout(timer);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
return ws;
}
describe("gateway silent scope-upgrade reconnect", () => {
test("does not silently widen a read-scoped paired device to admin on shared-auth reconnect", async () => {
const started = await startServerWithClient("secret");
const paired = await pairReadScopedOperator("silent-scope-upgrade-reconnect-poc");
const paired = await issueOperatorToken({
name: "silent-scope-upgrade-reconnect-poc",
approvedScopes: ["operator.read"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
let watcherWs: WebSocket | undefined;
let sharedAuthReconnectWs: WebSocket | undefined;
@@ -130,12 +64,12 @@ describe("gateway silent scope-upgrade reconnect", () => {
const afterUpgradeAttempt = await getPairedDevice(paired.deviceId);
expect(afterUpgradeAttempt?.approvedScopes).toEqual(["operator.read"]);
expect(afterUpgradeAttempt?.tokens?.operator?.scopes).toEqual(["operator.read"]);
expect(afterUpgradeAttempt?.tokens?.operator?.token).toBe(paired.deviceToken);
expect(afterUpgradeAttempt?.tokens?.operator?.token).toBe(paired.token);
postAttemptDeviceTokenWs = await openTrackedWs(started.port);
const afterUpgrade = await connectReq(postAttemptDeviceTokenWs, {
skipDefaultAuth: true,
deviceToken: paired.deviceToken,
deviceToken: paired.token,
deviceIdentityPath: paired.identityPath,
scopes: ["operator.admin"],
});