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 }) {
return await new Promise<GatewayClient>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: GatewayClient) => {
if (settled) {
let done = false;
const finish = (result: { client?: GatewayClient; error?: Error }) => {
if (done) {
return;
}
settled = true;
clearTimeout(timer);
if (err) {
reject(err);
} else {
resolve(client as GatewayClient);
done = true;
clearTimeout(connectTimeout);
if (result.error) {
reject(result.error);
return;
}
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({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientVersion: "dev",
mode: "test",
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
onHelloOk: () => finish({ client }),
onConnectError: (error) => finish({ error }),
onClose: failWithClose,
});
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();
});
}

View File

@@ -325,20 +325,33 @@ describe("gateway agent handler", () => {
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();
await invokeAgent(
{
message: "owner-tools check",
sessionKey: "agent:main:main",
idempotencyKey: "test-sender-owner-write",
idempotencyKey,
},
{
client: {
connect: {
role: "operator",
scopes: ["operator.write"],
scopes,
client: { id: "test-client", mode: "gateway" },
},
} as unknown as AgentHandlerArgs["client"],
@@ -349,34 +362,7 @@ describe("gateway agent handler", () => {
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
| { senderIsOwner?: boolean }
| undefined;
expect(callArgs?.senderIsOwner).toBe(false);
});
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);
expect(callArgs?.senderIsOwner).toBe(senderIsOwner);
});
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();
}
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(() => {
mocks.fsReadFile.mockImplementation(async () => {
throw createEnoentError();
@@ -517,7 +531,7 @@ describe("agents.files.get/set symlink safety", () => {
mocks.fsMkdir.mockResolvedValue(undefined);
});
it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => {
function mockWorkspaceEscapeSymlink() {
const workspace = "/workspace/test-agent";
const candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => {
@@ -536,54 +550,21 @@ describe("agents.files.get/set symlink safety", () => {
}
throw createEnoentError();
});
}
const { respond, promise } = makeCall("agents.files.get", {
agentId: "main",
name: "AGENTS.md",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
});
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;
it.each([
{ method: "agents.files.get" as const, expectNoOpen: false },
{ method: "agents.files.set" as const, expectNoOpen: true },
])(
"rejects $method when allowlisted file symlink escapes workspace",
async ({ method, expectNoOpen }) => {
mockWorkspaceEscapeSymlink();
await expectUnsafeWorkspaceFile(method);
if (expectNoOpen) {
expect(mocks.fsOpen).not.toHaveBeenCalled();
}
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 () => {
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 candidate = path.resolve(workspace, "AGENTS.md");
mocks.fsRealpath.mockImplementation(async (p: string) => {
@@ -670,49 +651,19 @@ describe("agents.files.get/set symlink safety", () => {
}
throw createEnoentError();
});
}
const { respond, promise } = makeCall("agents.files.get", {
agentId: "main",
name: "AGENTS.md",
});
await promise;
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
);
});
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;
it.each([
{ method: "agents.files.get" as const, expectNoOpen: false },
{ method: "agents.files.set" as const, expectNoOpen: true },
])(
"rejects $method when allowlisted file is a hardlinked alias",
async ({ method, expectNoOpen }) => {
mockHardlinkedWorkspaceAlias();
await expectUnsafeWorkspaceFile(method);
if (expectNoOpen) {
expect(mocks.fsOpen).not.toHaveBeenCalled();
}
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),
}));
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", () => ({
resolveSessionAgentId: ({
sessionKey,
@@ -37,21 +53,13 @@ vi.mock("../../agents/agent-scope.js", () => ({
sessionKey?: string;
config?: unknown;
agentId?: string;
}) => {
if (typeof sessionKey === "string") {
const match = sessionKey.match(/^agent:([^:]+)/i);
if (match?.[1]) {
return match[1];
}
}
return "main";
},
}) => resolveAgentIdFromSessionKeyForTests({ sessionKey }),
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE,
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
}));
vi.mock("../../plugins/loader.js", () => ({

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,28 @@ import {
shouldEnforceGatewayAuthForPluginPath,
} 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", () => {
it("returns false when no handlers are registered", async () => {
const log = { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"];
const log = createPluginLog();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry(),
log,
@@ -32,9 +49,7 @@ describe("createGatewayPluginRequestHandler", () => {
{ pluginId: "second", handler: second, source: "second" },
],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
log: createPluginLog(),
});
const { res } = makeMockHttpResponse();
@@ -51,19 +66,10 @@ describe("createGatewayPluginRequestHandler", () => {
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/demo",
handler: routeHandler,
source: "route",
},
],
httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
log: createPluginLog(),
});
const { res } = makeMockHttpResponse();
@@ -80,19 +86,10 @@ describe("createGatewayPluginRequestHandler", () => {
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/api/demo",
handler: routeHandler,
source: "route",
},
],
httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
log: createPluginLog(),
});
const { res } = makeMockHttpResponse();
@@ -103,9 +100,7 @@ describe("createGatewayPluginRequestHandler", () => {
});
it("logs and responds with 500 when a handler throws", async () => {
const log = { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"];
const log = createPluginLog();
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpHandlers: [
@@ -134,14 +129,7 @@ describe("createGatewayPluginRequestHandler", () => {
describe("plugin HTTP registry helpers", () => {
it("detects registered route paths", () => {
const registry = createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/demo",
handler: () => {},
source: "route",
},
],
httpRoutes: [createRoute({ path: "/demo" })],
});
expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false);
@@ -149,14 +137,7 @@ describe("plugin HTTP registry helpers", () => {
it("matches canonicalized variants of registered route paths", () => {
const registry = createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/api/demo",
handler: () => {},
source: "route",
},
],
httpRoutes: [createRoute({ path: "/api/demo" })],
});
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", () => {
const registry = createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/api/demo",
handler: () => {},
source: "route",
},
],
httpRoutes: [createRoute({ path: "/api/demo" })],
});
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);

View File

@@ -40,6 +40,39 @@ function createSingleAgentAvatarConfig(workspace: string): 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", () => {
test("capArrayByJsonBytes trims from the front", () => {
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
@@ -281,13 +314,9 @@ describe("gateway session utils", () => {
describe("resolveSessionModelRef", () => {
test("prefers runtime model/provider from session entry", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s1",
@@ -302,13 +331,9 @@ describe("resolveSessionModelRef", () => {
});
test("preserves openrouter provider when model contains vendor prefix", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "openrouter/minimax/minimax-m2.5" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "openrouter/minimax/minimax-m2.5",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s-or",
@@ -324,13 +349,9 @@ describe("resolveSessionModelRef", () => {
});
test("falls back to override when runtime model is not recorded yet", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-6" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "anthropic/claude-opus-4-6",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "s2",
@@ -342,13 +363,9 @@ describe("resolveSessionModelRef", () => {
});
test("falls back to resolved provider for unprefixed legacy runtime model", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "legacy-session",
@@ -366,13 +383,9 @@ describe("resolveSessionModelRef", () => {
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")
// parseModelRef should extract it correctly even without modelProvider set.
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveSessionModelRef(cfg, {
sessionId: "slash-model",
@@ -387,13 +400,9 @@ describe("resolveSessionModelRef", () => {
describe("resolveSessionModelIdentityRef", () => {
test("does not inherit default provider for unprefixed legacy runtime model", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session",
@@ -406,16 +415,12 @@ describe("resolveSessionModelIdentityRef", () => {
});
test("infers provider from configured model allowlist when unambiguous", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
models: {
"anthropic/claude-sonnet-4-6": {},
},
},
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
models: {
"anthropic/claude-sonnet-4-6": {},
},
} as OpenClawConfig;
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session",
@@ -428,17 +433,13 @@ describe("resolveSessionModelIdentityRef", () => {
});
test("keeps provider unknown when configured models are ambiguous", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
models: {
"anthropic/claude-sonnet-4-6": {},
"minimax/claude-sonnet-4-6": {},
},
},
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
models: {
"anthropic/claude-sonnet-4-6": {},
"minimax/claude-sonnet-4-6": {},
},
} as OpenClawConfig;
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "legacy-session",
@@ -451,13 +452,9 @@ describe("resolveSessionModelIdentityRef", () => {
});
test("preserves provider from slash-prefixed runtime model", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} as OpenClawConfig;
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "slash-model",
@@ -470,16 +467,12 @@ describe("resolveSessionModelIdentityRef", () => {
});
test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
models: {
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
},
},
const cfg = createModelDefaultsConfig({
primary: "google-gemini-cli/gemini-3-pro-preview",
models: {
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
},
} as OpenClawConfig;
});
const resolved = resolveSessionModelIdentityRef(cfg, {
sessionId: "slash-model",
@@ -683,97 +676,37 @@ describe("listSessionsFromStore search", () => {
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
});
test("does not guess provider for legacy runtime model without modelProvider", () => {
const cfg = {
session: { mainKey: "main" },
agents: {
defaults: {
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
},
},
} 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,
};
test.each([
{
name: "does not guess provider for legacy runtime model without modelProvider",
cfg: createLegacyRuntimeListConfig(),
runtimeModel: "claude-sonnet-4-6",
expectedProvider: undefined,
},
{
name: "infers provider for legacy runtime model when allowlist match is unique",
cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }),
runtimeModel: "claude-sonnet-4-6",
expectedProvider: "anthropic",
},
{
name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique",
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({
cfg,
storePath: "/tmp/sessions.json",
store,
store: createLegacyRuntimeStore(runtimeModel),
opts: {},
});
expect(result.sessions[0]?.modelProvider).toBeUndefined();
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6");
});
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");
expect(result.sessions[0]?.modelProvider).toBe(expectedProvider);
expect(result.sessions[0]?.model).toBe(runtimeModel);
});
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 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) {
const res = await applySessionsPatchToStore({
cfg,
store: {},
storeKey: KIMI_SUBAGENT_KEY,
patch: {
key: KIMI_SUBAGENT_KEY,
model: SUBAGENT_MODEL,
},
loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
{ 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;
return expectPatchOk(
await runPatch({
cfg,
storeKey: KIMI_SUBAGENT_KEY,
patch: {
key: KIMI_SUBAGENT_KEY,
model: SUBAGENT_MODEL,
},
loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
],
}),
);
}
function makeKimiSubagentCfg(params: {
@@ -54,131 +91,100 @@ function makeKimiSubagentCfg(params: {
} 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", () => {
test("persists thinkingLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", thinkingLevel: "off" },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.thinkingLevel).toBe("off");
const entry = expectPatchOk(
await runPatch({
patch: { key: MAIN_SESSION_KEY, thinkingLevel: "off" },
}),
);
expect(entry.thinkingLevel).toBe("off");
});
test("clears thinkingLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = {
"agent:main:main": { thinkingLevel: "low" } as SessionEntry,
[MAIN_SESSION_KEY]: { thinkingLevel: "low" } as SessionEntry,
};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", thinkingLevel: null },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.thinkingLevel).toBeUndefined();
const entry = expectPatchOk(
await runPatch({
store,
patch: { key: MAIN_SESSION_KEY, thinkingLevel: null },
}),
);
expect(entry.thinkingLevel).toBeUndefined();
});
test("persists reasoningLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", reasoningLevel: "off" },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.reasoningLevel).toBe("off");
const entry = expectPatchOk(
await runPatch({
patch: { key: MAIN_SESSION_KEY, reasoningLevel: "off" },
}),
);
expect(entry.reasoningLevel).toBe("off");
});
test("clears reasoningLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = {
"agent:main:main": { reasoningLevel: "stream" } as SessionEntry,
[MAIN_SESSION_KEY]: { reasoningLevel: "stream" } as SessionEntry,
};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", reasoningLevel: null },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.reasoningLevel).toBeUndefined();
const entry = expectPatchOk(
await runPatch({
store,
patch: { key: MAIN_SESSION_KEY, reasoningLevel: null },
}),
);
expect(entry.reasoningLevel).toBeUndefined();
});
test("persists elevatedLevel=off (does not clear)", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", elevatedLevel: "off" },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.elevatedLevel).toBe("off");
const entry = expectPatchOk(
await runPatch({
patch: { key: MAIN_SESSION_KEY, elevatedLevel: "off" },
}),
);
expect(entry.elevatedLevel).toBe("off");
});
test("persists elevatedLevel=on", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", elevatedLevel: "on" },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.elevatedLevel).toBe("on");
const entry = expectPatchOk(
await runPatch({
patch: { key: MAIN_SESSION_KEY, elevatedLevel: "on" },
}),
);
expect(entry.elevatedLevel).toBe("on");
});
test("clears elevatedLevel when patch sets null", async () => {
const store: Record<string, SessionEntry> = {
"agent:main:main": { elevatedLevel: "off" } as SessionEntry,
[MAIN_SESSION_KEY]: { elevatedLevel: "off" } as SessionEntry,
};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", elevatedLevel: null },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.elevatedLevel).toBeUndefined();
const entry = expectPatchOk(
await runPatch({
store,
patch: { key: MAIN_SESSION_KEY, elevatedLevel: null },
}),
);
expect(entry.elevatedLevel).toBeUndefined();
});
test("rejects invalid elevatedLevel values", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", elevatedLevel: "maybe" },
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, elevatedLevel: "maybe" },
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid elevatedLevel");
expectPatchError(result, "invalid elevatedLevel");
});
test("clears auth overrides when model patch changes", async () => {
@@ -193,189 +199,107 @@ describe("gateway sessions patch", () => {
authProfileOverrideCompactionCount: 3,
} as SessionEntry,
};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", model: "openai/gpt-5.2" },
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }],
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.providerOverride).toBe("openai");
expect(res.entry.modelOverride).toBe("gpt-5.2");
expect(res.entry.authProfileOverride).toBeUndefined();
expect(res.entry.authProfileOverrideSource).toBeUndefined();
expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined();
const entry = expectPatchOk(
await runPatch({
store,
patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.2" },
loadGatewayModelCatalog: async () => [
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
],
}),
);
expect(entry.providerOverride).toBe("openai");
expect(entry.modelOverride).toBe("gpt-5.2");
expect(entry.authProfileOverride).toBeUndefined();
expect(entry.authProfileOverrideSource).toBeUndefined();
expect(entry.authProfileOverrideCompactionCount).toBeUndefined();
});
test("accepts explicit allowlisted provider/model refs from sessions.patch", 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 () => [
test.each([
{
name: "accepts explicit allowlisted provider/model refs from sessions.patch",
catalog: [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
],
});
expect(res.ok).toBe(true);
if (!res.ok) {
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 () => [
},
{
name: "accepts explicit allowlisted refs absent from bundled catalog",
catalog: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
],
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.providerOverride).toBe("anthropic");
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6");
},
])("$name", async ({ catalog }) => {
const entry = expectPatchOk(
await runPatch({
cfg: createAllowlistedAnthropicModelCfg(),
patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" },
loadGatewayModelCatalog: async () => catalog,
}),
);
expect(entry.providerOverride).toBe("anthropic");
expect(entry.modelOverride).toBe("claude-sonnet-4-6");
});
test("sets spawnDepth for subagent sessions", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:subagent:child",
patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.spawnDepth).toBe(2);
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:subagent:child",
patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
}),
);
expect(entry.spawnDepth).toBe(2);
});
test("rejects spawnDepth on non-subagent sessions", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", spawnDepth: 1 },
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.error.message).toContain("spawnDepth is only supported");
expectPatchError(result, "spawnDepth is only supported");
});
test("normalizes exec/send/group patches", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: {
key: "agent:main:main",
execHost: " NODE ",
execSecurity: " ALLOWLIST ",
execAsk: " ON-MISS ",
execNode: " worker-1 ",
sendPolicy: "DENY" as unknown as "allow",
groupActivation: "Always" as unknown as "mention",
},
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
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");
const entry = expectPatchOk(
await runPatch({
patch: {
key: MAIN_SESSION_KEY,
execHost: " NODE ",
execSecurity: " ALLOWLIST ",
execAsk: " ON-MISS ",
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(entry.execAsk).toBe("on-miss");
expect(entry.execNode).toBe("worker-1");
expect(entry.sendPolicy).toBe("deny");
expect(entry.groupActivation).toBe("always");
});
test("rejects invalid execHost values", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", execHost: "edge" },
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, execHost: "edge" },
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid execHost");
expectPatchError(result, "invalid execHost");
});
test("rejects invalid sendPolicy values", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", sendPolicy: "ask" as unknown as "allow" },
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, sendPolicy: "ask" as unknown as "allow" },
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid sendPolicy");
expectPatchError(result, "invalid sendPolicy");
});
test("rejects invalid groupActivation values", async () => {
const store: Record<string, SessionEntry> = {};
const res = await applySessionsPatchToStore({
cfg: {} as OpenClawConfig,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", groupActivation: "never" as unknown as "mention" },
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, groupActivation: "never" as unknown as "mention" },
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid groupActivation");
expectPatchError(result, "invalid groupActivation");
});
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";
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", () => ({
loadConfig: () => cfg,
@@ -15,19 +19,19 @@ vi.mock("../config/sessions.js", () => ({
}));
vi.mock("./auth.js", () => ({
authorizeHttpGatewayConnect: async () => ({ ok: true }),
authorizeHttpGatewayConnect: alwaysAuthorized,
}));
vi.mock("../logger.js", () => ({
logWarn: () => {},
logWarn: noWarnLog,
}));
vi.mock("../plugins/config-state.js", () => ({
isTestDefaultMemorySlotDisabled: () => false,
isTestDefaultMemorySlotDisabled: disableDefaultMemorySlot,
}));
vi.mock("../plugins/tools.js", () => ({
getPluginToolMeta: () => undefined,
getPluginToolMeta: noPluginToolMeta,
}));
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];
}
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", () => {
const baseResolution = {
rawExecutable: "rg",
@@ -288,16 +303,9 @@ describe("exec approvals command resolution", () => {
if (process.platform !== "win32") {
fs.chmodSync(envPath, 0o755);
}
const analysis = analyzeArgvCommand({
const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({
argv: [envPath, "-S", 'sh -c "echo pwned"'],
cwd: dir,
env: makePathEnv(binDir),
});
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: [{ pattern: envPath }],
safeBins: normalizeSafeBins([]),
envPath: envPath,
cwd: dir,
});
@@ -317,20 +325,13 @@ describe("exec approvals command resolution", () => {
const envPath = path.join(binDir, "env");
fs.writeFileSync(envPath, "#!/bin/sh\n");
fs.chmodSync(envPath, 0o755);
const analysis = analyzeArgvCommand({
const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({
argv: buildNestedEnvShellCommand({
envExecutable: envPath,
depth: 5,
payload: "echo pwned",
}),
cwd: dir,
env: makePathEnv(binDir),
});
const allowlistEval = evaluateExecAllowlist({
analysis,
allowlist: [{ pattern: envPath }],
safeBins: normalizeSafeBins([]),
envPath,
cwd: dir,
});

View File

@@ -5,18 +5,28 @@ const mocks = vi.hoisted(() => ({
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", () => ({
getChannelPlugin: mocks.getChannelPlugin,
normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined,
normalizeChannelId: normalizeChannel,
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
resolveAgentWorkspaceDir: () => TEST_WORKSPACE_ROOT,
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
}));
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 () => {
await setupGitCheckout();
const { runner, calls } = createRunner({
@@ -392,23 +425,14 @@ describe("runGatewayUpdate", () => {
await seedGlobalPackageRoot(pkgRoot);
let stalePresentAtInstall = true;
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
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") {
const runCommand = createGlobalNpmUpdateRunner({
nodeModules,
pkgRoot,
onBaseInstall: async () => {
stalePresentAtInstall = await pathExists(staleDir);
return { stdout: "ok", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
},
});
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
@@ -423,33 +447,22 @@ describe("runGatewayUpdate", () => {
await seedGlobalPackageRoot(pkgRoot);
let firstAttempt = true;
const runCommand = async (argv: string[]) => {
const key = argv.join(" ");
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
return { stdout: "", stderr: "not a git repository", code: 128 };
}
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") {
const runCommand = createGlobalNpmUpdateRunner({
nodeModules,
pkgRoot,
onBaseInstall: async () => {
firstAttempt = false;
return { stdout: "", stderr: "node-gyp failed", code: 1 };
}
if (
key === "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error"
) {
},
onOmitOptionalInstall: async () => {
await fs.writeFile(
path.join(pkgRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
"utf-8",
);
return { stdout: "ok", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
},
});
const result = await runWithCommand(runCommand, { cwd: pkgRoot });

View File

@@ -21,6 +21,61 @@ describe("formatSystemRunAllowlistMissMessage", () => {
});
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[] {
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: {
preferMacAppExecHost: boolean;
runViaResponse?: ExecHostResponse | null;
@@ -53,26 +146,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
security?: "full" | "allowlist";
ask?: "off" | "on-miss" | "always";
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(
async (
_command: string[],
_cwd?: string,
_env?: Record<string, string>,
_timeoutMs?: number,
) => ({
success: true,
stdout: "local-ok",
stderr: "",
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 () => {});
const runCommand =
params.runCommand ??
vi.fn(async (_command: string[], _cwd?: string, _env?: Record<string, string>) =>
createLocalRunResult(),
);
const runViaMacAppExecHost =
params.runViaMacAppExecHost ?? vi.fn(async () => params.runViaResponse ?? null);
const sendInvokeResult = params.sendInvokeResult ?? vi.fn(async () => {});
const sendExecFinishedEvent = params.sendExecFinishedEvent ?? vi.fn(async () => {});
const sendNodeEvent = params.sendNodeEvent ?? vi.fn(async () => {});
await handleSystemRunInvoke({
client: {} as never,
@@ -83,7 +173,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
sessionKey: "agent:main:main",
},
skillBins: {
current: async () => [],
current: params.skillBinsCurrent ?? (async () => []),
},
execHostEnforced: false,
execHostFallbackAllowed: true,
@@ -93,7 +183,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
sanitizeEnv: () => undefined,
runCommand,
runViaMacAppExecHost,
sendNodeEvent: async () => {},
sendNodeEvent,
buildExecEventPayload: (payload) => payload,
sendInvokeResult,
sendExecFinishedEvent,
@@ -110,12 +200,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
expect(runViaMacAppExecHost).not.toHaveBeenCalled();
expect(runCommand).toHaveBeenCalledTimes(1);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
payloadJSON: expect.stringContaining("local-ok"),
}),
);
expectInvokeOk(sendInvokeResult, { payloadContains: "local-ok" });
});
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(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
payloadJSON: expect.stringContaining("app-ok"),
}),
);
expectInvokeOk(sendInvokeResult, { payloadContains: "app-ok" });
});
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") {
expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("allowlist miss"),
}),
}),
);
expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
return;
}
@@ -203,11 +276,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
expect(runArgs).toBeDefined();
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
expect(runArgs?.slice(1)).toEqual(["a", "b"]);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
}),
);
expectInvokeOk(sendInvokeResult);
});
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"],
});
expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("allowlist miss"),
}),
}),
);
expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
});
it.runIf(process.platform !== "win32")(
"pins PATH-token executable to canonical path for approval-based runs",
async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-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 ?? ""}`;
try {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: ["poccmd", "-n", "SAFE"],
approved: true,
security: "full",
ask: "off",
});
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 });
}
await withPathTokenCommand({
tmpPrefix: "openclaw-approval-path-pin-",
run: async ({ expected }) => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
command: ["poccmd", "-n", "SAFE"],
approved: true,
security: "full",
ask: "off",
});
expectCommandPinnedToCanonicalPath({
runCommand,
expected,
commandTail: ["-n", "SAFE"],
});
expectInvokeOk(sendInvokeResult);
},
});
},
);
it.runIf(process.platform !== "win32")(
"pins PATH-token executable to canonical path for allowlist runs",
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 () => ({
success: true,
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
...createLocalRunResult(),
}));
const sendInvokeResult = vi.fn(async () => {});
const sendNodeEvent = vi.fn(async () => {});
try {
await withTempApprovalsHome({
approvals: {
version: 1,
defaults: {
security: "allowlist",
ask: "off",
askFallback: "deny",
},
agents: {
main: {
allowlist: [{ pattern: link }],
await withPathTokenCommand({
tmpPrefix: "openclaw-allowlist-path-pin-",
run: async ({ link, expected }) => {
await withTempApprovalsHome({
approvals: {
version: 1,
defaults: {
security: "allowlist",
ask: "off",
askFallback: "deny",
},
agents: {
main: {
allowlist: [{ pattern: link }],
},
},
},
},
run: async () => {
await handleSystemRunInvoke({
client: {} as never,
params: {
run: async () => {
await runSystemInvoke({
preferMacAppExecHost: false,
command: ["poccmd", "-n", "SAFE"],
sessionKey: "agent:main:main",
},
skillBins: {
current: async () => [],
},
execHostEnforced: false,
execHostFallbackAllowed: true,
resolveExecSecurity: () => "allowlist",
resolveExecAsk: () => "off",
isCmdExeInvocation: () => false,
sanitizeEnv: () => undefined,
runCommand,
runViaMacAppExecHost: vi.fn(async () => null),
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 });
}
security: "allowlist",
ask: "off",
runCommand,
sendInvokeResult,
});
},
});
expectCommandPinnedToCanonicalPath({
runCommand,
expected,
commandTail: ["-n", "SAFE"],
});
expectInvokeOk(sendInvokeResult);
},
});
},
);
@@ -374,14 +380,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask: "off",
});
expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("canonical cwd"),
}),
}),
);
expectInvokeErrorMessage(sendInvokeResult, { message: "canonical cwd" });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
@@ -407,14 +406,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask: "off",
});
expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("no symlink path components"),
}),
}),
);
expectInvokeErrorMessage(sendInvokeResult, { message: "no symlink path components" });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
@@ -435,17 +427,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
security: "full",
ask: "off",
});
expect(runCommand).toHaveBeenCalledWith(
[fs.realpathSync(script), "--flag"],
fs.realpathSync(tmp),
undefined,
undefined,
);
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
}),
);
expectCommandPinnedToCanonicalPath({
runCommand,
expected: fs.realpathSync(script),
commandTail: ["--flag"],
cwd: fs.realpathSync(tmp),
});
expectInvokeOk(sendInvokeResult);
} finally {
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 runCommand = vi.fn(async () => {
fs.writeFileSync(marker, "executed");
return {
success: true,
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
};
return createLocalRunResult();
});
const sendInvokeResult = vi.fn(async () => {});
const sendNodeEvent = vi.fn(async () => {});
await handleSystemRunInvoke({
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 () => {}),
await runSystemInvoke({
preferMacAppExecHost: false,
command: ["./sh", "-lc", "/bin/echo approved-only"],
security: "allowlist",
ask: "on-miss",
runCommand,
sendInvokeResult,
sendNodeEvent,
});
expect(runCommand).not.toHaveBeenCalled();
expect(fs.existsSync(marker)).toBe(false);
expect(sendNodeEvent).toHaveBeenCalledWith(
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",
}),
}),
);
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
try {
fs.unlinkSync(marker);
} catch {
@@ -514,15 +468,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
});
it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => {
const runCommand = vi.fn(async () => ({
success: true,
stdout: "local-ok",
stderr: "",
timedOut: false,
truncated: false,
exitCode: 0,
error: null,
}));
const runCommand = vi.fn(async () => createLocalRunResult());
const sendInvokeResult = 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");
fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 });
fs.chmodSync(skillBinPath, 0o755);
await handleSystemRunInvoke({
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 () => {}),
await runSystemInvoke({
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(sendNodeEvent).toHaveBeenCalledWith(
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",
}),
}),
);
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
});
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"'],
});
expect(runCommand).not.toHaveBeenCalled();
expect(sendInvokeResult).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: expect.stringContaining("allowlist miss"),
}),
}),
);
expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
});
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,
});
expect(runCommand, payload).not.toHaveBeenCalled();
expect(sendInvokeResult, payload).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
error: expect.objectContaining({
message: "SYSTEM_RUN_DENIED: approval required",
}),
}),
);
expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: approval required",
exact: true,
});
}
});
@@ -652,49 +562,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
},
run: async ({ tempHome }) => {
const marker = path.join(tempHome, "pwned.txt");
await handleSystemRunInvoke({
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 () => {}),
await runSystemInvoke({
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(runCommand).not.toHaveBeenCalled();
expect(sendNodeEvent).toHaveBeenCalledWith(
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",
}),
}),
);
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
});
});