mirror of https://github.com/openclaw/openclaw.git
refactor(gateway): dedupe auth and discord monitor suites
This commit is contained in:
parent
ab8b8dae70
commit
5f0cbd0edc
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? "");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue