test(gateway): dedupe gateway and infra test scaffolds

This commit is contained in:
Peter Steinberger
2026-03-02 06:41:22 +00:00
parent cded1b960a
commit d3e0c0b29c
14 changed files with 1126 additions and 1693 deletions

View File

@@ -121,32 +121,39 @@ async function getFreeGatewayPort(): Promise<number> {
async function connectClient(params: { url: string; token: string }) { async function connectClient(params: { url: string; token: string }) {
return await new Promise<GatewayClient>((resolve, reject) => { return await new Promise<GatewayClient>((resolve, reject) => {
let settled = false; let done = false;
const stop = (err?: Error, client?: GatewayClient) => { const finish = (result: { client?: GatewayClient; error?: Error }) => {
if (settled) { if (done) {
return; return;
} }
settled = true; done = true;
clearTimeout(timer); clearTimeout(connectTimeout);
if (err) { if (result.error) {
reject(err); reject(result.error);
} else { return;
resolve(client as GatewayClient);
} }
resolve(result.client as GatewayClient);
}; };
const failWithClose = (code: number, reason: string) =>
finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) });
const client = new GatewayClient({ const client = new GatewayClient({
url: params.url, url: params.url,
token: params.token, token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST, clientName: GATEWAY_CLIENT_NAMES.TEST,
clientVersion: "dev", clientVersion: "dev",
mode: "test", mode: "test",
onHelloOk: () => stop(undefined, client), onHelloOk: () => finish({ client }),
onConnectError: (err) => stop(err), onConnectError: (error) => finish({ error }),
onClose: (code, reason) => onClose: failWithClose,
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
}); });
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref(); const connectTimeout = setTimeout(
() => finish({ error: new Error("gateway connect timeout") }),
10_000,
);
connectTimeout.unref();
client.start(); client.start();
}); });
} }

View File

@@ -325,20 +325,33 @@ describe("gateway agent handler", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("passes senderIsOwner=false for write-scoped gateway callers", async () => { it.each([
{
name: "passes senderIsOwner=false for write-scoped gateway callers",
scopes: ["operator.write"],
idempotencyKey: "test-sender-owner-write",
senderIsOwner: false,
},
{
name: "passes senderIsOwner=true for admin-scoped gateway callers",
scopes: ["operator.admin"],
idempotencyKey: "test-sender-owner-admin",
senderIsOwner: true,
},
])("$name", async ({ scopes, idempotencyKey, senderIsOwner }) => {
primeMainAgentRun(); primeMainAgentRun();
await invokeAgent( await invokeAgent(
{ {
message: "owner-tools check", message: "owner-tools check",
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
idempotencyKey: "test-sender-owner-write", idempotencyKey,
}, },
{ {
client: { client: {
connect: { connect: {
role: "operator", role: "operator",
scopes: ["operator.write"], scopes,
client: { id: "test-client", mode: "gateway" }, client: { id: "test-client", mode: "gateway" },
}, },
} as unknown as AgentHandlerArgs["client"], } as unknown as AgentHandlerArgs["client"],
@@ -349,34 +362,7 @@ describe("gateway agent handler", () => {
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
| { senderIsOwner?: boolean } | { senderIsOwner?: boolean }
| undefined; | undefined;
expect(callArgs?.senderIsOwner).toBe(false); expect(callArgs?.senderIsOwner).toBe(senderIsOwner);
});
it("passes senderIsOwner=true for admin-scoped gateway callers", async () => {
primeMainAgentRun();
await invokeAgent(
{
message: "owner-tools check",
sessionKey: "agent:main:main",
idempotencyKey: "test-sender-owner-admin",
},
{
client: {
connect: {
role: "operator",
scopes: ["operator.admin"],
client: { id: "test-client", mode: "gateway" },
},
} as unknown as AgentHandlerArgs["client"],
},
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(callArgs?.senderIsOwner).toBe(true);
}); });
it("respects explicit bestEffortDeliver=false for main session runs", async () => { it("respects explicit bestEffortDeliver=false for main session runs", async () => {

View File

@@ -201,6 +201,20 @@ function expectNotFoundResponseAndNoWrite(respond: ReturnType<typeof vi.fn>) {
expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled();
} }
async function expectUnsafeWorkspaceFile(method: "agents.files.get" | "agents.files.set") {
const params =
method === "agents.files.set"
? { agentId: "main", name: "AGENTS.md", content: "x" }
: { agentId: "main", name: "AGENTS.md" };
const { respond, promise } = makeCall(method, params);
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
}
beforeEach(() => { beforeEach(() => {
mocks.fsReadFile.mockImplementation(async () => { mocks.fsReadFile.mockImplementation(async () => {
throw createEnoentError(); throw createEnoentError();
@@ -517,7 +531,7 @@ describe("agents.files.get/set symlink safety", () => {
mocks.fsMkdir.mockResolvedValue(undefined); mocks.fsMkdir.mockResolvedValue(undefined);
}); });
it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => { function mockWorkspaceEscapeSymlink() {
const workspace = "/workspace/test-agent"; const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md"); const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => { mocks.fsRealpath.mockImplementation(async (p: string) => {
@@ -536,54 +550,21 @@ describe("agents.files.get/set symlink safety", () => {
} }
throw createEnoentError(); throw createEnoentError();
}); });
}
const { respond, promise } = makeCall("agents.files.get", { it.each([
agentId: "main", { method: "agents.files.get" as const, expectNoOpen: false },
name: "AGENTS.md", { method: "agents.files.set" as const, expectNoOpen: true },
}); ])(
await promise; "rejects $method when allowlisted file symlink escapes workspace",
async ({ method, expectNoOpen }) => {
expect(respond).toHaveBeenCalledWith( mockWorkspaceEscapeSymlink();
false, await expectUnsafeWorkspaceFile(method);
undefined, if (expectNoOpen) {
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), expect(mocks.fsOpen).not.toHaveBeenCalled();
);
});
it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
} }
if (p === candidate) { },
return "/outside/secret.txt"; );
}
return p;
});
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeSymlinkStat();
}
throw createEnoentError();
});
const { respond, promise } = makeCall("agents.files.set", {
agentId: "main",
name: "AGENTS.md",
content: "x",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
expect(mocks.fsOpen).not.toHaveBeenCalled();
});
it("allows in-workspace symlink targets for get/set", async () => { it("allows in-workspace symlink targets for get/set", async () => {
const workspace = "/workspace/test-agent"; const workspace = "/workspace/test-agent";
@@ -654,7 +635,7 @@ describe("agents.files.get/set symlink safety", () => {
); );
}); });
it("rejects agents.files.get when allowlisted file is a hardlinked alias", async () => { function mockHardlinkedWorkspaceAlias() {
const workspace = "/workspace/test-agent"; const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md"); const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => { mocks.fsRealpath.mockImplementation(async (p: string) => {
@@ -670,49 +651,19 @@ describe("agents.files.get/set symlink safety", () => {
} }
throw createEnoentError(); throw createEnoentError();
}); });
}
const { respond, promise } = makeCall("agents.files.get", { it.each([
agentId: "main", { method: "agents.files.get" as const, expectNoOpen: false },
name: "AGENTS.md", { method: "agents.files.set" as const, expectNoOpen: true },
}); ])(
await promise; "rejects $method when allowlisted file is a hardlinked alias",
async ({ method, expectNoOpen }) => {
expect(respond).toHaveBeenCalledWith( mockHardlinkedWorkspaceAlias();
false, await expectUnsafeWorkspaceFile(method);
undefined, if (expectNoOpen) {
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }), expect(mocks.fsOpen).not.toHaveBeenCalled();
);
});
it("rejects agents.files.set when allowlisted file is a hardlinked alias", async () => {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => {
if (p === workspace) {
return workspace;
} }
return p; },
}); );
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
const p = typeof args[0] === "string" ? args[0] : "";
if (p === candidate) {
return makeFileStat({ nlink: 2 });
}
throw createEnoentError();
});
const { respond, promise } = makeCall("agents.files.set", {
agentId: "main",
name: "AGENTS.md",
content: "x",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
expect(mocks.fsOpen).not.toHaveBeenCalled();
});
}); });

View File

@@ -30,6 +30,22 @@ vi.mock("../../channels/plugins/index.js", () => ({
normalizeChannelId: (value: string) => (value === "webchat" ? null : value), normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
})); }));
const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace";
function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string {
if (typeof params.sessionKey === "string") {
const match = params.sessionKey.match(/^agent:([^:]+)/i);
if (match?.[1]) {
return match[1];
}
}
return "main";
}
function passthroughPluginAutoEnable(config: unknown) {
return { config, changes: [] as unknown[] };
}
vi.mock("../../agents/agent-scope.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({
resolveSessionAgentId: ({ resolveSessionAgentId: ({
sessionKey, sessionKey,
@@ -37,21 +53,13 @@ vi.mock("../../agents/agent-scope.js", () => ({
sessionKey?: string; sessionKey?: string;
config?: unknown; config?: unknown;
agentId?: string; agentId?: string;
}) => { }) => resolveAgentIdFromSessionKeyForTests({ sessionKey }),
if (typeof sessionKey === "string") {
const match = sessionKey.match(/^agent:([^:]+)/i);
if (match?.[1]) {
return match[1];
}
}
return "main";
},
resolveDefaultAgentId: () => "main", resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE,
})); }));
vi.mock("../../config/plugin-auto-enable.js", () => ({ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
})); }));
vi.mock("../../plugins/loader.js", () => ({ vi.mock("../../plugins/loader.js", () => ({

View File

@@ -22,18 +22,36 @@ vi.mock("../../commands/status.js", () => ({
})); }));
describe("waitForAgentJob", () => { describe("waitForAgentJob", () => {
it("maps lifecycle end events with aborted=true to timeout", async () => { async function runLifecycleScenario(params: {
const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`; runIdPrefix: string;
startedAt: number;
endedAt: number;
aborted?: boolean;
}) {
const runId = `${params.runIdPrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } });
emitAgentEvent({ emitAgentEvent({
runId, runId,
stream: "lifecycle", stream: "lifecycle",
data: { phase: "end", endedAt: 200, aborted: true }, data: { phase: "start", startedAt: params.startedAt },
});
emitAgentEvent({
runId,
stream: "lifecycle",
data: { phase: "end", endedAt: params.endedAt, aborted: params.aborted },
}); });
const snapshot = await waitPromise; return waitPromise;
}
it("maps lifecycle end events with aborted=true to timeout", async () => {
const snapshot = await runLifecycleScenario({
runIdPrefix: "run-timeout",
startedAt: 100,
endedAt: 200,
aborted: true,
});
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
expect(snapshot?.status).toBe("timeout"); expect(snapshot?.status).toBe("timeout");
expect(snapshot?.startedAt).toBe(100); expect(snapshot?.startedAt).toBe(100);
@@ -41,13 +59,11 @@ describe("waitForAgentJob", () => {
}); });
it("keeps non-aborted lifecycle end events as ok", async () => { it("keeps non-aborted lifecycle end events as ok", async () => {
const runId = `run-ok-${Date.now()}-${Math.random().toString(36).slice(2)}`; const snapshot = await runLifecycleScenario({
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 }); runIdPrefix: "run-ok",
startedAt: 300,
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 300 } }); endedAt: 400,
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 400 } }); });
const snapshot = await waitPromise;
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
expect(snapshot?.status).toBe("ok"); expect(snapshot?.status).toBe("ok");
expect(snapshot?.startedAt).toBe(300); expect(snapshot?.startedAt).toBe(300);
@@ -359,47 +375,43 @@ describe("exec approval handlers", () => {
return { handlers, broadcasts, respond, context }; return { handlers, broadcasts, respond, context };
} }
function createForwardingExecApprovalFixture() {
const manager = new ExecApprovalManager();
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
stop: vi.fn(),
};
const handlers = createExecApprovalHandlers(manager, { forwarder });
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => false,
};
return { manager, handlers, forwarder, respond, context };
}
async function drainApprovalRequestTicks() {
for (let idx = 0; idx < 20; idx += 1) {
await Promise.resolve();
}
}
describe("ExecApprovalRequestParams validation", () => { describe("ExecApprovalRequestParams validation", () => {
it("accepts request with resolvedPath omitted", () => { const baseParams = {
const params = { command: "echo hi",
command: "echo hi", cwd: "/tmp",
cwd: "/tmp", nodeId: "node-1",
nodeId: "node-1", host: "node",
host: "node", };
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as string", () => { it.each([
const params = { { label: "omitted", extra: {} },
command: "echo hi", { label: "string", extra: { resolvedPath: "/usr/bin/echo" } },
cwd: "/tmp", { label: "undefined", extra: { resolvedPath: undefined } },
nodeId: "node-1", { label: "null", extra: { resolvedPath: null } },
host: "node", ])("accepts request with resolvedPath $label", ({ extra }) => {
resolvedPath: "/usr/bin/echo", const params = { ...baseParams, ...extra };
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as undefined", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
nodeId: "node-1",
host: "node",
resolvedPath: undefined,
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
nodeId: "node-1",
host: "node",
resolvedPath: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(true); expect(validateExecApprovalRequestParams(params)).toBe(true);
}); });
}); });
@@ -618,18 +630,7 @@ describe("exec approval handlers", () => {
it("forwards turn-source metadata to exec approval forwarding", async () => { it("forwards turn-source metadata to exec approval forwarding", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const manager = new ExecApprovalManager(); const { handlers, forwarder, respond, context } = createForwardingExecApprovalFixture();
const forwarder = {
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
stop: vi.fn(),
};
const handlers = createExecApprovalHandlers(manager, { forwarder });
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => false,
};
const requestPromise = requestExecApproval({ const requestPromise = requestExecApproval({
handlers, handlers,
@@ -643,9 +644,7 @@ describe("exec approval handlers", () => {
turnSourceThreadId: "1739201675.123", turnSourceThreadId: "1739201675.123",
}, },
}); });
for (let idx = 0; idx < 20; idx += 1) { await drainApprovalRequestTicks();
await Promise.resolve();
}
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(forwarder.handleRequested).toHaveBeenCalledWith( expect(forwarder.handleRequested).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -668,18 +667,8 @@ describe("exec approval handlers", () => {
it("expires immediately when no approver clients and no forwarding targets", async () => { it("expires immediately when no approver clients and no forwarding targets", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
const manager = new ExecApprovalManager(); const { manager, handlers, forwarder, respond, context } =
const forwarder = { createForwardingExecApprovalFixture();
handleRequested: vi.fn(async () => false),
handleResolved: vi.fn(async () => {}),
stop: vi.fn(),
};
const handlers = createExecApprovalHandlers(manager, { forwarder });
const respond = vi.fn();
const context = {
broadcast: (_event: string, _payload: unknown) => {},
hasExecApprovalClients: () => false,
};
const expireSpy = vi.spyOn(manager, "expire"); const expireSpy = vi.spyOn(manager, "expire");
const requestPromise = requestExecApproval({ const requestPromise = requestExecApproval({
@@ -688,9 +677,7 @@ describe("exec approval handlers", () => {
context, context,
params: { timeoutMs: 60_000 }, params: { timeoutMs: 60_000 },
}); });
for (let idx = 0; idx < 20; idx += 1) { await drainApprovalRequestTicks();
await Promise.resolve();
}
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1); expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
expect(expireSpy).toHaveBeenCalledTimes(1); expect(expireSpy).toHaveBeenCalledTimes(1);
await vi.runOnlyPendingTimersAsync(); await vi.runOnlyPendingTimersAsync();

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,28 @@ import {
shouldEnforceGatewayAuthForPluginPath, shouldEnforceGatewayAuthForPluginPath,
} from "./plugins-http.js"; } from "./plugins-http.js";
type PluginHandlerLog = Parameters<typeof createGatewayPluginRequestHandler>[0]["log"];
function createPluginLog(): PluginHandlerLog {
return { warn: vi.fn() } as unknown as PluginHandlerLog;
}
function createRoute(params: {
path: string;
pluginId?: string;
handler?: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
}) {
return {
pluginId: params.pluginId ?? "route",
path: params.path,
handler: params.handler ?? (() => {}),
source: params.pluginId ?? "route",
};
}
describe("createGatewayPluginRequestHandler", () => { describe("createGatewayPluginRequestHandler", () => {
it("returns false when no handlers are registered", async () => { it("returns false when no handlers are registered", async () => {
const log = { warn: vi.fn() } as unknown as Parameters< const log = createPluginLog();
typeof createGatewayPluginRequestHandler
>[0]["log"];
const handler = createGatewayPluginRequestHandler({ const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry(), registry: createTestRegistry(),
log, log,
@@ -32,9 +49,7 @@ describe("createGatewayPluginRequestHandler", () => {
{ pluginId: "second", handler: second, source: "second" }, { pluginId: "second", handler: second, source: "second" },
], ],
}), }),
log: { warn: vi.fn() } as unknown as Parameters< log: createPluginLog(),
typeof createGatewayPluginRequestHandler
>[0]["log"],
}); });
const { res } = makeMockHttpResponse(); const { res } = makeMockHttpResponse();
@@ -51,19 +66,10 @@ describe("createGatewayPluginRequestHandler", () => {
const fallback = vi.fn(async () => true); const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({ const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({ registry: createTestRegistry({
httpRoutes: [ httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })],
{
pluginId: "route",
path: "/demo",
handler: routeHandler,
source: "route",
},
],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}), }),
log: { warn: vi.fn() } as unknown as Parameters< log: createPluginLog(),
typeof createGatewayPluginRequestHandler
>[0]["log"],
}); });
const { res } = makeMockHttpResponse(); const { res } = makeMockHttpResponse();
@@ -80,19 +86,10 @@ describe("createGatewayPluginRequestHandler", () => {
const fallback = vi.fn(async () => true); const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({ const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({ registry: createTestRegistry({
httpRoutes: [ httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })],
{
pluginId: "route",
path: "/api/demo",
handler: routeHandler,
source: "route",
},
],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}), }),
log: { warn: vi.fn() } as unknown as Parameters< log: createPluginLog(),
typeof createGatewayPluginRequestHandler
>[0]["log"],
}); });
const { res } = makeMockHttpResponse(); const { res } = makeMockHttpResponse();
@@ -103,9 +100,7 @@ describe("createGatewayPluginRequestHandler", () => {
}); });
it("logs and responds with 500 when a handler throws", async () => { it("logs and responds with 500 when a handler throws", async () => {
const log = { warn: vi.fn() } as unknown as Parameters< const log = createPluginLog();
typeof createGatewayPluginRequestHandler
>[0]["log"];
const handler = createGatewayPluginRequestHandler({ const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({ registry: createTestRegistry({
httpHandlers: [ httpHandlers: [
@@ -134,14 +129,7 @@ describe("createGatewayPluginRequestHandler", () => {
describe("plugin HTTP registry helpers", () => { describe("plugin HTTP registry helpers", () => {
it("detects registered route paths", () => { it("detects registered route paths", () => {
const registry = createTestRegistry({ const registry = createTestRegistry({
httpRoutes: [ httpRoutes: [createRoute({ path: "/demo" })],
{
pluginId: "route",
path: "/demo",
handler: () => {},
source: "route",
},
],
}); });
expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false); expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false);
@@ -149,14 +137,7 @@ describe("plugin HTTP registry helpers", () => {
it("matches canonicalized variants of registered route paths", () => { it("matches canonicalized variants of registered route paths", () => {
const registry = createTestRegistry({ const registry = createTestRegistry({
httpRoutes: [ httpRoutes: [createRoute({ path: "/api/demo" })],
{
pluginId: "route",
path: "/api/demo",
handler: () => {},
source: "route",
},
],
}); });
expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true);
@@ -165,14 +146,7 @@ describe("plugin HTTP registry helpers", () => {
it("enforces auth for protected and registered plugin routes", () => { it("enforces auth for protected and registered plugin routes", () => {
const registry = createTestRegistry({ const registry = createTestRegistry({
httpRoutes: [ httpRoutes: [createRoute({ path: "/api/demo" })],
{
pluginId: "route",
path: "/api/demo",
handler: () => {},
source: "route",
},
],
}); });
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);

View File

@@ -40,6 +40,39 @@ function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig {
} as OpenClawConfig; } as OpenClawConfig;
} }
function createModelDefaultsConfig(params: {
primary: string;
models?: Record<string, Record<string, never>>;
}): OpenClawConfig {
return {
agents: {
defaults: {
model: { primary: params.primary },
models: params.models,
},
},
} as OpenClawConfig;
}
function createLegacyRuntimeListConfig(
models?: Record<string, Record<string, never>>,
): OpenClawConfig {
return createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
...(models ? { models } : {}),
});
}
function createLegacyRuntimeStore(model: string): Record<string, SessionEntry> {
return {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
model,
} as SessionEntry,
};
}
describe("gateway session utils", () => { describe("gateway session utils", () => {
test("capArrayByJsonBytes trims from the front", () => { test("capArrayByJsonBytes trims from the front", () => {
const res = capArrayByJsonBytes(["a", "b", "c"], 10); const res = capArrayByJsonBytes(["a", "b", "c"], 10);
@@ -281,13 +314,9 @@ describe("gateway session utils", () => {
describe("resolveSessionModelRef", () => { describe("resolveSessionModelRef", () => {
test("prefers runtime model/provider from session entry", () => { test("prefers runtime model/provider from session entry", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "anthropic/claude-opus-4-6",
defaults: { });
model: { primary: "anthropic/claude-opus-4-6" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelRef(cfg, { const resolved = resolveSessionModelRef(cfg, {
sessionId: "s1", sessionId: "s1",
@@ -302,13 +331,9 @@ describe("resolveSessionModelRef", () => {
}); });
test("preserves openrouter provider when model contains vendor prefix", () => { test("preserves openrouter provider when model contains vendor prefix", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "openrouter/minimax/minimax-m2.5",
defaults: { });
model: { primary: "openrouter/minimax/minimax-m2.5" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelRef(cfg, { const resolved = resolveSessionModelRef(cfg, {
sessionId: "s-or", sessionId: "s-or",
@@ -324,13 +349,9 @@ describe("resolveSessionModelRef", () => {
}); });
test("falls back to override when runtime model is not recorded yet", () => { test("falls back to override when runtime model is not recorded yet", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "anthropic/claude-opus-4-6",
defaults: { });
model: { primary: "anthropic/claude-opus-4-6" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelRef(cfg, { const resolved = resolveSessionModelRef(cfg, {
sessionId: "s2", sessionId: "s2",
@@ -342,13 +363,9 @@ describe("resolveSessionModelRef", () => {
}); });
test("falls back to resolved provider for unprefixed legacy runtime model", () => { test("falls back to resolved provider for unprefixed legacy runtime model", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { });
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelRef(cfg, { const resolved = resolveSessionModelRef(cfg, {
sessionId: "legacy-session", sessionId: "legacy-session",
@@ -366,13 +383,9 @@ describe("resolveSessionModelRef", () => {
test("preserves provider from slash-prefixed model when modelProvider is missing", () => { test("preserves provider from slash-prefixed model when modelProvider is missing", () => {
// When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6") // When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6")
// parseModelRef should extract it correctly even without modelProvider set. // parseModelRef should extract it correctly even without modelProvider set.
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { });
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelRef(cfg, { const resolved = resolveSessionModelRef(cfg, {
sessionId: "slash-model", sessionId: "slash-model",
@@ -387,13 +400,9 @@ describe("resolveSessionModelRef", () => {
describe("resolveSessionModelIdentityRef", () => { describe("resolveSessionModelIdentityRef", () => {
test("does not inherit default provider for unprefixed legacy runtime model", () => { test("does not inherit default provider for unprefixed legacy runtime model", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { });
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelIdentityRef(cfg, { const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session", sessionId: "legacy-session",
@@ -406,16 +415,12 @@ describe("resolveSessionModelIdentityRef", () => {
}); });
test("infers provider from configured model allowlist when unambiguous", () => { test("infers provider from configured model allowlist when unambiguous", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { models: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, "anthropic/claude-sonnet-4-6": {},
models: {
"anthropic/claude-sonnet-4-6": {},
},
},
}, },
} as OpenClawConfig; });
const resolved = resolveSessionModelIdentityRef(cfg, { const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session", sessionId: "legacy-session",
@@ -428,17 +433,13 @@ describe("resolveSessionModelIdentityRef", () => {
}); });
test("keeps provider unknown when configured models are ambiguous", () => { test("keeps provider unknown when configured models are ambiguous", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { models: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, "anthropic/claude-sonnet-4-6": {},
models: { "minimax/claude-sonnet-4-6": {},
"anthropic/claude-sonnet-4-6": {},
"minimax/claude-sonnet-4-6": {},
},
},
}, },
} as OpenClawConfig; });
const resolved = resolveSessionModelIdentityRef(cfg, { const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session", sessionId: "legacy-session",
@@ -451,13 +452,9 @@ describe("resolveSessionModelIdentityRef", () => {
}); });
test("preserves provider from slash-prefixed runtime model", () => { test("preserves provider from slash-prefixed runtime model", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { });
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const resolved = resolveSessionModelIdentityRef(cfg, { const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "slash-model", sessionId: "slash-model",
@@ -470,16 +467,12 @@ describe("resolveSessionModelIdentityRef", () => {
}); });
test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => { test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => {
const cfg = { const cfg = createModelDefaultsConfig({
agents: { primary: "google-gemini-cli/gemini-3-pro-preview",
defaults: { models: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
models: {
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
},
},
}, },
} as OpenClawConfig; });
const resolved = resolveSessionModelIdentityRef(cfg, { const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "slash-model", sessionId: "slash-model",
@@ -683,97 +676,37 @@ describe("listSessionsFromStore search", () => {
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]); expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
}); });
test("does not guess provider for legacy runtime model without modelProvider", () => { test.each([
const cfg = { {
session: { mainKey: "main" }, name: "does not guess provider for legacy runtime model without modelProvider",
agents: { cfg: createLegacyRuntimeListConfig(),
defaults: { runtimeModel: "claude-sonnet-4-6",
model: { primary: "google-gemini-cli/gemini-3-pro-preview" }, expectedProvider: undefined,
}, },
}, {
} as OpenClawConfig; name: "infers provider for legacy runtime model when allowlist match is unique",
const now = Date.now(); cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }),
const store: Record<string, SessionEntry> = { runtimeModel: "claude-sonnet-4-6",
"agent:main:main": { expectedProvider: "anthropic",
sessionId: "sess-main", },
updatedAt: now, {
model: "claude-sonnet-4-6", name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique",
} as SessionEntry, cfg: createLegacyRuntimeListConfig({
}; "vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
}),
runtimeModel: "anthropic/claude-sonnet-4-6",
expectedProvider: "vercel-ai-gateway",
},
])("$name", ({ cfg, runtimeModel, expectedProvider }) => {
const result = listSessionsFromStore({ const result = listSessionsFromStore({
cfg, cfg,
storePath: "/tmp/sessions.json", storePath: "/tmp/sessions.json",
store, store: createLegacyRuntimeStore(runtimeModel),
opts: {}, opts: {},
}); });
expect(result.sessions[0]?.modelProvider).toBeUndefined(); expect(result.sessions[0]?.modelProvider).toBe(expectedProvider);
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6"); expect(result.sessions[0]?.model).toBe(runtimeModel);
});
test("infers provider for legacy runtime model when allowlist match is unique", () => {
const cfg = {
session: { mainKey: "main" },
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
models: {
"anthropic/claude-sonnet-4-6": {},
},
},
},
} as OpenClawConfig;
const now = Date.now();
const store: Record<string, SessionEntry> = {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: now,
model: "claude-sonnet-4-6",
} as SessionEntry,
};
const result = listSessionsFromStore({
cfg,
storePath: "/tmp/sessions.json",
store,
opts: {},
});
expect(result.sessions[0]?.modelProvider).toBe("anthropic");
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6");
});
test("infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", () => {
const cfg = {
session: { mainKey: "main" },
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
models: {
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
},
},
},
} as OpenClawConfig;
const now = Date.now();
const store: Record<string, SessionEntry> = {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: now,
model: "anthropic/claude-sonnet-4-6",
} as SessionEntry,
};
const result = listSessionsFromStore({
cfg,
storePath: "/tmp/sessions.json",
store,
opts: {},
});
expect(result.sessions[0]?.modelProvider).toBe("vercel-ai-gateway");
expect(result.sessions[0]?.model).toBe("anthropic/claude-sonnet-4-6");
}); });
test("exposes unknown totals when freshness is stale or missing", () => { test("exposes unknown totals when freshness is stale or missing", () => {

View File

@@ -5,26 +5,63 @@ import { applySessionsPatchToStore } from "./sessions-patch.js";
const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5"; const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5";
const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child"; const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child";
const MAIN_SESSION_KEY = "agent:main:main";
const EMPTY_CFG = {} as OpenClawConfig;
type ApplySessionsPatchArgs = Parameters<typeof applySessionsPatchToStore>[0];
async function runPatch(params: {
patch: ApplySessionsPatchArgs["patch"];
store?: Record<string, SessionEntry>;
cfg?: OpenClawConfig;
storeKey?: string;
loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"];
}) {
return applySessionsPatchToStore({
cfg: params.cfg ?? EMPTY_CFG,
store: params.store ?? {},
storeKey: params.storeKey ?? MAIN_SESSION_KEY,
patch: params.patch,
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
});
}
function expectPatchOk(
result: Awaited<ReturnType<typeof applySessionsPatchToStore>>,
): SessionEntry {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error(result.error.message);
}
return result.entry;
}
function expectPatchError(
result: Awaited<ReturnType<typeof applySessionsPatchToStore>>,
message: string,
): void {
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error(`Expected patch failure containing: ${message}`);
}
expect(result.error.message).toContain(message);
}
async function applySubagentModelPatch(cfg: OpenClawConfig) { async function applySubagentModelPatch(cfg: OpenClawConfig) {
const res = await applySessionsPatchToStore({ return expectPatchOk(
cfg, await runPatch({
store: {}, cfg,
storeKey: KIMI_SUBAGENT_KEY, storeKey: KIMI_SUBAGENT_KEY,
patch: { patch: {
key: KIMI_SUBAGENT_KEY, key: KIMI_SUBAGENT_KEY,
model: SUBAGENT_MODEL, model: SUBAGENT_MODEL,
}, },
loadGatewayModelCatalog: async () => [ loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
], ],
}); }),
expect(res.ok).toBe(true); );
if (!res.ok) {
throw new Error(res.error.message);
}
return res.entry;
} }
function makeKimiSubagentCfg(params: { function makeKimiSubagentCfg(params: {
@@ -54,131 +91,100 @@ function makeKimiSubagentCfg(params: {
} as OpenClawConfig; } as OpenClawConfig;
} }
function createAllowlistedAnthropicModelCfg(): OpenClawConfig {
return {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
}
describe("gateway sessions patch", () => { describe("gateway sessions patch", () => {
test("persists thinkingLevel=off (does not clear)", async () => { test("persists thinkingLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {}; const entry = expectPatchOk(
const res = await applySessionsPatchToStore({ await runPatch({
cfg: {} as OpenClawConfig, patch: { key: MAIN_SESSION_KEY, thinkingLevel: "off" },
store, }),
storeKey: "agent:main:main", );
patch: { key: "agent:main:main", thinkingLevel: "off" }, expect(entry.thinkingLevel).toBe("off");
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.thinkingLevel).toBe("off");
}); });
test("clears thinkingLevel when patch sets null", async () => { test("clears thinkingLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = { const store: Record<string, SessionEntry> = {
"agent:main:main": { thinkingLevel: "low" } as SessionEntry, [MAIN_SESSION_KEY]: { thinkingLevel: "low" } as SessionEntry,
}; };
const res = await applySessionsPatchToStore({ const entry = expectPatchOk(
cfg: {} as OpenClawConfig, await runPatch({
store, store,
storeKey: "agent:main:main", patch: { key: MAIN_SESSION_KEY, thinkingLevel: null },
patch: { key: "agent:main:main", thinkingLevel: null }, }),
}); );
expect(res.ok).toBe(true); expect(entry.thinkingLevel).toBeUndefined();
if (!res.ok) {
return;
}
expect(res.entry.thinkingLevel).toBeUndefined();
}); });
test("persists reasoningLevel=off (does not clear)", async () => { test("persists reasoningLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {}; const entry = expectPatchOk(
const res = await applySessionsPatchToStore({ await runPatch({
cfg: {} as OpenClawConfig, patch: { key: MAIN_SESSION_KEY, reasoningLevel: "off" },
store, }),
storeKey: "agent:main:main", );
patch: { key: "agent:main:main", reasoningLevel: "off" }, expect(entry.reasoningLevel).toBe("off");
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.reasoningLevel).toBe("off");
}); });
test("clears reasoningLevel when patch sets null", async () => { test("clears reasoningLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = { const store: Record<string, SessionEntry> = {
"agent:main:main": { reasoningLevel: "stream" } as SessionEntry, [MAIN_SESSION_KEY]: { reasoningLevel: "stream" } as SessionEntry,
}; };
const res = await applySessionsPatchToStore({ const entry = expectPatchOk(
cfg: {} as OpenClawConfig, await runPatch({
store, store,
storeKey: "agent:main:main", patch: { key: MAIN_SESSION_KEY, reasoningLevel: null },
patch: { key: "agent:main:main", reasoningLevel: null }, }),
}); );
expect(res.ok).toBe(true); expect(entry.reasoningLevel).toBeUndefined();
if (!res.ok) {
return;
}
expect(res.entry.reasoningLevel).toBeUndefined();
}); });
test("persists elevatedLevel=off (does not clear)", async () => { test("persists elevatedLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {}; const entry = expectPatchOk(
const res = await applySessionsPatchToStore({ await runPatch({
cfg: {} as OpenClawConfig, patch: { key: MAIN_SESSION_KEY, elevatedLevel: "off" },
store, }),
storeKey: "agent:main:main", );
patch: { key: "agent:main:main", elevatedLevel: "off" }, expect(entry.elevatedLevel).toBe("off");
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.elevatedLevel).toBe("off");
}); });
test("persists elevatedLevel=on", async () => { test("persists elevatedLevel=on", async () => {
const store: Record<string, SessionEntry> = {}; const entry = expectPatchOk(
const res = await applySessionsPatchToStore({ await runPatch({
cfg: {} as OpenClawConfig, patch: { key: MAIN_SESSION_KEY, elevatedLevel: "on" },
store, }),
storeKey: "agent:main:main", );
patch: { key: "agent:main:main", elevatedLevel: "on" }, expect(entry.elevatedLevel).toBe("on");
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.elevatedLevel).toBe("on");
}); });
test("clears elevatedLevel when patch sets null", async () => { test("clears elevatedLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = { const store: Record<string, SessionEntry> = {
"agent:main:main": { elevatedLevel: "off" } as SessionEntry, [MAIN_SESSION_KEY]: { elevatedLevel: "off" } as SessionEntry,
}; };
const res = await applySessionsPatchToStore({ const entry = expectPatchOk(
cfg: {} as OpenClawConfig, await runPatch({
store, store,
storeKey: "agent:main:main", patch: { key: MAIN_SESSION_KEY, elevatedLevel: null },
patch: { key: "agent:main:main", elevatedLevel: null }, }),
}); );
expect(res.ok).toBe(true); expect(entry.elevatedLevel).toBeUndefined();
if (!res.ok) {
return;
}
expect(res.entry.elevatedLevel).toBeUndefined();
}); });
test("rejects invalid elevatedLevel values", async () => { test("rejects invalid elevatedLevel values", async () => {
const store: Record<string, SessionEntry> = {}; const result = await runPatch({
const res = await applySessionsPatchToStore({ patch: { key: MAIN_SESSION_KEY, elevatedLevel: "maybe" },
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", elevatedLevel: "maybe" },
}); });
expect(res.ok).toBe(false); expectPatchError(result, "invalid elevatedLevel");
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid elevatedLevel");
}); });
test("clears auth overrides when model patch changes", async () => { test("clears auth overrides when model patch changes", async () => {
@@ -193,189 +199,107 @@ describe("gateway sessions patch", () => {
authProfileOverrideCompactionCount: 3, authProfileOverrideCompactionCount: 3,
} as SessionEntry, } as SessionEntry,
}; };
const res = await applySessionsPatchToStore({ const entry = expectPatchOk(
cfg: {} as OpenClawConfig, await runPatch({
store, store,
storeKey: "agent:main:main", patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.2" },
patch: { key: "agent:main:main", model: "openai/gpt-5.2" }, loadGatewayModelCatalog: async () => [
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }], { provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
}); ],
expect(res.ok).toBe(true); }),
if (!res.ok) { );
return; expect(entry.providerOverride).toBe("openai");
} expect(entry.modelOverride).toBe("gpt-5.2");
expect(res.entry.providerOverride).toBe("openai"); expect(entry.authProfileOverride).toBeUndefined();
expect(res.entry.modelOverride).toBe("gpt-5.2"); expect(entry.authProfileOverrideSource).toBeUndefined();
expect(res.entry.authProfileOverride).toBeUndefined(); expect(entry.authProfileOverrideCompactionCount).toBeUndefined();
expect(res.entry.authProfileOverrideSource).toBeUndefined();
expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined();
}); });
test("accepts explicit allowlisted provider/model refs from sessions.patch", async () => { test.each([
const store: Record<string, SessionEntry> = {}; {
const cfg = { name: "accepts explicit allowlisted provider/model refs from sessions.patch",
agents: { catalog: [
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
const res = await applySessionsPatchToStore({
cfg,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" },
loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
], ],
}); },
{
expect(res.ok).toBe(true); name: "accepts explicit allowlisted refs absent from bundled catalog",
if (!res.ok) { catalog: [
return;
}
expect(res.entry.providerOverride).toBe("anthropic");
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6");
});
test("accepts explicit allowlisted refs absent from bundled catalog", async () => {
const store: Record<string, SessionEntry> = {};
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
const res = await applySessionsPatchToStore({
cfg,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" },
loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
], ],
}); },
])("$name", async ({ catalog }) => {
expect(res.ok).toBe(true); const entry = expectPatchOk(
if (!res.ok) { await runPatch({
return; cfg: createAllowlistedAnthropicModelCfg(),
} patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" },
expect(res.entry.providerOverride).toBe("anthropic"); loadGatewayModelCatalog: async () => catalog,
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6"); }),
);
expect(entry.providerOverride).toBe("anthropic");
expect(entry.modelOverride).toBe("claude-sonnet-4-6");
}); });
test("sets spawnDepth for subagent sessions", async () => { test("sets spawnDepth for subagent sessions", async () => {
const store: Record<string, SessionEntry> = {}; const entry = expectPatchOk(
const res = await applySessionsPatchToStore({ await runPatch({
cfg: {} as OpenClawConfig, storeKey: "agent:main:subagent:child",
store, patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
storeKey: "agent:main:subagent:child", }),
patch: { key: "agent:main:subagent:child", spawnDepth: 2 }, );
}); expect(entry.spawnDepth).toBe(2);
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.spawnDepth).toBe(2);
}); });
test("rejects spawnDepth on non-subagent sessions", async () => { test("rejects spawnDepth on non-subagent sessions", async () => {
const store: Record<string, SessionEntry> = {}; const result = await runPatch({
const res = await applySessionsPatchToStore({ patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", spawnDepth: 1 },
}); });
expect(res.ok).toBe(false); expectPatchError(result, "spawnDepth is only supported");
if (res.ok) {
return;
}
expect(res.error.message).toContain("spawnDepth is only supported");
}); });
test("normalizes exec/send/group patches", async () => { test("normalizes exec/send/group patches", async () => {
const store: Record<string, SessionEntry> = {}; const entry = expectPatchOk(
const res = await applySessionsPatchToStore({ await runPatch({
cfg: {} as OpenClawConfig, patch: {
store, key: MAIN_SESSION_KEY,
storeKey: "agent:main:main", execHost: " NODE ",
patch: { execSecurity: " ALLOWLIST ",
key: "agent:main:main", execAsk: " ON-MISS ",
execHost: " NODE ", execNode: " worker-1 ",
execSecurity: " ALLOWLIST ", sendPolicy: "DENY" as unknown as "allow",
execAsk: " ON-MISS ", groupActivation: "Always" as unknown as "mention",
execNode: " worker-1 ", },
sendPolicy: "DENY" as unknown as "allow", }),
groupActivation: "Always" as unknown as "mention", );
}, expect(entry.execHost).toBe("node");
}); expect(entry.execSecurity).toBe("allowlist");
expect(res.ok).toBe(true); expect(entry.execAsk).toBe("on-miss");
if (!res.ok) { expect(entry.execNode).toBe("worker-1");
return; expect(entry.sendPolicy).toBe("deny");
} expect(entry.groupActivation).toBe("always");
expect(res.entry.execHost).toBe("node");
expect(res.entry.execSecurity).toBe("allowlist");
expect(res.entry.execAsk).toBe("on-miss");
expect(res.entry.execNode).toBe("worker-1");
expect(res.entry.sendPolicy).toBe("deny");
expect(res.entry.groupActivation).toBe("always");
}); });
test("rejects invalid execHost values", async () => { test("rejects invalid execHost values", async () => {
const store: Record<string, SessionEntry> = {}; const result = await runPatch({
const res = await applySessionsPatchToStore({ patch: { key: MAIN_SESSION_KEY, execHost: "edge" },
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", execHost: "edge" },
}); });
expect(res.ok).toBe(false); expectPatchError(result, "invalid execHost");
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid execHost");
}); });
test("rejects invalid sendPolicy values", async () => { test("rejects invalid sendPolicy values", async () => {
const store: Record<string, SessionEntry> = {}; const result = await runPatch({
const res = await applySessionsPatchToStore({ patch: { key: MAIN_SESSION_KEY, sendPolicy: "ask" as unknown as "allow" },
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", sendPolicy: "ask" as unknown as "allow" },
}); });
expect(res.ok).toBe(false); expectPatchError(result, "invalid sendPolicy");
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid sendPolicy");
}); });
test("rejects invalid groupActivation values", async () => { test("rejects invalid groupActivation values", async () => {
const store: Record<string, SessionEntry> = {}; const result = await runPatch({
const res = await applySessionsPatchToStore({ patch: { key: MAIN_SESSION_KEY, groupActivation: "never" as unknown as "mention" },
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", groupActivation: "never" as unknown as "mention" },
}); });
expect(res.ok).toBe(false); expectPatchError(result, "invalid groupActivation");
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid groupActivation");
}); });
test("allows target agent own model for subagent session even when missing from global allowlist", async () => { test("allows target agent own model for subagent session even when missing from global allowlist", async () => {

View File

@@ -5,6 +5,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
let cfg: Record<string, unknown> = {}; let cfg: Record<string, unknown> = {};
const alwaysAuthorized = async () => ({ ok: true as const });
const disableDefaultMemorySlot = () => false;
const noPluginToolMeta = () => undefined;
const noWarnLog = () => {};
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
loadConfig: () => cfg, loadConfig: () => cfg,
@@ -15,19 +19,19 @@ vi.mock("../config/sessions.js", () => ({
})); }));
vi.mock("./auth.js", () => ({ vi.mock("./auth.js", () => ({
authorizeHttpGatewayConnect: async () => ({ ok: true }), authorizeHttpGatewayConnect: alwaysAuthorized,
})); }));
vi.mock("../logger.js", () => ({ vi.mock("../logger.js", () => ({
logWarn: () => {}, logWarn: noWarnLog,
})); }));
vi.mock("../plugins/config-state.js", () => ({ vi.mock("../plugins/config-state.js", () => ({
isTestDefaultMemorySlotDisabled: () => false, isTestDefaultMemorySlotDisabled: disableDefaultMemorySlot,
})); }));
vi.mock("../plugins/tools.js", () => ({ vi.mock("../plugins/tools.js", () => ({
getPluginToolMeta: () => undefined, getPluginToolMeta: noPluginToolMeta,
})); }));
vi.mock("../agents/openclaw-tools.js", () => { vi.mock("../agents/openclaw-tools.js", () => {

View File

@@ -32,6 +32,21 @@ function buildNestedEnvShellCommand(params: {
return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload]; return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload];
} }
function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; cwd: string }) {
const analysis = analyzeArgvCommand({
argv: params.argv,
cwd: params.cwd,
env: makePathEnv(params.envPath),
});
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: [{ pattern: params.envPath }],
safeBins: normalizeSafeBins([]),
cwd: params.cwd,
});
return { analysis, allowlistEval };
}
describe("exec approvals allowlist matching", () => { describe("exec approvals allowlist matching", () => {
const baseResolution = { const baseResolution = {
rawExecutable: "rg", rawExecutable: "rg",
@@ -288,16 +303,9 @@ describe("exec approvals command resolution", () => {
if (process.platform !== "win32") { if (process.platform !== "win32") {
fs.chmodSync(envPath, 0o755); fs.chmodSync(envPath, 0o755);
} }
const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({
const analysis = analyzeArgvCommand({
argv: [envPath, "-S", 'sh -c "echo pwned"'], argv: [envPath, "-S", 'sh -c "echo pwned"'],
cwd: dir, envPath: envPath,
env: makePathEnv(binDir),
});
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: [{ pattern: envPath }],
safeBins: normalizeSafeBins([]),
cwd: dir, cwd: dir,
}); });
@@ -317,20 +325,13 @@ describe("exec approvals command resolution", () => {
const envPath = path.join(binDir, "env"); const envPath = path.join(binDir, "env");
fs.writeFileSync(envPath, "#!/bin/sh\n"); fs.writeFileSync(envPath, "#!/bin/sh\n");
fs.chmodSync(envPath, 0o755); fs.chmodSync(envPath, 0o755);
const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({
const analysis = analyzeArgvCommand({
argv: buildNestedEnvShellCommand({ argv: buildNestedEnvShellCommand({
envExecutable: envPath, envExecutable: envPath,
depth: 5, depth: 5,
payload: "echo pwned", payload: "echo pwned",
}), }),
cwd: dir, envPath,
env: makePathEnv(binDir),
});
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: [{ pattern: envPath }],
safeBins: normalizeSafeBins([]),
cwd: dir, cwd: dir,
}); });

View File

@@ -5,18 +5,28 @@ const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn(), loadOpenClawPlugins: vi.fn(),
})); }));
const TEST_WORKSPACE_ROOT = "/tmp/openclaw-test-workspace";
function normalizeChannel(value?: string) {
return value?.trim().toLowerCase() ?? undefined;
}
function passthroughPluginAutoEnable(config: unknown) {
return { config, changes: [] as unknown[] };
}
vi.mock("../../channels/plugins/index.js", () => ({ vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin, getChannelPlugin: mocks.getChannelPlugin,
normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined, normalizeChannelId: normalizeChannel,
})); }));
vi.mock("../../agents/agent-scope.js", () => ({ vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId: () => "main", resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", resolveAgentWorkspaceDir: () => TEST_WORKSPACE_ROOT,
})); }));
vi.mock("../../config/plugin-auto-enable.js", () => ({ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
})); }));
vi.mock("../../plugins/loader.js", () => ({ vi.mock("../../plugins/loader.js", () => ({

View File

@@ -182,6 +182,39 @@ describe("runGatewayUpdate", () => {
); );
} }
function createGlobalNpmUpdateRunner(params: {
pkgRoot: string;
nodeModules: string;
onBaseInstall?: () => Promise<CommandResult>;
onOmitOptionalInstall?: () => Promise<CommandResult>;
}) {
const baseInstallKey = "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error";
const omitOptionalInstallKey =
"npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error";
return async (argv: string[]): Promise<CommandResult> => {
const key = argv.join(" ");
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
if (key === "npm root -g") {
return { stdout: params.nodeModules, stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === baseInstallKey) {
return (await params.onBaseInstall?.()) ?? { stdout: "ok", stderr: "", code: 0 };
}
if (key === omitOptionalInstallKey) {
return (
(await params.onOmitOptionalInstall?.()) ?? { stdout: "", stderr: "not found", code: 1 }
);
}
return { stdout: "", stderr: "", code: 0 };
};
}
it("skips git update when worktree is dirty", async () => { it("skips git update when worktree is dirty", async () => {
await setupGitCheckout(); await setupGitCheckout();
const { runner, calls } = createRunner({ const { runner, calls } = createRunner({
@@ -392,23 +425,14 @@ describe("runGatewayUpdate", () => {
await seedGlobalPackageRoot(pkgRoot); await seedGlobalPackageRoot(pkgRoot);
let stalePresentAtInstall = true; let stalePresentAtInstall = true;
const runCommand = async (argv: string[]) => { const runCommand = createGlobalNpmUpdateRunner({
const key = argv.join(" "); nodeModules,
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { pkgRoot,
return { stdout: "", stderr: "not a git repository", code: 128 }; onBaseInstall: async () => {
}
if (key === "npm root -g") {
return { stdout: nodeModules, stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
stalePresentAtInstall = await pathExists(staleDir); stalePresentAtInstall = await pathExists(staleDir);
return { stdout: "ok", stderr: "", code: 0 }; return { stdout: "ok", stderr: "", code: 0 };
} },
return { stdout: "", stderr: "", code: 0 }; });
};
const result = await runWithCommand(runCommand, { cwd: pkgRoot }); const result = await runWithCommand(runCommand, { cwd: pkgRoot });
@@ -423,33 +447,22 @@ describe("runGatewayUpdate", () => {
await seedGlobalPackageRoot(pkgRoot); await seedGlobalPackageRoot(pkgRoot);
let firstAttempt = true; let firstAttempt = true;
const runCommand = async (argv: string[]) => { const runCommand = createGlobalNpmUpdateRunner({
const key = argv.join(" "); nodeModules,
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { pkgRoot,
return { stdout: "", stderr: "not a git repository", code: 128 }; onBaseInstall: async () => {
}
if (key === "npm root -g") {
return { stdout: nodeModules, stderr: "", code: 0 };
}
if (key === "pnpm root -g") {
return { stdout: "", stderr: "", code: 1 };
}
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
firstAttempt = false; firstAttempt = false;
return { stdout: "", stderr: "node-gyp failed", code: 1 }; return { stdout: "", stderr: "node-gyp failed", code: 1 };
} },
if ( onOmitOptionalInstall: async () => {
key === "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error"
) {
await fs.writeFile( await fs.writeFile(
path.join(pkgRoot, "package.json"), path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }), JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8", "utf-8",
); );
return { stdout: "ok", stderr: "", code: 0 }; return { stdout: "ok", stderr: "", code: 0 };
} },
return { stdout: "", stderr: "", code: 0 }; });
};
const result = await runWithCommand(runCommand, { cwd: pkgRoot }); const result = await runWithCommand(runCommand, { cwd: pkgRoot });

View File

@@ -21,6 +21,61 @@ describe("formatSystemRunAllowlistMissMessage", () => {
}); });
describe("handleSystemRunInvoke mac app exec host routing", () => { describe("handleSystemRunInvoke mac app exec host routing", () => {
function createLocalRunResult(stdout = "local-ok") {
return {
success: true,
stdout,
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
};
}
function expectInvokeOk(
sendInvokeResult: ReturnType<typeof vi.fn>,
params?: { payloadContains?: string },
) {
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
...(params?.payloadContains
? { payloadJSON: expect.stringContaining(params.payloadContains) }
: {}),
}),
);
}
function expectInvokeErrorMessage(
sendInvokeResult: ReturnType<typeof vi.fn>,
params: { message: string; exact?: boolean },
) {
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: params.exact ? params.message : expect.stringContaining(params.message),
}),
}),
);
}
function expectApprovalRequiredDenied(params: {
sendNodeEvent: ReturnType<typeof vi.fn>;
sendInvokeResult: ReturnType<typeof vi.fn>;
}) {
expect(params.sendNodeEvent).toHaveBeenCalledWith(
expect.anything(),
"exec.denied",
expect.objectContaining({ reason: "approval-required" }),
);
expectInvokeErrorMessage(params.sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: approval required",
exact: true,
});
}
function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] { function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] {
return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload]; return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload];
} }
@@ -45,6 +100,44 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
} }
} }
async function withPathTokenCommand<T>(params: {
tmpPrefix: string;
run: (ctx: { link: string; expected: string }) => Promise<T>;
}): Promise<T> {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix));
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const link = path.join(binDir, "poccmd");
fs.symlinkSync("/bin/echo", link);
const expected = fs.realpathSync(link);
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
try {
return await params.run({ link, expected });
} finally {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
fs.rmSync(tmp, { recursive: true, force: true });
}
}
function expectCommandPinnedToCanonicalPath(params: {
runCommand: ReturnType<typeof vi.fn>;
expected: string;
commandTail: string[];
cwd?: string;
}) {
expect(params.runCommand).toHaveBeenCalledWith(
[params.expected, ...params.commandTail],
params.cwd,
undefined,
undefined,
);
}
async function runSystemInvoke(params: { async function runSystemInvoke(params: {
preferMacAppExecHost: boolean; preferMacAppExecHost: boolean;
runViaResponse?: ExecHostResponse | null; runViaResponse?: ExecHostResponse | null;
@@ -53,26 +146,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
security?: "full" | "allowlist"; security?: "full" | "allowlist";
ask?: "off" | "on-miss" | "always"; ask?: "off" | "on-miss" | "always";
approved?: boolean; approved?: boolean;
runCommand?: ReturnType<typeof vi.fn>;
runViaMacAppExecHost?: ReturnType<typeof vi.fn>;
sendInvokeResult?: ReturnType<typeof vi.fn>;
sendExecFinishedEvent?: ReturnType<typeof vi.fn>;
sendNodeEvent?: ReturnType<typeof vi.fn>;
skillBinsCurrent?: () => Promise<Array<{ name: string; resolvedPath: string }>>;
}) { }) {
const runCommand = vi.fn( const runCommand =
async ( params.runCommand ??
_command: string[], vi.fn(async (_command: string[], _cwd?: string, _env?: Record<string, string>) =>
_cwd?: string, createLocalRunResult(),
_env?: Record<string, string>, );
_timeoutMs?: number, const runViaMacAppExecHost =
) => ({ params.runViaMacAppExecHost ?? vi.fn(async () => params.runViaResponse ?? null);
success: true, const sendInvokeResult = params.sendInvokeResult ?? vi.fn(async () => {});
stdout: "local-ok", const sendExecFinishedEvent = params.sendExecFinishedEvent ?? vi.fn(async () => {});
stderr: "", const sendNodeEvent = params.sendNodeEvent ?? vi.fn(async () => {});
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
}),
);
const runViaMacAppExecHost = vi.fn(async () => params.runViaResponse ?? null);
const sendInvokeResult = vi.fn(async () => {});
const sendExecFinishedEvent = vi.fn(async () => {});
await handleSystemRunInvoke({ await handleSystemRunInvoke({
client: {} as never, client: {} as never,
@@ -83,7 +173,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
sessionKey: "agent:main:main", sessionKey: "agent:main:main",
}, },
skillBins: { skillBins: {
current: async () => [], current: params.skillBinsCurrent ?? (async () => []),
}, },
execHostEnforced: false, execHostEnforced: false,
execHostFallbackAllowed: true, execHostFallbackAllowed: true,
@@ -93,7 +183,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
sanitizeEnv: () => undefined, sanitizeEnv: () => undefined,
runCommand, runCommand,
runViaMacAppExecHost, runViaMacAppExecHost,
sendNodeEvent: async () => {}, sendNodeEvent,
buildExecEventPayload: (payload) => payload, buildExecEventPayload: (payload) => payload,
sendInvokeResult, sendInvokeResult,
sendExecFinishedEvent, sendExecFinishedEvent,
@@ -110,12 +200,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
expect(runViaMacAppExecHost).not.toHaveBeenCalled(); expect(runViaMacAppExecHost).not.toHaveBeenCalled();
expect(runCommand).toHaveBeenCalledTimes(1); expect(runCommand).toHaveBeenCalledTimes(1);
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeOk(sendInvokeResult, { payloadContains: "local-ok" });
expect.objectContaining({
ok: true,
payloadJSON: expect.stringContaining("local-ok"),
}),
);
}); });
it("uses mac app exec host when explicitly preferred", async () => { it("uses mac app exec host when explicitly preferred", async () => {
@@ -146,12 +231,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
}), }),
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeOk(sendInvokeResult, { payloadContains: "app-ok" });
expect.objectContaining({
ok: true,
payloadJSON: expect.stringContaining("app-ok"),
}),
);
}); });
it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => { it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => {
@@ -188,14 +268,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
}); });
if (process.platform === "win32") { if (process.platform === "win32") {
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("allowlist miss"),
}),
}),
);
return; return;
} }
@@ -203,11 +276,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
expect(runArgs).toBeDefined(); expect(runArgs).toBeDefined();
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/); expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
expect(runArgs?.slice(1)).toEqual(["a", "b"]); expect(runArgs?.slice(1)).toEqual(["a", "b"]);
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeOk(sendInvokeResult);
expect.objectContaining({
ok: true,
}),
);
}); });
it("denies semantic env wrappers in allowlist mode", async () => { it("denies semantic env wrappers in allowlist mode", async () => {
@@ -217,139 +286,76 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
command: ["env", "FOO=bar", "tr", "a", "b"], command: ["env", "FOO=bar", "tr", "a", "b"],
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("allowlist miss"),
}),
}),
);
}); });
it.runIf(process.platform !== "win32")( it.runIf(process.platform !== "win32")(
"pins PATH-token executable to canonical path for approval-based runs", "pins PATH-token executable to canonical path for approval-based runs",
async () => { async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-path-pin-")); await withPathTokenCommand({
const binDir = path.join(tmp, "bin"); tmpPrefix: "openclaw-approval-path-pin-",
fs.mkdirSync(binDir, { recursive: true }); run: async ({ expected }) => {
const link = path.join(binDir, "poccmd"); const { runCommand, sendInvokeResult } = await runSystemInvoke({
fs.symlinkSync("/bin/echo", link); preferMacAppExecHost: false,
const expected = fs.realpathSync(link); command: ["poccmd", "-n", "SAFE"],
const oldPath = process.env.PATH; approved: true,
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; security: "full",
try { ask: "off",
const { runCommand, sendInvokeResult } = await runSystemInvoke({ });
preferMacAppExecHost: false, expectCommandPinnedToCanonicalPath({
command: ["poccmd", "-n", "SAFE"], runCommand,
approved: true, expected,
security: "full", commandTail: ["-n", "SAFE"],
ask: "off", });
}); expectInvokeOk(sendInvokeResult);
expect(runCommand).toHaveBeenCalledWith( },
[expected, "-n", "SAFE"], });
undefined,
undefined,
undefined,
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
}),
);
} finally {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
fs.rmSync(tmp, { recursive: true, force: true });
}
}, },
); );
it.runIf(process.platform !== "win32")( it.runIf(process.platform !== "win32")(
"pins PATH-token executable to canonical path for allowlist runs", "pins PATH-token executable to canonical path for allowlist runs",
async () => { async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-allowlist-path-pin-"));
const binDir = path.join(tmp, "bin");
fs.mkdirSync(binDir, { recursive: true });
const link = path.join(binDir, "poccmd");
fs.symlinkSync("/bin/echo", link);
const expected = fs.realpathSync(link);
const oldPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
const runCommand = vi.fn(async () => ({ const runCommand = vi.fn(async () => ({
success: true, ...createLocalRunResult(),
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
})); }));
const sendInvokeResult = vi.fn(async () => {}); const sendInvokeResult = vi.fn(async () => {});
const sendNodeEvent = vi.fn(async () => {}); await withPathTokenCommand({
try { tmpPrefix: "openclaw-allowlist-path-pin-",
await withTempApprovalsHome({ run: async ({ link, expected }) => {
approvals: { await withTempApprovalsHome({
version: 1, approvals: {
defaults: { version: 1,
security: "allowlist", defaults: {
ask: "off", security: "allowlist",
askFallback: "deny", ask: "off",
}, askFallback: "deny",
agents: { },
main: { agents: {
allowlist: [{ pattern: link }], main: {
allowlist: [{ pattern: link }],
},
}, },
}, },
}, run: async () => {
run: async () => { await runSystemInvoke({
await handleSystemRunInvoke({ preferMacAppExecHost: false,
client: {} as never,
params: {
command: ["poccmd", "-n", "SAFE"], command: ["poccmd", "-n", "SAFE"],
sessionKey: "agent:main:main", security: "allowlist",
}, ask: "off",
skillBins: { runCommand,
current: async () => [], sendInvokeResult,
}, });
execHostEnforced: false, },
execHostFallbackAllowed: true, });
resolveExecSecurity: () => "allowlist", expectCommandPinnedToCanonicalPath({
resolveExecAsk: () => "off", runCommand,
isCmdExeInvocation: () => false, expected,
sanitizeEnv: () => undefined, commandTail: ["-n", "SAFE"],
runCommand, });
runViaMacAppExecHost: vi.fn(async () => null), expectInvokeOk(sendInvokeResult);
sendNodeEvent, },
buildExecEventPayload: (payload) => payload, });
sendInvokeResult,
sendExecFinishedEvent: vi.fn(async () => {}),
preferMacAppExecHost: false,
});
},
});
expect(runCommand).toHaveBeenCalledWith(
[expected, "-n", "SAFE"],
undefined,
undefined,
undefined,
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
}),
);
} finally {
if (oldPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = oldPath;
}
fs.rmSync(tmp, { recursive: true, force: true });
}
}, },
); );
@@ -374,14 +380,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask: "off", ask: "off",
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeErrorMessage(sendInvokeResult, { message: "canonical cwd" });
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("canonical cwd"),
}),
}),
);
} finally { } finally {
fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(tmp, { recursive: true, force: true });
} }
@@ -407,14 +406,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask: "off", ask: "off",
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeErrorMessage(sendInvokeResult, { message: "no symlink path components" });
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("no symlink path components"),
}),
}),
);
} finally { } finally {
fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(tmp, { recursive: true, force: true });
} }
@@ -435,17 +427,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
security: "full", security: "full",
ask: "off", ask: "off",
}); });
expect(runCommand).toHaveBeenCalledWith( expectCommandPinnedToCanonicalPath({
[fs.realpathSync(script), "--flag"], runCommand,
fs.realpathSync(tmp), expected: fs.realpathSync(script),
undefined, commandTail: ["--flag"],
undefined, cwd: fs.realpathSync(tmp),
); });
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeOk(sendInvokeResult);
expect.objectContaining({
ok: true,
}),
);
} finally { } finally {
fs.rmSync(tmp, { recursive: true, force: true }); fs.rmSync(tmp, { recursive: true, force: true });
} }
@@ -454,58 +442,24 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`); const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`);
const runCommand = vi.fn(async () => { const runCommand = vi.fn(async () => {
fs.writeFileSync(marker, "executed"); fs.writeFileSync(marker, "executed");
return { return createLocalRunResult();
success: true,
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
};
}); });
const sendInvokeResult = vi.fn(async () => {}); const sendInvokeResult = vi.fn(async () => {});
const sendNodeEvent = vi.fn(async () => {}); const sendNodeEvent = vi.fn(async () => {});
await handleSystemRunInvoke({ await runSystemInvoke({
client: {} as never,
params: {
command: ["./sh", "-lc", "/bin/echo approved-only"],
sessionKey: "agent:main:main",
},
skillBins: {
current: async () => [],
},
execHostEnforced: false,
execHostFallbackAllowed: true,
resolveExecSecurity: () => "allowlist",
resolveExecAsk: () => "on-miss",
isCmdExeInvocation: () => false,
sanitizeEnv: () => undefined,
runCommand,
runViaMacAppExecHost: vi.fn(async () => null),
sendNodeEvent,
buildExecEventPayload: (payload) => payload,
sendInvokeResult,
sendExecFinishedEvent: vi.fn(async () => {}),
preferMacAppExecHost: false, preferMacAppExecHost: false,
command: ["./sh", "-lc", "/bin/echo approved-only"],
security: "allowlist",
ask: "on-miss",
runCommand,
sendInvokeResult,
sendNodeEvent,
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(fs.existsSync(marker)).toBe(false); expect(fs.existsSync(marker)).toBe(false);
expect(sendNodeEvent).toHaveBeenCalledWith( expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
expect.anything(),
"exec.denied",
expect.objectContaining({ reason: "approval-required" }),
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: "SYSTEM_RUN_DENIED: approval required",
}),
}),
);
try { try {
fs.unlinkSync(marker); fs.unlinkSync(marker);
} catch { } catch {
@@ -514,15 +468,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
}); });
it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => { it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => {
const runCommand = vi.fn(async () => ({ const runCommand = vi.fn(async () => createLocalRunResult());
success: true,
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
}));
const sendInvokeResult = vi.fn(async () => {}); const sendInvokeResult = vi.fn(async () => {});
const sendNodeEvent = vi.fn(async () => {}); const sendNodeEvent = vi.fn(async () => {});
@@ -541,47 +487,22 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
const skillBinPath = path.join(tempHome, "skill-bin"); const skillBinPath = path.join(tempHome, "skill-bin");
fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 }); fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 });
fs.chmodSync(skillBinPath, 0o755); fs.chmodSync(skillBinPath, 0o755);
await handleSystemRunInvoke({ await runSystemInvoke({
client: {} as never,
params: {
command: ["./skill-bin", "--help"],
cwd: tempHome,
sessionKey: "agent:main:main",
},
skillBins: {
current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }],
},
execHostEnforced: false,
execHostFallbackAllowed: true,
resolveExecSecurity: () => "allowlist",
resolveExecAsk: () => "on-miss",
isCmdExeInvocation: () => false,
sanitizeEnv: () => undefined,
runCommand,
runViaMacAppExecHost: vi.fn(async () => null),
sendNodeEvent,
buildExecEventPayload: (payload) => payload,
sendInvokeResult,
sendExecFinishedEvent: vi.fn(async () => {}),
preferMacAppExecHost: false, preferMacAppExecHost: false,
command: ["./skill-bin", "--help"],
cwd: tempHome,
security: "allowlist",
ask: "on-miss",
skillBinsCurrent: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }],
runCommand,
sendInvokeResult,
sendNodeEvent,
}); });
}, },
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendNodeEvent).toHaveBeenCalledWith( expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
expect.anything(),
"exec.denied",
expect.objectContaining({ reason: "approval-required" }),
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: "SYSTEM_RUN_DENIED: approval required",
}),
}),
);
}); });
it("denies env -S shell payloads in allowlist mode", async () => { it("denies env -S shell payloads in allowlist mode", async () => {
@@ -591,14 +512,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
command: ["env", "-S", 'sh -c "echo pwned"'], command: ["env", "-S", 'sh -c "echo pwned"'],
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith( expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("allowlist miss"),
}),
}),
);
}); });
it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => { it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => {
@@ -615,14 +529,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
command, command,
}); });
expect(runCommand, payload).not.toHaveBeenCalled(); expect(runCommand, payload).not.toHaveBeenCalled();
expect(sendInvokeResult, payload).toHaveBeenCalledWith( expectInvokeErrorMessage(sendInvokeResult, {
expect.objectContaining({ message: "SYSTEM_RUN_DENIED: approval required",
ok: false, exact: true,
error: expect.objectContaining({ });
message: "SYSTEM_RUN_DENIED: approval required",
}),
}),
);
} }
}); });
@@ -652,49 +562,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
}, },
run: async ({ tempHome }) => { run: async ({ tempHome }) => {
const marker = path.join(tempHome, "pwned.txt"); const marker = path.join(tempHome, "pwned.txt");
await handleSystemRunInvoke({ await runSystemInvoke({
client: {} as never,
params: {
command: buildNestedEnvShellCommand({
depth: 5,
payload: `echo PWNED > ${marker}`,
}),
sessionKey: "agent:main:main",
},
skillBins: {
current: async () => [],
},
execHostEnforced: false,
execHostFallbackAllowed: true,
resolveExecSecurity: () => "allowlist",
resolveExecAsk: () => "on-miss",
isCmdExeInvocation: () => false,
sanitizeEnv: () => undefined,
runCommand,
runViaMacAppExecHost: vi.fn(async () => null),
sendNodeEvent,
buildExecEventPayload: (payload) => payload,
sendInvokeResult,
sendExecFinishedEvent: vi.fn(async () => {}),
preferMacAppExecHost: false, preferMacAppExecHost: false,
command: buildNestedEnvShellCommand({
depth: 5,
payload: `echo PWNED > ${marker}`,
}),
security: "allowlist",
ask: "on-miss",
runCommand,
sendInvokeResult,
sendNodeEvent,
}); });
expect(fs.existsSync(marker)).toBe(false); expect(fs.existsSync(marker)).toBe(false);
}, },
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
expect(sendNodeEvent).toHaveBeenCalledWith( expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
expect.anything(),
"exec.denied",
expect.objectContaining({ reason: "approval-required" }),
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: "SYSTEM_RUN_DENIED: approval required",
}),
}),
);
}); });
}); });