refactor(gateway): dedupe auth and discord monitor suites

This commit is contained in:
Peter Steinberger 2026-03-02 21:30:43 +00:00
parent ab8b8dae70
commit 5f0cbd0edc
14 changed files with 434 additions and 500 deletions

View File

@ -8,31 +8,27 @@ describe("resolveDiscordDmCommandAccess", () => {
tag: "alice#0001",
};
it("allows open DMs and keeps command auth enabled without allowlist entries", async () => {
const result = await resolveDiscordDmCommandAccess({
async function resolveOpenDmAccess(configuredAllowFrom: string[]) {
return await resolveDiscordDmCommandAccess({
accountId: "default",
dmPolicy: "open",
configuredAllowFrom: [],
configuredAllowFrom,
sender,
allowNameMatching: false,
useAccessGroups: true,
readStoreAllowFrom: async () => [],
});
}
it("allows open DMs and keeps command auth enabled without allowlist entries", async () => {
const result = await resolveOpenDmAccess([]);
expect(result.decision).toBe("allow");
expect(result.commandAuthorized).toBe(true);
});
it("marks command auth true when sender is allowlisted", async () => {
const result = await resolveDiscordDmCommandAccess({
accountId: "default",
dmPolicy: "open",
configuredAllowFrom: ["discord:123"],
sender,
allowNameMatching: false,
useAccessGroups: true,
readStoreAllowFrom: async () => [],
});
const result = await resolveOpenDmAccess(["discord:123"]);
expect(result.decision).toBe("allow");
expect(result.commandAuthorized).toBe(true);

View File

@ -168,6 +168,18 @@ function getLastDispatchCtx():
return params?.ctx;
}
async function runProcessDiscordMessage(ctx: unknown): Promise<void> {
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
}
async function runInPartialStreamMode(): Promise<void> {
const ctx = await createBaseContext({
discordConfig: { streamMode: "partial" },
});
await runProcessDiscordMessage(ctx);
}
describe("processDiscordMessage ack reactions", () => {
it("skips ack reactions for group-mentions when mentions are not required", async () => {
const ctx = await createBaseContext({
@ -543,12 +555,7 @@ describe("processDiscordMessage draft streaming", () => {
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
});
const ctx = await createBaseContext({
discordConfig: { streamMode: "partial" },
});
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
await runInPartialStreamMode();
const updates = draftStream.update.mock.calls.map((call) => call[0]);
for (const text of updates) {
@ -567,12 +574,7 @@ describe("processDiscordMessage draft streaming", () => {
return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } };
});
const ctx = await createBaseContext({
discordConfig: { streamMode: "partial" },
});
// oxlint-disable-next-line typescript/no-explicit-any
await processDiscordMessage(ctx as any);
await runInPartialStreamMode();
expect(draftStream.update).not.toHaveBeenCalled();
});

View File

@ -167,6 +167,24 @@ async function runSubmitButton(params: {
return submitInteraction;
}
async function runModelSelect(params: {
context: ModelPickerContext;
data?: PickerSelectData;
userId?: string;
values?: string[];
}) {
const select = createDiscordModelPickerFallbackSelect(params.context);
const selectInteraction = createInteraction({
userId: params.userId ?? "owner",
values: params.values ?? ["gpt-4o"],
});
await select.run(
selectInteraction as unknown as PickerSelectInteraction,
params.data ?? createModelsViewSelectData(),
);
return selectInteraction;
}
function expectDispatchedModelSelection(params: {
dispatchSpy: { mock: { calls: Array<[unknown]> } };
model: string;
@ -270,15 +288,7 @@ describe("Discord model picker interactions", () => {
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
const select = createDiscordModelPickerFallbackSelect(context);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
const selectData = createModelsViewSelectData();
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
const selectInteraction = await runModelSelect({ context });
expect(selectInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled();
@ -315,15 +325,7 @@ describe("Discord model picker interactions", () => {
.spyOn(timeoutModule, "withTimeout")
.mockRejectedValue(new Error("timeout"));
const select = createDiscordModelPickerFallbackSelect(context);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
const selectData = createModelsViewSelectData();
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
await runModelSelect({ context });
const button = createDiscordModelPickerFallbackButton(context);
const submitInteraction = createInteraction({ userId: "owner" });

View File

@ -143,6 +143,11 @@ describe("runDiscordGatewayLifecycle", () => {
return { emitter, gateway };
}
async function emitGatewayOpenAndWait(emitter: EventEmitter, delayMs = 30000): Promise<void> {
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(delayMs);
}
it("cleans up thread bindings when exec approvals startup fails", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
@ -260,12 +265,9 @@ describe("runDiscordGatewayLifecycle", () => {
});
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(30000);
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(30000);
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(30000);
await emitGatewayOpenAndWait(emitter);
await emitGatewayOpenAndWait(emitter);
await emitGatewayOpenAndWait(emitter);
});
const { lifecycleParams } = createLifecycleHarness({ gateway });
@ -299,22 +301,17 @@ describe("runDiscordGatewayLifecycle", () => {
});
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
waitForDiscordGatewayStopMock.mockImplementationOnce(async () => {
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(30000);
await emitGatewayOpenAndWait(emitter);
// Successful reconnect (READY/RESUMED sets isConnected=true), then
// quick drop before the HELLO timeout window finishes.
gateway.isConnected = true;
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(10);
await emitGatewayOpenAndWait(emitter, 10);
emitter.emit("debug", "WebSocket connection closed with code 1006");
gateway.isConnected = false;
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(30000);
emitter.emit("debug", "WebSocket connection opened");
await vi.advanceTimersByTimeAsync(30000);
await emitGatewayOpenAndWait(emitter);
await emitGatewayOpenAndWait(emitter);
});
const { lifecycleParams } = createLifecycleHarness({ gateway });

View File

@ -38,24 +38,32 @@ async function fetchDiscordApplicationMe(
timeoutMs: number,
fetcher: typeof fetch,
): Promise<{ id?: string; flags?: number } | undefined> {
try {
const appResponse = await fetchDiscordApplicationMeResponse(token, timeoutMs, fetcher);
if (!appResponse || !appResponse.ok) {
return undefined;
}
return (await appResponse.json()) as { id?: string; flags?: number };
} catch {
return undefined;
}
}
async function fetchDiscordApplicationMeResponse(
token: string,
timeoutMs: number,
fetcher: typeof fetch,
): Promise<Response | undefined> {
const normalized = normalizeDiscordToken(token);
if (!normalized) {
return undefined;
}
try {
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/oauth2/applications/@me`,
{ headers: { Authorization: `Bot ${normalized}` } },
timeoutMs,
getResolvedFetch(fetcher),
);
if (!res.ok) {
return undefined;
}
return (await res.json()) as { id?: string; flags?: number };
} catch {
return undefined;
}
return await fetchWithTimeout(
`${DISCORD_API_BASE}/oauth2/applications/@me`,
{ headers: { Authorization: `Bot ${normalized}` } },
timeoutMs,
getResolvedFetch(fetcher),
);
}
export function resolveDiscordPrivilegedIntentsFromFlags(
@ -198,17 +206,14 @@ export async function fetchDiscordApplicationId(
timeoutMs: number,
fetcher: typeof fetch = fetch,
): Promise<string | undefined> {
const normalized = normalizeDiscordToken(token);
if (!normalized) {
if (!normalizeDiscordToken(token)) {
return undefined;
}
try {
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/oauth2/applications/@me`,
{ headers: { Authorization: `Bot ${normalized}` } },
timeoutMs,
getResolvedFetch(fetcher),
);
const res = await fetchDiscordApplicationMeResponse(token, timeoutMs, fetcher);
if (!res) {
return undefined;
}
if (res.ok) {
const json = (await res.json()) as { id?: string };
if (json?.id) {

View File

@ -19,6 +19,29 @@ function expectMismatch(
expect(result.code).toBe(code);
}
function expectV1BindingMatch(params: {
argv: string[];
requestCommand: string;
commandArgv?: string[];
}) {
const result = evaluateSystemRunApprovalMatch({
argv: params.argv,
request: {
host: "node",
command: params.requestCommand,
commandArgv: params.commandArgv,
systemRunBinding: buildSystemRunApprovalBinding({
argv: params.argv,
cwd: null,
agentId: null,
sessionKey: null,
}).binding,
},
binding: defaultBinding,
});
expect(result).toEqual({ ok: true });
}
describe("evaluateSystemRunApprovalMatch", () => {
test("rejects approvals that do not carry v1 binding", () => {
const result = evaluateSystemRunApprovalMatch({
@ -33,21 +56,10 @@ describe("evaluateSystemRunApprovalMatch", () => {
});
test("enforces exact argv binding in v1 object", () => {
const result = evaluateSystemRunApprovalMatch({
expectV1BindingMatch({
argv: ["echo", "SAFE"],
request: {
host: "node",
command: "echo SAFE",
systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"],
cwd: null,
agentId: null,
sessionKey: null,
}).binding,
},
binding: defaultBinding,
requestCommand: "echo SAFE",
});
expect(result).toEqual({ ok: true });
});
test("rejects argv mismatch in v1 object", () => {
@ -124,21 +136,10 @@ describe("evaluateSystemRunApprovalMatch", () => {
});
test("uses v1 binding even when legacy command text diverges", () => {
const result = evaluateSystemRunApprovalMatch({
expectV1BindingMatch({
argv: ["echo", "SAFE"],
request: {
host: "node",
command: "echo STALE",
commandArgv: ["echo STALE"],
systemRunBinding: buildSystemRunApprovalBinding({
argv: ["echo", "SAFE"],
cwd: null,
agentId: null,
sessionKey: null,
}).binding,
},
binding: defaultBinding,
requestCommand: "echo STALE",
commandArgv: ["echo STALE"],
});
expect(result).toEqual({ ok: true });
});
});

View File

@ -352,6 +352,26 @@ function respondWorkspaceFileInvalid(respond: RespondFn, name: string, reason: s
);
}
async function resolveWorkspaceFilePathOrRespond(params: {
respond: RespondFn;
workspaceDir: string;
name: string;
}): Promise<
| Exclude<Awaited<ReturnType<typeof resolveAgentWorkspaceFilePath>>, { kind: "invalid" }>
| undefined
> {
const resolvedPath = await resolveAgentWorkspaceFilePath({
workspaceDir: params.workspaceDir,
name: params.name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respondWorkspaceFileInvalid(params.respond, params.name, resolvedPath.reason);
return undefined;
}
return resolvedPath;
}
function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void {
respond(
false,
@ -629,13 +649,12 @@ export const agentsHandlers: GatewayRequestHandlers = {
}
const { agentId, workspaceDir, name } = resolved;
const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveAgentWorkspaceFilePath({
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
respond,
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respondWorkspaceFileInvalid(respond, name, resolvedPath.reason);
if (!resolvedPath) {
return;
}
if (resolvedPath.kind === "missing") {
@ -691,13 +710,12 @@ export const agentsHandlers: GatewayRequestHandlers = {
const { agentId, workspaceDir, name } = resolved;
await fs.mkdir(workspaceDir, { recursive: true });
const filePath = path.join(workspaceDir, name);
const resolvedPath = await resolveAgentWorkspaceFilePath({
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
respond,
workspaceDir,
name,
allowMissing: true,
});
if (resolvedPath.kind === "invalid") {
respondWorkspaceFileInvalid(respond, name, resolvedPath.reason);
if (!resolvedPath) {
return;
}
const content = String(params.content ?? "");

View File

@ -1,20 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { createSecretsHandlers } from "./secrets.js";
async function invokeSecretsReload(params: {
handlers: ReturnType<typeof createSecretsHandlers>;
respond: ReturnType<typeof vi.fn>;
}) {
await params.handlers["secrets.reload"]({
req: { type: "req", id: "1", method: "secrets.reload" },
params: {},
client: null,
isWebchatConnect: () => false,
respond: params.respond as unknown as Parameters<
ReturnType<typeof createSecretsHandlers>["secrets.reload"]
>[0]["respond"],
context: {} as never,
});
}
describe("secrets handlers", () => {
it("responds with warning count on successful reload", async () => {
const handlers = createSecretsHandlers({
reloadSecrets: vi.fn().mockResolvedValue({ warningCount: 2 }),
});
const respond = vi.fn();
await handlers["secrets.reload"]({
req: { type: "req", id: "1", method: "secrets.reload" },
params: {},
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
await invokeSecretsReload({ handlers, respond });
expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 2 });
});
@ -23,14 +32,7 @@ describe("secrets handlers", () => {
reloadSecrets: vi.fn().mockRejectedValue(new Error("reload failed")),
});
const respond = vi.fn();
await handlers["secrets.reload"]({
req: { type: "req", id: "1", method: "secrets.reload" },
params: {},
client: null,
isWebchatConnect: () => false,
respond,
context: {} as never,
});
await invokeSecretsReload({ handlers, respond });
expect(respond).toHaveBeenCalledWith(
false,
undefined,

View File

@ -3,7 +3,6 @@ import { WebSocket } from "ws";
import {
approvePendingPairingIfNeeded,
BACKEND_GATEWAY_CLIENT,
buildDeviceAuthPayload,
connectReq,
configureTrustedProxyControlUiAuth,
CONTROL_UI_CLIENT,
@ -64,6 +63,32 @@ export function registerControlUiAndPairingSuite(): void {
},
];
const buildSignedDeviceForIdentity = async (params: {
identityPath: string;
client: { id: string; mode: string };
nonce: string;
scopes: string[];
role?: "operator" | "node";
}) => {
const { device } = await createSignedDevice({
token: "secret",
scopes: params.scopes,
clientId: params.client.id,
clientMode: params.client.mode,
role: params.role ?? "operator",
identityPath: params.identityPath,
nonce: params.nonce,
});
return device;
};
const expectStatusAndHealthOk = async (ws: WebSocket) => {
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
};
for (const tc of trustedProxyControlUiCases) {
test(tc.name, async () => {
await configureTrustedProxyControlUiAuth();
@ -104,10 +129,7 @@ export function registerControlUiAndPairingSuite(): void {
return;
}
if (tc.expectStatusChecks) {
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
await expectStatusAndHealthOk(ws);
}
ws.close();
});
@ -122,18 +144,10 @@ export function registerControlUiAndPairingSuite(): void {
const res = await connectReq(ws, {
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(true);
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
await expectStatusAndHealthOk(ws);
ws.close();
await server.close();
restoreGatewayToken(prevToken);
@ -147,15 +161,10 @@ export function registerControlUiAndPairingSuite(): void {
const res = await connectReq(ws, {
password: "secret",
device: null,
client: {
...CONTROL_UI_CLIENT,
},
client: { ...CONTROL_UI_CLIENT },
});
expect(res.ok).toBe(true);
const status = await rpcReq(ws, "status");
expect(status.ok).toBe(true);
const health = await rpcReq(ws, "health");
expect(health.ok).toBe(true);
await expectStatusAndHealthOk(ws);
ws.close();
});
});
@ -408,39 +417,13 @@ export function registerControlUiAndPairingSuite(): void {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const client = {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role: "operator",
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const identityPath = join(identityDir, "device.json");
const identity = loadOrCreateDeviceIdentity(identityPath);
const client = { ...TEST_OPERATOR_CLIENT };
ws.close();
const wsRemoteRead = await openWs(port, { host: "gateway.example" });
@ -449,7 +432,12 @@ export function registerControlUiAndPairingSuite(): void {
token: "secret",
scopes: ["operator.read"],
client,
device: buildDevice(["operator.read"], initialNonce),
device: await buildSignedDeviceForIdentity({
identityPath,
client,
scopes: ["operator.read"],
nonce: initialNonce,
}),
});
expect(initial.ok).toBe(false);
expect(initial.error?.message ?? "").toContain("pairing required");
@ -469,7 +457,12 @@ export function registerControlUiAndPairingSuite(): void {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"], nonce2),
device: await buildSignedDeviceForIdentity({
identityPath,
client,
scopes: ["operator.admin"],
nonce: nonce2,
}),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("pairing required");
@ -491,35 +484,15 @@ export function registerControlUiAndPairingSuite(): void {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-token-scope-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const identityPath = join(identityDir, "device.json");
const identity = loadOrCreateDeviceIdentity(identityPath);
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: CONTROL_UI_CLIENT.id,
clientMode: CONTROL_UI_CLIENT.mode,
role: "operator",
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: devicePublicKey,
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const seeded = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: devicePublicKey,
@ -540,7 +513,12 @@ export function registerControlUiAndPairingSuite(): void {
token: "secret",
scopes: ["operator.admin"],
client: { ...CONTROL_UI_CLIENT },
device: buildDevice(["operator.admin"], nonce2),
device: await buildSignedDeviceForIdentity({
identityPath,
client: CONTROL_UI_CLIENT,
scopes: ["operator.admin"],
nonce: nonce2,
}),
});
expect(upgraded.ok).toBe(true);
const pending = await listDevicePairing();
@ -557,40 +535,15 @@ export function registerControlUiAndPairingSuite(): void {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const client = {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (role: "operator" | "node", scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role,
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const identityPath = join(identityDir, "device.json");
const identity = loadOrCreateDeviceIdentity(identityPath);
const client = { ...TEST_OPERATOR_CLIENT };
const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => {
const socket = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { host: "gateway.example" },
@ -609,7 +562,13 @@ export function registerControlUiAndPairingSuite(): void {
role,
scopes,
client,
device: buildDevice(role, scopes, String(nonce)),
device: await buildSignedDeviceForIdentity({
identityPath,
client,
role,
scopes,
nonce: String(nonce),
}),
});
socket.close();
return result;
@ -656,45 +615,25 @@ export function registerControlUiAndPairingSuite(): void {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { listDevicePairing } = await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-scope-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const client = {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role: "operator",
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const identityPath = join(identityDir, "device.json");
const identity = loadOrCreateDeviceIdentity(identityPath);
const client = { ...TEST_OPERATOR_CLIENT };
const initialNonce = await readConnectChallengeNonce(ws);
const initial = await connectReq(ws, {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"], initialNonce),
device: await buildSignedDeviceForIdentity({
identityPath,
client,
scopes: ["operator.admin"],
nonce: initialNonce,
}),
});
if (!initial.ok) {
await approvePendingPairingIfNeeded();
@ -708,7 +647,12 @@ export function registerControlUiAndPairingSuite(): void {
token: "secret",
scopes: ["operator.read"],
client,
device: buildDevice(["operator.read"], nonce2),
device: await buildSignedDeviceForIdentity({
identityPath,
client,
scopes: ["operator.read"],
nonce: nonce2,
}),
});
expect(res.ok).toBe(true);
ws2.close();
@ -724,15 +668,15 @@ export function registerControlUiAndPairingSuite(): void {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js");
const { writeJsonAtomic } = await import("../infra/json-files.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-meta-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const identityPath = join(identityDir, "device.json");
const identity = loadOrCreateDeviceIdentity(identityPath);
const deviceId = identity.deviceId;
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const pending = await requestDevicePairing({
@ -757,26 +701,6 @@ export function registerControlUiAndPairingSuite(): void {
delete legacy.scopes;
await writeJsonAtomic(pairedPath, paired);
const buildDevice = (nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId,
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
role: "operator",
scopes: ["operator.read"],
signedAtMs,
token: "secret",
nonce,
});
return {
id: deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const { server, ws, port, prevToken } = await startServerWithClient("secret");
let ws2: WebSocket | undefined;
try {
@ -789,7 +713,12 @@ export function registerControlUiAndPairingSuite(): void {
token: "secret",
scopes: ["operator.read"],
client: TEST_OPERATOR_CLIENT,
device: buildDevice(reconnectNonce),
device: await buildSignedDeviceForIdentity({
identityPath,
client: TEST_OPERATOR_CLIENT,
scopes: ["operator.read"],
nonce: reconnectNonce,
}),
});
expect(reconnect.ok).toBe(true);
@ -812,23 +741,21 @@ export function registerControlUiAndPairingSuite(): void {
const { join } = await import("node:path");
const { readJsonFile, resolvePairingPaths } = await import("../infra/pairing-files.js");
const { writeJsonAtomic } = await import("../infra/json-files.js");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
await import("../infra/device-pairing.js");
const { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } =
await import("../utils/message-channel.js");
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-"));
const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json"));
const identityPath = join(identityDir, "device.json");
const identity = loadOrCreateDeviceIdentity(identityPath);
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const seeded = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: devicePublicKey,
role: "operator",
scopes: ["operator.read"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
displayName: "legacy-upgrade-test",
platform: "test",
});
@ -848,32 +775,7 @@ export function registerControlUiAndPairingSuite(): void {
const { server, ws, port, prevToken } = await startServerWithClient("secret");
let ws2: WebSocket | undefined;
try {
const client = {
id: GATEWAY_CLIENT_NAMES.TEST,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
};
const buildDevice = (scopes: string[], nonce: string) => {
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
role: "operator",
scopes,
signedAtMs,
token: "secret",
nonce,
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
nonce,
};
};
const client = { ...TEST_OPERATOR_CLIENT };
ws.close();
@ -884,7 +786,12 @@ export function registerControlUiAndPairingSuite(): void {
token: "secret",
scopes: ["operator.admin"],
client,
device: buildDevice(["operator.admin"], upgradeNonce),
device: await buildSignedDeviceForIdentity({
identityPath,
client,
scopes: ["operator.admin"],
nonce: upgradeNonce,
}),
});
expect(upgraded.ok).toBe(true);
wsUpgrade.close();

View File

@ -37,6 +37,36 @@ export function registerDefaultAuthTokenSuite(): void {
await server.close();
});
async function expectNonceValidationError(params: {
connectId: string;
mutateNonce: (nonce: string) => string;
expectedMessage: string;
expectedCode: string;
expectedReason: string;
}) {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token,
scopes: ["operator.admin"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
nonce,
});
const connectRes = await sendRawConnectReq(ws, {
id: params.connectId,
token,
device: { ...device, nonce: params.mutateNonce(nonce) },
});
expect(connectRes.ok).toBe(false);
expect(connectRes.error?.message ?? "").toContain(params.expectedMessage);
expect(connectRes.error?.details?.code).toBe(params.expectedCode);
expect(connectRes.error?.details?.reason).toBe(params.expectedReason);
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
}
test("closes silent handshakes after timeout", async () => {
vi.useRealTimers();
const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
@ -316,55 +346,23 @@ export function registerDefaultAuthTokenSuite(): void {
});
test("returns nonce-required detail code when nonce is blank", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token,
scopes: ["operator.admin"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
nonce,
await expectNonceValidationError({
connectId: "c-blank-nonce",
mutateNonce: () => " ",
expectedMessage: "device nonce required",
expectedCode: ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED,
expectedReason: "device-nonce-missing",
});
const connectRes = await sendRawConnectReq(ws, {
id: "c-blank-nonce",
token,
device: { ...device, nonce: " " },
});
expect(connectRes.ok).toBe(false);
expect(connectRes.error?.message ?? "").toContain("device nonce required");
expect(connectRes.error?.details?.code).toBe(
ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED,
);
expect(connectRes.error?.details?.reason).toBe("device-nonce-missing");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("returns nonce-mismatch detail code when nonce does not match challenge", async () => {
const ws = await openWs(port);
const token = resolveGatewayTokenOrEnv();
const nonce = await readConnectChallengeNonce(ws);
const { device } = await createSignedDevice({
token,
scopes: ["operator.admin"],
clientId: TEST_OPERATOR_CLIENT.id,
clientMode: TEST_OPERATOR_CLIENT.mode,
nonce,
await expectNonceValidationError({
connectId: "c-wrong-nonce",
mutateNonce: (nonce) => `${nonce}-stale`,
expectedMessage: "device nonce mismatch",
expectedCode: ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH,
expectedReason: "device-nonce-mismatch",
});
const connectRes = await sendRawConnectReq(ws, {
id: "c-wrong-nonce",
token,
device: { ...device, nonce: `${nonce}-stale` },
});
expect(connectRes.ok).toBe(false);
expect(connectRes.error?.message ?? "").toContain("device nonce mismatch");
expect(connectRes.error?.details?.code).toBe(
ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH,
);
expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("invalid connect params surface in response and close reason", async () => {

View File

@ -8,6 +8,7 @@ import {
openWs,
originForPort,
rpcReq,
restoreGatewayToken,
startGatewayServer,
testState,
testTailscaleWhois,
@ -58,11 +59,7 @@ export function registerAuthModesSuite(): void {
afterAll(async () => {
await server.close();
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
restoreGatewayToken(prevToken);
});
test("rejects invalid token", async () => {
@ -119,11 +116,7 @@ export function registerAuthModesSuite(): void {
afterAll(async () => {
await server.close();
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
restoreGatewayToken(prevToken);
});
test("allows loopback connect without shared secret when mode is none", async () => {

View File

@ -191,6 +191,29 @@ describe("gateway server models + voicewake", () => {
}
};
const expectAllowlistedModels = async (options: {
primary: string;
models: Record<string, object>;
expected: ModelCatalogRpcEntry[];
}): Promise<void> => {
await withModelsConfig(
{
agents: {
defaults: {
model: { primary: options.primary },
models: options.models,
},
},
},
async () => {
seedPiCatalog();
const res = await listModels();
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual(options.expected);
},
);
};
test(
"voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 20_000 },
@ -294,66 +317,42 @@ describe("gateway server models + voicewake", () => {
});
test("models.list filters to allowlisted configured models by default", async () => {
await withModelsConfig(
{
agents: {
defaults: {
model: { primary: "openai/gpt-test-z" },
models: {
"openai/gpt-test-z": {},
"anthropic/claude-test-a": {},
},
},
await expectAllowlistedModels({
primary: "openai/gpt-test-z",
models: {
"openai/gpt-test-z": {},
"anthropic/claude-test-a": {},
},
expected: [
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
},
async () => {
seedPiCatalog();
const res = await listModels();
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual([
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
{
id: "gpt-test-z",
name: "gpt-test-z",
provider: "openai",
},
]);
},
);
{
id: "gpt-test-z",
name: "gpt-test-z",
provider: "openai",
},
],
});
});
test("models.list includes synthetic entries for allowlist models absent from catalog", async () => {
await withModelsConfig(
{
agents: {
defaults: {
model: { primary: "openai/not-in-catalog" },
models: {
"openai/not-in-catalog": {},
},
},
await expectAllowlistedModels({
primary: "openai/not-in-catalog",
models: {
"openai/not-in-catalog": {},
},
expected: [
{
id: "not-in-catalog",
name: "not-in-catalog",
provider: "openai",
},
},
async () => {
seedPiCatalog();
const res = await listModels();
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual([
{
id: "not-in-catalog",
name: "not-in-catalog",
provider: "openai",
},
]);
},
);
],
});
});
test("models.list rejects unknown params", async () => {

View File

@ -94,6 +94,39 @@ async function expectDiscordSessionTargetRequest(params: {
expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount);
}
async function expectSessionFilterRequestResult(params: {
sessionFilter: string[];
sessionKey: string;
expectedAccepted: boolean;
expectedDeliveryCount: number;
}) {
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "session",
sessionFilter: params.sessionFilter,
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
});
const request = {
...baseRequest,
request: {
...baseRequest.request,
sessionKey: params.sessionKey,
},
};
await expect(forwarder.handleRequested(request)).resolves.toBe(params.expectedAccepted);
expect(deliver).toHaveBeenCalledTimes(params.expectedDeliveryCount);
}
describe("exec approval forwarder", () => {
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
@ -167,59 +200,21 @@ describe("exec approval forwarder", () => {
});
it("rejects unsafe nested-repetition regex in sessionFilter", async () => {
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "session",
sessionFilter: ["(a+)+$"],
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
await expectSessionFilterRequestResult({
sessionFilter: ["(a+)+$"],
sessionKey: `${"a".repeat(28)}!`,
expectedAccepted: false,
expectedDeliveryCount: 0,
});
const request = {
...baseRequest,
request: {
...baseRequest.request,
sessionKey: `${"a".repeat(28)}!`,
},
};
await expect(forwarder.handleRequested(request)).resolves.toBe(false);
expect(deliver).not.toHaveBeenCalled();
});
it("matches long session keys with tail-bounded regex checks", async () => {
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "session",
sessionFilter: ["discord:tail$"],
},
},
} as OpenClawConfig;
const { deliver, forwarder } = createForwarder({
cfg,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
await expectSessionFilterRequestResult({
sessionFilter: ["discord:tail$"],
sessionKey: `${"x".repeat(5000)}discord:tail`,
expectedAccepted: true,
expectedDeliveryCount: 1,
});
const request = {
...baseRequest,
request: {
...baseRequest.request,
sessionKey: `${"x".repeat(5000)}discord:tail`,
},
};
await expect(forwarder.handleRequested(request)).resolves.toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
});
it("returns false when all targets are skipped", async () => {

View File

@ -46,6 +46,33 @@ function tryResolveRealpath(filePath: string | undefined): string | undefined {
}
}
function buildCommandResolution(params: {
rawExecutable: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
effectiveArgv: string[];
wrapperChain: string[];
policyBlocked: boolean;
blockedWrapper?: string;
}): CommandResolution {
const resolvedPath = resolveExecutableCandidatePath(params.rawExecutable, {
cwd: params.cwd,
env: params.env,
});
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : params.rawExecutable;
return {
rawExecutable: params.rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
effectiveArgv: params.effectiveArgv,
wrapperChain: params.wrapperChain,
policyBlocked: params.policyBlocked,
blockedWrapper: params.blockedWrapper,
};
}
export function resolveCommandResolution(
command: string,
cwd?: string,
@ -55,18 +82,14 @@ export function resolveCommandResolution(
if (!rawExecutable) {
return null;
}
const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { cwd, env });
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return {
return buildCommandResolution({
rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
effectiveArgv: [rawExecutable],
wrapperChain: [],
policyBlocked: false,
};
cwd,
env,
});
}
export function resolveCommandResolutionFromArgv(
@ -80,19 +103,15 @@ export function resolveCommandResolutionFromArgv(
if (!rawExecutable) {
return null;
}
const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { cwd, env });
const resolvedRealPath = tryResolveRealpath(resolvedPath);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return {
return buildCommandResolution({
rawExecutable,
resolvedPath,
resolvedRealPath,
executableName,
effectiveArgv,
wrapperChain: plan.wrappers,
policyBlocked: plan.policyBlocked,
blockedWrapper: plan.blockedWrapper,
};
cwd,
env,
});
}
function normalizeMatchTarget(value: string): string {