mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 04:50:21 +00:00
test(gateway): dedupe gateway and infra test scaffolds
This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user