mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 05:53:51 +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 }) {
|
async function connectClient(params: { url: string; token: string }) {
|
||||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||||
let settled = false;
|
let done = false;
|
||||||
const stop = (err?: Error, client?: GatewayClient) => {
|
const finish = (result: { client?: GatewayClient; error?: Error }) => {
|
||||||
if (settled) {
|
if (done) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
done = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(connectTimeout);
|
||||||
if (err) {
|
if (result.error) {
|
||||||
reject(err);
|
reject(result.error);
|
||||||
} else {
|
return;
|
||||||
resolve(client as GatewayClient);
|
|
||||||
}
|
}
|
||||||
|
resolve(result.client as GatewayClient);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const failWithClose = (code: number, reason: string) =>
|
||||||
|
finish({ error: new Error(`gateway closed during connect (${code}): ${reason}`) });
|
||||||
|
|
||||||
const client = new GatewayClient({
|
const client = new GatewayClient({
|
||||||
url: params.url,
|
url: params.url,
|
||||||
token: params.token,
|
token: params.token,
|
||||||
clientName: GATEWAY_CLIENT_NAMES.TEST,
|
clientName: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
clientVersion: "dev",
|
clientVersion: "dev",
|
||||||
mode: "test",
|
mode: "test",
|
||||||
onHelloOk: () => stop(undefined, client),
|
onHelloOk: () => finish({ client }),
|
||||||
onConnectError: (err) => stop(err),
|
onConnectError: (error) => finish({ error }),
|
||||||
onClose: (code, reason) =>
|
onClose: failWithClose,
|
||||||
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
|
|
||||||
});
|
});
|
||||||
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
|
|
||||||
timer.unref();
|
const connectTimeout = setTimeout(
|
||||||
|
() => finish({ error: new Error("gateway connect timeout") }),
|
||||||
|
10_000,
|
||||||
|
);
|
||||||
|
connectTimeout.unref();
|
||||||
client.start();
|
client.start();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,20 +325,33 @@ describe("gateway agent handler", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes senderIsOwner=false for write-scoped gateway callers", async () => {
|
it.each([
|
||||||
|
{
|
||||||
|
name: "passes senderIsOwner=false for write-scoped gateway callers",
|
||||||
|
scopes: ["operator.write"],
|
||||||
|
idempotencyKey: "test-sender-owner-write",
|
||||||
|
senderIsOwner: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "passes senderIsOwner=true for admin-scoped gateway callers",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
idempotencyKey: "test-sender-owner-admin",
|
||||||
|
senderIsOwner: true,
|
||||||
|
},
|
||||||
|
])("$name", async ({ scopes, idempotencyKey, senderIsOwner }) => {
|
||||||
primeMainAgentRun();
|
primeMainAgentRun();
|
||||||
|
|
||||||
await invokeAgent(
|
await invokeAgent(
|
||||||
{
|
{
|
||||||
message: "owner-tools check",
|
message: "owner-tools check",
|
||||||
sessionKey: "agent:main:main",
|
sessionKey: "agent:main:main",
|
||||||
idempotencyKey: "test-sender-owner-write",
|
idempotencyKey,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
client: {
|
client: {
|
||||||
connect: {
|
connect: {
|
||||||
role: "operator",
|
role: "operator",
|
||||||
scopes: ["operator.write"],
|
scopes,
|
||||||
client: { id: "test-client", mode: "gateway" },
|
client: { id: "test-client", mode: "gateway" },
|
||||||
},
|
},
|
||||||
} as unknown as AgentHandlerArgs["client"],
|
} as unknown as AgentHandlerArgs["client"],
|
||||||
@@ -349,34 +362,7 @@ describe("gateway agent handler", () => {
|
|||||||
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
|
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
|
||||||
| { senderIsOwner?: boolean }
|
| { senderIsOwner?: boolean }
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(callArgs?.senderIsOwner).toBe(false);
|
expect(callArgs?.senderIsOwner).toBe(senderIsOwner);
|
||||||
});
|
|
||||||
|
|
||||||
it("passes senderIsOwner=true for admin-scoped gateway callers", async () => {
|
|
||||||
primeMainAgentRun();
|
|
||||||
|
|
||||||
await invokeAgent(
|
|
||||||
{
|
|
||||||
message: "owner-tools check",
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
idempotencyKey: "test-sender-owner-admin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
client: {
|
|
||||||
connect: {
|
|
||||||
role: "operator",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
client: { id: "test-client", mode: "gateway" },
|
|
||||||
},
|
|
||||||
} as unknown as AgentHandlerArgs["client"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
|
|
||||||
const callArgs = mocks.agentCommand.mock.calls.at(-1)?.[0] as
|
|
||||||
| { senderIsOwner?: boolean }
|
|
||||||
| undefined;
|
|
||||||
expect(callArgs?.senderIsOwner).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects explicit bestEffortDeliver=false for main session runs", async () => {
|
it("respects explicit bestEffortDeliver=false for main session runs", async () => {
|
||||||
|
|||||||
@@ -201,6 +201,20 @@ function expectNotFoundResponseAndNoWrite(respond: ReturnType<typeof vi.fn>) {
|
|||||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectUnsafeWorkspaceFile(method: "agents.files.get" | "agents.files.set") {
|
||||||
|
const params =
|
||||||
|
method === "agents.files.set"
|
||||||
|
? { agentId: "main", name: "AGENTS.md", content: "x" }
|
||||||
|
: { agentId: "main", name: "AGENTS.md" };
|
||||||
|
const { respond, promise } = makeCall(method, params);
|
||||||
|
await promise;
|
||||||
|
expect(respond).toHaveBeenCalledWith(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.fsReadFile.mockImplementation(async () => {
|
mocks.fsReadFile.mockImplementation(async () => {
|
||||||
throw createEnoentError();
|
throw createEnoentError();
|
||||||
@@ -517,7 +531,7 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
mocks.fsMkdir.mockResolvedValue(undefined);
|
mocks.fsMkdir.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects agents.files.get when allowlisted file symlink escapes workspace", async () => {
|
function mockWorkspaceEscapeSymlink() {
|
||||||
const workspace = "/workspace/test-agent";
|
const workspace = "/workspace/test-agent";
|
||||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||||
@@ -536,54 +550,21 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
}
|
}
|
||||||
throw createEnoentError();
|
throw createEnoentError();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { respond, promise } = makeCall("agents.files.get", {
|
it.each([
|
||||||
agentId: "main",
|
{ method: "agents.files.get" as const, expectNoOpen: false },
|
||||||
name: "AGENTS.md",
|
{ method: "agents.files.set" as const, expectNoOpen: true },
|
||||||
});
|
])(
|
||||||
await promise;
|
"rejects $method when allowlisted file symlink escapes workspace",
|
||||||
|
async ({ method, expectNoOpen }) => {
|
||||||
expect(respond).toHaveBeenCalledWith(
|
mockWorkspaceEscapeSymlink();
|
||||||
false,
|
await expectUnsafeWorkspaceFile(method);
|
||||||
undefined,
|
if (expectNoOpen) {
|
||||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects agents.files.set when allowlisted file symlink escapes workspace", async () => {
|
|
||||||
const workspace = "/workspace/test-agent";
|
|
||||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
|
||||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
|
||||||
if (p === workspace) {
|
|
||||||
return workspace;
|
|
||||||
}
|
}
|
||||||
if (p === candidate) {
|
},
|
||||||
return "/outside/secret.txt";
|
);
|
||||||
}
|
|
||||||
return p;
|
|
||||||
});
|
|
||||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
|
||||||
const p = typeof args[0] === "string" ? args[0] : "";
|
|
||||||
if (p === candidate) {
|
|
||||||
return makeSymlinkStat();
|
|
||||||
}
|
|
||||||
throw createEnoentError();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { respond, promise } = makeCall("agents.files.set", {
|
|
||||||
agentId: "main",
|
|
||||||
name: "AGENTS.md",
|
|
||||||
content: "x",
|
|
||||||
});
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(respond).toHaveBeenCalledWith(
|
|
||||||
false,
|
|
||||||
undefined,
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
|
||||||
);
|
|
||||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows in-workspace symlink targets for get/set", async () => {
|
it("allows in-workspace symlink targets for get/set", async () => {
|
||||||
const workspace = "/workspace/test-agent";
|
const workspace = "/workspace/test-agent";
|
||||||
@@ -654,7 +635,7 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects agents.files.get when allowlisted file is a hardlinked alias", async () => {
|
function mockHardlinkedWorkspaceAlias() {
|
||||||
const workspace = "/workspace/test-agent";
|
const workspace = "/workspace/test-agent";
|
||||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
const candidate = path.resolve(workspace, "AGENTS.md");
|
||||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
||||||
@@ -670,49 +651,19 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
}
|
}
|
||||||
throw createEnoentError();
|
throw createEnoentError();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { respond, promise } = makeCall("agents.files.get", {
|
it.each([
|
||||||
agentId: "main",
|
{ method: "agents.files.get" as const, expectNoOpen: false },
|
||||||
name: "AGENTS.md",
|
{ method: "agents.files.set" as const, expectNoOpen: true },
|
||||||
});
|
])(
|
||||||
await promise;
|
"rejects $method when allowlisted file is a hardlinked alias",
|
||||||
|
async ({ method, expectNoOpen }) => {
|
||||||
expect(respond).toHaveBeenCalledWith(
|
mockHardlinkedWorkspaceAlias();
|
||||||
false,
|
await expectUnsafeWorkspaceFile(method);
|
||||||
undefined,
|
if (expectNoOpen) {
|
||||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects agents.files.set when allowlisted file is a hardlinked alias", async () => {
|
|
||||||
const workspace = "/workspace/test-agent";
|
|
||||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
|
||||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
|
||||||
if (p === workspace) {
|
|
||||||
return workspace;
|
|
||||||
}
|
}
|
||||||
return p;
|
},
|
||||||
});
|
);
|
||||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
|
||||||
const p = typeof args[0] === "string" ? args[0] : "";
|
|
||||||
if (p === candidate) {
|
|
||||||
return makeFileStat({ nlink: 2 });
|
|
||||||
}
|
|
||||||
throw createEnoentError();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { respond, promise } = makeCall("agents.files.set", {
|
|
||||||
agentId: "main",
|
|
||||||
name: "AGENTS.md",
|
|
||||||
content: "x",
|
|
||||||
});
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(respond).toHaveBeenCalledWith(
|
|
||||||
false,
|
|
||||||
undefined,
|
|
||||||
expect.objectContaining({ message: expect.stringContaining("unsafe workspace file") }),
|
|
||||||
);
|
|
||||||
expect(mocks.fsOpen).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ vi.mock("../../channels/plugins/index.js", () => ({
|
|||||||
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
|
normalizeChannelId: (value: string) => (value === "webchat" ? null : value),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace";
|
||||||
|
|
||||||
|
function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string {
|
||||||
|
if (typeof params.sessionKey === "string") {
|
||||||
|
const match = params.sessionKey.match(/^agent:([^:]+)/i);
|
||||||
|
if (match?.[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
function passthroughPluginAutoEnable(config: unknown) {
|
||||||
|
return { config, changes: [] as unknown[] };
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
resolveSessionAgentId: ({
|
resolveSessionAgentId: ({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -37,21 +53,13 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
config?: unknown;
|
config?: unknown;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}) => {
|
}) => resolveAgentIdFromSessionKeyForTests({ sessionKey }),
|
||||||
if (typeof sessionKey === "string") {
|
|
||||||
const match = sessionKey.match(/^agent:([^:]+)/i);
|
|
||||||
if (match?.[1]) {
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "main";
|
|
||||||
},
|
|
||||||
resolveDefaultAgentId: () => "main",
|
resolveDefaultAgentId: () => "main",
|
||||||
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
|
resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||||
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
|
applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../plugins/loader.js", () => ({
|
vi.mock("../../plugins/loader.js", () => ({
|
||||||
|
|||||||
@@ -22,18 +22,36 @@ vi.mock("../../commands/status.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("waitForAgentJob", () => {
|
describe("waitForAgentJob", () => {
|
||||||
it("maps lifecycle end events with aborted=true to timeout", async () => {
|
async function runLifecycleScenario(params: {
|
||||||
const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
runIdPrefix: string;
|
||||||
|
startedAt: number;
|
||||||
|
endedAt: number;
|
||||||
|
aborted?: boolean;
|
||||||
|
}) {
|
||||||
|
const runId = `${params.runIdPrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
|
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
|
||||||
|
|
||||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } });
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId,
|
runId,
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end", endedAt: 200, aborted: true },
|
data: { phase: "start", startedAt: params.startedAt },
|
||||||
|
});
|
||||||
|
emitAgentEvent({
|
||||||
|
runId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: { phase: "end", endedAt: params.endedAt, aborted: params.aborted },
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await waitPromise;
|
return waitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("maps lifecycle end events with aborted=true to timeout", async () => {
|
||||||
|
const snapshot = await runLifecycleScenario({
|
||||||
|
runIdPrefix: "run-timeout",
|
||||||
|
startedAt: 100,
|
||||||
|
endedAt: 200,
|
||||||
|
aborted: true,
|
||||||
|
});
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(snapshot?.status).toBe("timeout");
|
expect(snapshot?.status).toBe("timeout");
|
||||||
expect(snapshot?.startedAt).toBe(100);
|
expect(snapshot?.startedAt).toBe(100);
|
||||||
@@ -41,13 +59,11 @@ describe("waitForAgentJob", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps non-aborted lifecycle end events as ok", async () => {
|
it("keeps non-aborted lifecycle end events as ok", async () => {
|
||||||
const runId = `run-ok-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const snapshot = await runLifecycleScenario({
|
||||||
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
|
runIdPrefix: "run-ok",
|
||||||
|
startedAt: 300,
|
||||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 300 } });
|
endedAt: 400,
|
||||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 400 } });
|
});
|
||||||
|
|
||||||
const snapshot = await waitPromise;
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(snapshot?.status).toBe("ok");
|
expect(snapshot?.status).toBe("ok");
|
||||||
expect(snapshot?.startedAt).toBe(300);
|
expect(snapshot?.startedAt).toBe(300);
|
||||||
@@ -359,47 +375,43 @@ describe("exec approval handlers", () => {
|
|||||||
return { handlers, broadcasts, respond, context };
|
return { handlers, broadcasts, respond, context };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createForwardingExecApprovalFixture() {
|
||||||
|
const manager = new ExecApprovalManager();
|
||||||
|
const forwarder = {
|
||||||
|
handleRequested: vi.fn(async () => false),
|
||||||
|
handleResolved: vi.fn(async () => {}),
|
||||||
|
stop: vi.fn(),
|
||||||
|
};
|
||||||
|
const handlers = createExecApprovalHandlers(manager, { forwarder });
|
||||||
|
const respond = vi.fn();
|
||||||
|
const context = {
|
||||||
|
broadcast: (_event: string, _payload: unknown) => {},
|
||||||
|
hasExecApprovalClients: () => false,
|
||||||
|
};
|
||||||
|
return { manager, handlers, forwarder, respond, context };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drainApprovalRequestTicks() {
|
||||||
|
for (let idx = 0; idx < 20; idx += 1) {
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("ExecApprovalRequestParams validation", () => {
|
describe("ExecApprovalRequestParams validation", () => {
|
||||||
it("accepts request with resolvedPath omitted", () => {
|
const baseParams = {
|
||||||
const params = {
|
command: "echo hi",
|
||||||
command: "echo hi",
|
cwd: "/tmp",
|
||||||
cwd: "/tmp",
|
nodeId: "node-1",
|
||||||
nodeId: "node-1",
|
host: "node",
|
||||||
host: "node",
|
};
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with resolvedPath as string", () => {
|
it.each([
|
||||||
const params = {
|
{ label: "omitted", extra: {} },
|
||||||
command: "echo hi",
|
{ label: "string", extra: { resolvedPath: "/usr/bin/echo" } },
|
||||||
cwd: "/tmp",
|
{ label: "undefined", extra: { resolvedPath: undefined } },
|
||||||
nodeId: "node-1",
|
{ label: "null", extra: { resolvedPath: null } },
|
||||||
host: "node",
|
])("accepts request with resolvedPath $label", ({ extra }) => {
|
||||||
resolvedPath: "/usr/bin/echo",
|
const params = { ...baseParams, ...extra };
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with resolvedPath as undefined", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
nodeId: "node-1",
|
|
||||||
host: "node",
|
|
||||||
resolvedPath: undefined,
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with resolvedPath as null", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
nodeId: "node-1",
|
|
||||||
host: "node",
|
|
||||||
resolvedPath: null,
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -618,18 +630,7 @@ describe("exec approval handlers", () => {
|
|||||||
it("forwards turn-source metadata to exec approval forwarding", async () => {
|
it("forwards turn-source metadata to exec approval forwarding", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const manager = new ExecApprovalManager();
|
const { handlers, forwarder, respond, context } = createForwardingExecApprovalFixture();
|
||||||
const forwarder = {
|
|
||||||
handleRequested: vi.fn(async () => false),
|
|
||||||
handleResolved: vi.fn(async () => {}),
|
|
||||||
stop: vi.fn(),
|
|
||||||
};
|
|
||||||
const handlers = createExecApprovalHandlers(manager, { forwarder });
|
|
||||||
const respond = vi.fn();
|
|
||||||
const context = {
|
|
||||||
broadcast: (_event: string, _payload: unknown) => {},
|
|
||||||
hasExecApprovalClients: () => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPromise = requestExecApproval({
|
const requestPromise = requestExecApproval({
|
||||||
handlers,
|
handlers,
|
||||||
@@ -643,9 +644,7 @@ describe("exec approval handlers", () => {
|
|||||||
turnSourceThreadId: "1739201675.123",
|
turnSourceThreadId: "1739201675.123",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
for (let idx = 0; idx < 20; idx += 1) {
|
await drainApprovalRequestTicks();
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||||
expect(forwarder.handleRequested).toHaveBeenCalledWith(
|
expect(forwarder.handleRequested).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -668,18 +667,8 @@ describe("exec approval handlers", () => {
|
|||||||
it("expires immediately when no approver clients and no forwarding targets", async () => {
|
it("expires immediately when no approver clients and no forwarding targets", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const manager = new ExecApprovalManager();
|
const { manager, handlers, forwarder, respond, context } =
|
||||||
const forwarder = {
|
createForwardingExecApprovalFixture();
|
||||||
handleRequested: vi.fn(async () => false),
|
|
||||||
handleResolved: vi.fn(async () => {}),
|
|
||||||
stop: vi.fn(),
|
|
||||||
};
|
|
||||||
const handlers = createExecApprovalHandlers(manager, { forwarder });
|
|
||||||
const respond = vi.fn();
|
|
||||||
const context = {
|
|
||||||
broadcast: (_event: string, _payload: unknown) => {},
|
|
||||||
hasExecApprovalClients: () => false,
|
|
||||||
};
|
|
||||||
const expireSpy = vi.spyOn(manager, "expire");
|
const expireSpy = vi.spyOn(manager, "expire");
|
||||||
|
|
||||||
const requestPromise = requestExecApproval({
|
const requestPromise = requestExecApproval({
|
||||||
@@ -688,9 +677,7 @@ describe("exec approval handlers", () => {
|
|||||||
context,
|
context,
|
||||||
params: { timeoutMs: 60_000 },
|
params: { timeoutMs: 60_000 },
|
||||||
});
|
});
|
||||||
for (let idx = 0; idx < 20; idx += 1) {
|
await drainApprovalRequestTicks();
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||||
expect(expireSpy).toHaveBeenCalledTimes(1);
|
expect(expireSpy).toHaveBeenCalledTimes(1);
|
||||||
await vi.runOnlyPendingTimersAsync();
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,28 @@ import {
|
|||||||
shouldEnforceGatewayAuthForPluginPath,
|
shouldEnforceGatewayAuthForPluginPath,
|
||||||
} from "./plugins-http.js";
|
} from "./plugins-http.js";
|
||||||
|
|
||||||
|
type PluginHandlerLog = Parameters<typeof createGatewayPluginRequestHandler>[0]["log"];
|
||||||
|
|
||||||
|
function createPluginLog(): PluginHandlerLog {
|
||||||
|
return { warn: vi.fn() } as unknown as PluginHandlerLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoute(params: {
|
||||||
|
path: string;
|
||||||
|
pluginId?: string;
|
||||||
|
handler?: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
pluginId: params.pluginId ?? "route",
|
||||||
|
path: params.path,
|
||||||
|
handler: params.handler ?? (() => {}),
|
||||||
|
source: params.pluginId ?? "route",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("createGatewayPluginRequestHandler", () => {
|
describe("createGatewayPluginRequestHandler", () => {
|
||||||
it("returns false when no handlers are registered", async () => {
|
it("returns false when no handlers are registered", async () => {
|
||||||
const log = { warn: vi.fn() } as unknown as Parameters<
|
const log = createPluginLog();
|
||||||
typeof createGatewayPluginRequestHandler
|
|
||||||
>[0]["log"];
|
|
||||||
const handler = createGatewayPluginRequestHandler({
|
const handler = createGatewayPluginRequestHandler({
|
||||||
registry: createTestRegistry(),
|
registry: createTestRegistry(),
|
||||||
log,
|
log,
|
||||||
@@ -32,9 +49,7 @@ describe("createGatewayPluginRequestHandler", () => {
|
|||||||
{ pluginId: "second", handler: second, source: "second" },
|
{ pluginId: "second", handler: second, source: "second" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
log: { warn: vi.fn() } as unknown as Parameters<
|
log: createPluginLog(),
|
||||||
typeof createGatewayPluginRequestHandler
|
|
||||||
>[0]["log"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { res } = makeMockHttpResponse();
|
const { res } = makeMockHttpResponse();
|
||||||
@@ -51,19 +66,10 @@ describe("createGatewayPluginRequestHandler", () => {
|
|||||||
const fallback = vi.fn(async () => true);
|
const fallback = vi.fn(async () => true);
|
||||||
const handler = createGatewayPluginRequestHandler({
|
const handler = createGatewayPluginRequestHandler({
|
||||||
registry: createTestRegistry({
|
registry: createTestRegistry({
|
||||||
httpRoutes: [
|
httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })],
|
||||||
{
|
|
||||||
pluginId: "route",
|
|
||||||
path: "/demo",
|
|
||||||
handler: routeHandler,
|
|
||||||
source: "route",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
||||||
}),
|
}),
|
||||||
log: { warn: vi.fn() } as unknown as Parameters<
|
log: createPluginLog(),
|
||||||
typeof createGatewayPluginRequestHandler
|
|
||||||
>[0]["log"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { res } = makeMockHttpResponse();
|
const { res } = makeMockHttpResponse();
|
||||||
@@ -80,19 +86,10 @@ describe("createGatewayPluginRequestHandler", () => {
|
|||||||
const fallback = vi.fn(async () => true);
|
const fallback = vi.fn(async () => true);
|
||||||
const handler = createGatewayPluginRequestHandler({
|
const handler = createGatewayPluginRequestHandler({
|
||||||
registry: createTestRegistry({
|
registry: createTestRegistry({
|
||||||
httpRoutes: [
|
httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })],
|
||||||
{
|
|
||||||
pluginId: "route",
|
|
||||||
path: "/api/demo",
|
|
||||||
handler: routeHandler,
|
|
||||||
source: "route",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
|
||||||
}),
|
}),
|
||||||
log: { warn: vi.fn() } as unknown as Parameters<
|
log: createPluginLog(),
|
||||||
typeof createGatewayPluginRequestHandler
|
|
||||||
>[0]["log"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { res } = makeMockHttpResponse();
|
const { res } = makeMockHttpResponse();
|
||||||
@@ -103,9 +100,7 @@ describe("createGatewayPluginRequestHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs and responds with 500 when a handler throws", async () => {
|
it("logs and responds with 500 when a handler throws", async () => {
|
||||||
const log = { warn: vi.fn() } as unknown as Parameters<
|
const log = createPluginLog();
|
||||||
typeof createGatewayPluginRequestHandler
|
|
||||||
>[0]["log"];
|
|
||||||
const handler = createGatewayPluginRequestHandler({
|
const handler = createGatewayPluginRequestHandler({
|
||||||
registry: createTestRegistry({
|
registry: createTestRegistry({
|
||||||
httpHandlers: [
|
httpHandlers: [
|
||||||
@@ -134,14 +129,7 @@ describe("createGatewayPluginRequestHandler", () => {
|
|||||||
describe("plugin HTTP registry helpers", () => {
|
describe("plugin HTTP registry helpers", () => {
|
||||||
it("detects registered route paths", () => {
|
it("detects registered route paths", () => {
|
||||||
const registry = createTestRegistry({
|
const registry = createTestRegistry({
|
||||||
httpRoutes: [
|
httpRoutes: [createRoute({ path: "/demo" })],
|
||||||
{
|
|
||||||
pluginId: "route",
|
|
||||||
path: "/demo",
|
|
||||||
handler: () => {},
|
|
||||||
source: "route",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true);
|
expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true);
|
||||||
expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false);
|
expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false);
|
||||||
@@ -149,14 +137,7 @@ describe("plugin HTTP registry helpers", () => {
|
|||||||
|
|
||||||
it("matches canonicalized variants of registered route paths", () => {
|
it("matches canonicalized variants of registered route paths", () => {
|
||||||
const registry = createTestRegistry({
|
const registry = createTestRegistry({
|
||||||
httpRoutes: [
|
httpRoutes: [createRoute({ path: "/api/demo" })],
|
||||||
{
|
|
||||||
pluginId: "route",
|
|
||||||
path: "/api/demo",
|
|
||||||
handler: () => {},
|
|
||||||
source: "route",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true);
|
expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true);
|
||||||
expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true);
|
expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true);
|
||||||
@@ -165,14 +146,7 @@ describe("plugin HTTP registry helpers", () => {
|
|||||||
|
|
||||||
it("enforces auth for protected and registered plugin routes", () => {
|
it("enforces auth for protected and registered plugin routes", () => {
|
||||||
const registry = createTestRegistry({
|
const registry = createTestRegistry({
|
||||||
httpRoutes: [
|
httpRoutes: [createRoute({ path: "/api/demo" })],
|
||||||
{
|
|
||||||
pluginId: "route",
|
|
||||||
path: "/api/demo",
|
|
||||||
handler: () => {},
|
|
||||||
source: "route",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
|
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
|
||||||
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);
|
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ function createSingleAgentAvatarConfig(workspace: string): OpenClawConfig {
|
|||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createModelDefaultsConfig(params: {
|
||||||
|
primary: string;
|
||||||
|
models?: Record<string, Record<string, never>>;
|
||||||
|
}): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: params.primary },
|
||||||
|
models: params.models,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLegacyRuntimeListConfig(
|
||||||
|
models?: Record<string, Record<string, never>>,
|
||||||
|
): OpenClawConfig {
|
||||||
|
return createModelDefaultsConfig({
|
||||||
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
|
...(models ? { models } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLegacyRuntimeStore(model: string): Record<string, SessionEntry> {
|
||||||
|
return {
|
||||||
|
"agent:main:main": {
|
||||||
|
sessionId: "sess-main",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
model,
|
||||||
|
} as SessionEntry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway session utils", () => {
|
describe("gateway session utils", () => {
|
||||||
test("capArrayByJsonBytes trims from the front", () => {
|
test("capArrayByJsonBytes trims from the front", () => {
|
||||||
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
|
const res = capArrayByJsonBytes(["a", "b", "c"], 10);
|
||||||
@@ -281,13 +314,9 @@ describe("gateway session utils", () => {
|
|||||||
|
|
||||||
describe("resolveSessionModelRef", () => {
|
describe("resolveSessionModelRef", () => {
|
||||||
test("prefers runtime model/provider from session entry", () => {
|
test("prefers runtime model/provider from session entry", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "anthropic/claude-opus-4-6",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "anthropic/claude-opus-4-6" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelRef(cfg, {
|
const resolved = resolveSessionModelRef(cfg, {
|
||||||
sessionId: "s1",
|
sessionId: "s1",
|
||||||
@@ -302,13 +331,9 @@ describe("resolveSessionModelRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("preserves openrouter provider when model contains vendor prefix", () => {
|
test("preserves openrouter provider when model contains vendor prefix", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "openrouter/minimax/minimax-m2.5",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "openrouter/minimax/minimax-m2.5" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelRef(cfg, {
|
const resolved = resolveSessionModelRef(cfg, {
|
||||||
sessionId: "s-or",
|
sessionId: "s-or",
|
||||||
@@ -324,13 +349,9 @@ describe("resolveSessionModelRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to override when runtime model is not recorded yet", () => {
|
test("falls back to override when runtime model is not recorded yet", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "anthropic/claude-opus-4-6",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "anthropic/claude-opus-4-6" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelRef(cfg, {
|
const resolved = resolveSessionModelRef(cfg, {
|
||||||
sessionId: "s2",
|
sessionId: "s2",
|
||||||
@@ -342,13 +363,9 @@ describe("resolveSessionModelRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to resolved provider for unprefixed legacy runtime model", () => {
|
test("falls back to resolved provider for unprefixed legacy runtime model", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelRef(cfg, {
|
const resolved = resolveSessionModelRef(cfg, {
|
||||||
sessionId: "legacy-session",
|
sessionId: "legacy-session",
|
||||||
@@ -366,13 +383,9 @@ describe("resolveSessionModelRef", () => {
|
|||||||
test("preserves provider from slash-prefixed model when modelProvider is missing", () => {
|
test("preserves provider from slash-prefixed model when modelProvider is missing", () => {
|
||||||
// When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6")
|
// When model string contains a provider prefix (e.g. "anthropic/claude-sonnet-4-6")
|
||||||
// parseModelRef should extract it correctly even without modelProvider set.
|
// parseModelRef should extract it correctly even without modelProvider set.
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelRef(cfg, {
|
const resolved = resolveSessionModelRef(cfg, {
|
||||||
sessionId: "slash-model",
|
sessionId: "slash-model",
|
||||||
@@ -387,13 +400,9 @@ describe("resolveSessionModelRef", () => {
|
|||||||
|
|
||||||
describe("resolveSessionModelIdentityRef", () => {
|
describe("resolveSessionModelIdentityRef", () => {
|
||||||
test("does not inherit default provider for unprefixed legacy runtime model", () => {
|
test("does not inherit default provider for unprefixed legacy runtime model", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||||
sessionId: "legacy-session",
|
sessionId: "legacy-session",
|
||||||
@@ -406,16 +415,12 @@ describe("resolveSessionModelIdentityRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("infers provider from configured model allowlist when unambiguous", () => {
|
test("infers provider from configured model allowlist when unambiguous", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
models: {
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
"anthropic/claude-sonnet-4-6": {},
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
});
|
||||||
|
|
||||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||||
sessionId: "legacy-session",
|
sessionId: "legacy-session",
|
||||||
@@ -428,17 +433,13 @@ describe("resolveSessionModelIdentityRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps provider unknown when configured models are ambiguous", () => {
|
test("keeps provider unknown when configured models are ambiguous", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
models: {
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
"anthropic/claude-sonnet-4-6": {},
|
||||||
models: {
|
"minimax/claude-sonnet-4-6": {},
|
||||||
"anthropic/claude-sonnet-4-6": {},
|
|
||||||
"minimax/claude-sonnet-4-6": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
});
|
||||||
|
|
||||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||||
sessionId: "legacy-session",
|
sessionId: "legacy-session",
|
||||||
@@ -451,13 +452,9 @@ describe("resolveSessionModelIdentityRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("preserves provider from slash-prefixed runtime model", () => {
|
test("preserves provider from slash-prefixed runtime model", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
});
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||||
sessionId: "slash-model",
|
sessionId: "slash-model",
|
||||||
@@ -470,16 +467,12 @@ describe("resolveSessionModelIdentityRef", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => {
|
test("infers wrapper provider for slash-prefixed runtime model when allowlist match is unique", () => {
|
||||||
const cfg = {
|
const cfg = createModelDefaultsConfig({
|
||||||
agents: {
|
primary: "google-gemini-cli/gemini-3-pro-preview",
|
||||||
defaults: {
|
models: {
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
||||||
models: {
|
|
||||||
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
});
|
||||||
|
|
||||||
const resolved = resolveSessionModelIdentityRef(cfg, {
|
const resolved = resolveSessionModelIdentityRef(cfg, {
|
||||||
sessionId: "slash-model",
|
sessionId: "slash-model",
|
||||||
@@ -683,97 +676,37 @@ describe("listSessionsFromStore search", () => {
|
|||||||
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
|
expect(result.sessions.map((session) => session.key)).toEqual(["agent:main:cron:job-1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not guess provider for legacy runtime model without modelProvider", () => {
|
test.each([
|
||||||
const cfg = {
|
{
|
||||||
session: { mainKey: "main" },
|
name: "does not guess provider for legacy runtime model without modelProvider",
|
||||||
agents: {
|
cfg: createLegacyRuntimeListConfig(),
|
||||||
defaults: {
|
runtimeModel: "claude-sonnet-4-6",
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
expectedProvider: undefined,
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
} as OpenClawConfig;
|
name: "infers provider for legacy runtime model when allowlist match is unique",
|
||||||
const now = Date.now();
|
cfg: createLegacyRuntimeListConfig({ "anthropic/claude-sonnet-4-6": {} }),
|
||||||
const store: Record<string, SessionEntry> = {
|
runtimeModel: "claude-sonnet-4-6",
|
||||||
"agent:main:main": {
|
expectedProvider: "anthropic",
|
||||||
sessionId: "sess-main",
|
},
|
||||||
updatedAt: now,
|
{
|
||||||
model: "claude-sonnet-4-6",
|
name: "infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique",
|
||||||
} as SessionEntry,
|
cfg: createLegacyRuntimeListConfig({
|
||||||
};
|
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
||||||
|
}),
|
||||||
|
runtimeModel: "anthropic/claude-sonnet-4-6",
|
||||||
|
expectedProvider: "vercel-ai-gateway",
|
||||||
|
},
|
||||||
|
])("$name", ({ cfg, runtimeModel, expectedProvider }) => {
|
||||||
const result = listSessionsFromStore({
|
const result = listSessionsFromStore({
|
||||||
cfg,
|
cfg,
|
||||||
storePath: "/tmp/sessions.json",
|
storePath: "/tmp/sessions.json",
|
||||||
store,
|
store: createLegacyRuntimeStore(runtimeModel),
|
||||||
opts: {},
|
opts: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.sessions[0]?.modelProvider).toBeUndefined();
|
expect(result.sessions[0]?.modelProvider).toBe(expectedProvider);
|
||||||
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6");
|
expect(result.sessions[0]?.model).toBe(runtimeModel);
|
||||||
});
|
|
||||||
|
|
||||||
test("infers provider for legacy runtime model when allowlist match is unique", () => {
|
|
||||||
const cfg = {
|
|
||||||
session: { mainKey: "main" },
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const now = Date.now();
|
|
||||||
const store: Record<string, SessionEntry> = {
|
|
||||||
"agent:main:main": {
|
|
||||||
sessionId: "sess-main",
|
|
||||||
updatedAt: now,
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
} as SessionEntry,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = listSessionsFromStore({
|
|
||||||
cfg,
|
|
||||||
storePath: "/tmp/sessions.json",
|
|
||||||
store,
|
|
||||||
opts: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sessions[0]?.modelProvider).toBe("anthropic");
|
|
||||||
expect(result.sessions[0]?.model).toBe("claude-sonnet-4-6");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("infers wrapper provider for slash-prefixed legacy runtime model when allowlist match is unique", () => {
|
|
||||||
const cfg = {
|
|
||||||
session: { mainKey: "main" },
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: { primary: "google-gemini-cli/gemini-3-pro-preview" },
|
|
||||||
models: {
|
|
||||||
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
const now = Date.now();
|
|
||||||
const store: Record<string, SessionEntry> = {
|
|
||||||
"agent:main:main": {
|
|
||||||
sessionId: "sess-main",
|
|
||||||
updatedAt: now,
|
|
||||||
model: "anthropic/claude-sonnet-4-6",
|
|
||||||
} as SessionEntry,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = listSessionsFromStore({
|
|
||||||
cfg,
|
|
||||||
storePath: "/tmp/sessions.json",
|
|
||||||
store,
|
|
||||||
opts: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.sessions[0]?.modelProvider).toBe("vercel-ai-gateway");
|
|
||||||
expect(result.sessions[0]?.model).toBe("anthropic/claude-sonnet-4-6");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exposes unknown totals when freshness is stale or missing", () => {
|
test("exposes unknown totals when freshness is stale or missing", () => {
|
||||||
|
|||||||
@@ -5,26 +5,63 @@ import { applySessionsPatchToStore } from "./sessions-patch.js";
|
|||||||
|
|
||||||
const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5";
|
const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5";
|
||||||
const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child";
|
const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child";
|
||||||
|
const MAIN_SESSION_KEY = "agent:main:main";
|
||||||
|
const EMPTY_CFG = {} as OpenClawConfig;
|
||||||
|
|
||||||
|
type ApplySessionsPatchArgs = Parameters<typeof applySessionsPatchToStore>[0];
|
||||||
|
|
||||||
|
async function runPatch(params: {
|
||||||
|
patch: ApplySessionsPatchArgs["patch"];
|
||||||
|
store?: Record<string, SessionEntry>;
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
storeKey?: string;
|
||||||
|
loadGatewayModelCatalog?: ApplySessionsPatchArgs["loadGatewayModelCatalog"];
|
||||||
|
}) {
|
||||||
|
return applySessionsPatchToStore({
|
||||||
|
cfg: params.cfg ?? EMPTY_CFG,
|
||||||
|
store: params.store ?? {},
|
||||||
|
storeKey: params.storeKey ?? MAIN_SESSION_KEY,
|
||||||
|
patch: params.patch,
|
||||||
|
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPatchOk(
|
||||||
|
result: Awaited<ReturnType<typeof applySessionsPatchToStore>>,
|
||||||
|
): SessionEntry {
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(result.error.message);
|
||||||
|
}
|
||||||
|
return result.entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPatchError(
|
||||||
|
result: Awaited<ReturnType<typeof applySessionsPatchToStore>>,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) {
|
||||||
|
throw new Error(`Expected patch failure containing: ${message}`);
|
||||||
|
}
|
||||||
|
expect(result.error.message).toContain(message);
|
||||||
|
}
|
||||||
|
|
||||||
async function applySubagentModelPatch(cfg: OpenClawConfig) {
|
async function applySubagentModelPatch(cfg: OpenClawConfig) {
|
||||||
const res = await applySessionsPatchToStore({
|
return expectPatchOk(
|
||||||
cfg,
|
await runPatch({
|
||||||
store: {},
|
cfg,
|
||||||
storeKey: KIMI_SUBAGENT_KEY,
|
storeKey: KIMI_SUBAGENT_KEY,
|
||||||
patch: {
|
patch: {
|
||||||
key: KIMI_SUBAGENT_KEY,
|
key: KIMI_SUBAGENT_KEY,
|
||||||
model: SUBAGENT_MODEL,
|
model: SUBAGENT_MODEL,
|
||||||
},
|
},
|
||||||
loadGatewayModelCatalog: async () => [
|
loadGatewayModelCatalog: async () => [
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
|
||||||
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
|
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
|
||||||
],
|
],
|
||||||
});
|
}),
|
||||||
expect(res.ok).toBe(true);
|
);
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(res.error.message);
|
|
||||||
}
|
|
||||||
return res.entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeKimiSubagentCfg(params: {
|
function makeKimiSubagentCfg(params: {
|
||||||
@@ -54,131 +91,100 @@ function makeKimiSubagentCfg(params: {
|
|||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAllowlistedAnthropicModelCfg(): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openai/gpt-5.2" },
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway sessions patch", () => {
|
describe("gateway sessions patch", () => {
|
||||||
test("persists thinkingLevel=off (does not clear)", async () => {
|
test("persists thinkingLevel=off (does not clear)", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const entry = expectPatchOk(
|
||||||
const res = await applySessionsPatchToStore({
|
await runPatch({
|
||||||
cfg: {} as OpenClawConfig,
|
patch: { key: MAIN_SESSION_KEY, thinkingLevel: "off" },
|
||||||
store,
|
}),
|
||||||
storeKey: "agent:main:main",
|
);
|
||||||
patch: { key: "agent:main:main", thinkingLevel: "off" },
|
expect(entry.thinkingLevel).toBe("off");
|
||||||
});
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.thinkingLevel).toBe("off");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears thinkingLevel when patch sets null", async () => {
|
test("clears thinkingLevel when patch sets null", async () => {
|
||||||
const store: Record<string, SessionEntry> = {
|
const store: Record<string, SessionEntry> = {
|
||||||
"agent:main:main": { thinkingLevel: "low" } as SessionEntry,
|
[MAIN_SESSION_KEY]: { thinkingLevel: "low" } as SessionEntry,
|
||||||
};
|
};
|
||||||
const res = await applySessionsPatchToStore({
|
const entry = expectPatchOk(
|
||||||
cfg: {} as OpenClawConfig,
|
await runPatch({
|
||||||
store,
|
store,
|
||||||
storeKey: "agent:main:main",
|
patch: { key: MAIN_SESSION_KEY, thinkingLevel: null },
|
||||||
patch: { key: "agent:main:main", thinkingLevel: null },
|
}),
|
||||||
});
|
);
|
||||||
expect(res.ok).toBe(true);
|
expect(entry.thinkingLevel).toBeUndefined();
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.thinkingLevel).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists reasoningLevel=off (does not clear)", async () => {
|
test("persists reasoningLevel=off (does not clear)", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const entry = expectPatchOk(
|
||||||
const res = await applySessionsPatchToStore({
|
await runPatch({
|
||||||
cfg: {} as OpenClawConfig,
|
patch: { key: MAIN_SESSION_KEY, reasoningLevel: "off" },
|
||||||
store,
|
}),
|
||||||
storeKey: "agent:main:main",
|
);
|
||||||
patch: { key: "agent:main:main", reasoningLevel: "off" },
|
expect(entry.reasoningLevel).toBe("off");
|
||||||
});
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.reasoningLevel).toBe("off");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears reasoningLevel when patch sets null", async () => {
|
test("clears reasoningLevel when patch sets null", async () => {
|
||||||
const store: Record<string, SessionEntry> = {
|
const store: Record<string, SessionEntry> = {
|
||||||
"agent:main:main": { reasoningLevel: "stream" } as SessionEntry,
|
[MAIN_SESSION_KEY]: { reasoningLevel: "stream" } as SessionEntry,
|
||||||
};
|
};
|
||||||
const res = await applySessionsPatchToStore({
|
const entry = expectPatchOk(
|
||||||
cfg: {} as OpenClawConfig,
|
await runPatch({
|
||||||
store,
|
store,
|
||||||
storeKey: "agent:main:main",
|
patch: { key: MAIN_SESSION_KEY, reasoningLevel: null },
|
||||||
patch: { key: "agent:main:main", reasoningLevel: null },
|
}),
|
||||||
});
|
);
|
||||||
expect(res.ok).toBe(true);
|
expect(entry.reasoningLevel).toBeUndefined();
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.reasoningLevel).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists elevatedLevel=off (does not clear)", async () => {
|
test("persists elevatedLevel=off (does not clear)", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const entry = expectPatchOk(
|
||||||
const res = await applySessionsPatchToStore({
|
await runPatch({
|
||||||
cfg: {} as OpenClawConfig,
|
patch: { key: MAIN_SESSION_KEY, elevatedLevel: "off" },
|
||||||
store,
|
}),
|
||||||
storeKey: "agent:main:main",
|
);
|
||||||
patch: { key: "agent:main:main", elevatedLevel: "off" },
|
expect(entry.elevatedLevel).toBe("off");
|
||||||
});
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.elevatedLevel).toBe("off");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists elevatedLevel=on", async () => {
|
test("persists elevatedLevel=on", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const entry = expectPatchOk(
|
||||||
const res = await applySessionsPatchToStore({
|
await runPatch({
|
||||||
cfg: {} as OpenClawConfig,
|
patch: { key: MAIN_SESSION_KEY, elevatedLevel: "on" },
|
||||||
store,
|
}),
|
||||||
storeKey: "agent:main:main",
|
);
|
||||||
patch: { key: "agent:main:main", elevatedLevel: "on" },
|
expect(entry.elevatedLevel).toBe("on");
|
||||||
});
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.elevatedLevel).toBe("on");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears elevatedLevel when patch sets null", async () => {
|
test("clears elevatedLevel when patch sets null", async () => {
|
||||||
const store: Record<string, SessionEntry> = {
|
const store: Record<string, SessionEntry> = {
|
||||||
"agent:main:main": { elevatedLevel: "off" } as SessionEntry,
|
[MAIN_SESSION_KEY]: { elevatedLevel: "off" } as SessionEntry,
|
||||||
};
|
};
|
||||||
const res = await applySessionsPatchToStore({
|
const entry = expectPatchOk(
|
||||||
cfg: {} as OpenClawConfig,
|
await runPatch({
|
||||||
store,
|
store,
|
||||||
storeKey: "agent:main:main",
|
patch: { key: MAIN_SESSION_KEY, elevatedLevel: null },
|
||||||
patch: { key: "agent:main:main", elevatedLevel: null },
|
}),
|
||||||
});
|
);
|
||||||
expect(res.ok).toBe(true);
|
expect(entry.elevatedLevel).toBeUndefined();
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.elevatedLevel).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid elevatedLevel values", async () => {
|
test("rejects invalid elevatedLevel values", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const result = await runPatch({
|
||||||
const res = await applySessionsPatchToStore({
|
patch: { key: MAIN_SESSION_KEY, elevatedLevel: "maybe" },
|
||||||
cfg: {} as OpenClawConfig,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", elevatedLevel: "maybe" },
|
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expectPatchError(result, "invalid elevatedLevel");
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.error.message).toContain("invalid elevatedLevel");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears auth overrides when model patch changes", async () => {
|
test("clears auth overrides when model patch changes", async () => {
|
||||||
@@ -193,189 +199,107 @@ describe("gateway sessions patch", () => {
|
|||||||
authProfileOverrideCompactionCount: 3,
|
authProfileOverrideCompactionCount: 3,
|
||||||
} as SessionEntry,
|
} as SessionEntry,
|
||||||
};
|
};
|
||||||
const res = await applySessionsPatchToStore({
|
const entry = expectPatchOk(
|
||||||
cfg: {} as OpenClawConfig,
|
await runPatch({
|
||||||
store,
|
store,
|
||||||
storeKey: "agent:main:main",
|
patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.2" },
|
||||||
patch: { key: "agent:main:main", model: "openai/gpt-5.2" },
|
loadGatewayModelCatalog: async () => [
|
||||||
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" }],
|
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
|
||||||
});
|
],
|
||||||
expect(res.ok).toBe(true);
|
}),
|
||||||
if (!res.ok) {
|
);
|
||||||
return;
|
expect(entry.providerOverride).toBe("openai");
|
||||||
}
|
expect(entry.modelOverride).toBe("gpt-5.2");
|
||||||
expect(res.entry.providerOverride).toBe("openai");
|
expect(entry.authProfileOverride).toBeUndefined();
|
||||||
expect(res.entry.modelOverride).toBe("gpt-5.2");
|
expect(entry.authProfileOverrideSource).toBeUndefined();
|
||||||
expect(res.entry.authProfileOverride).toBeUndefined();
|
expect(entry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||||
expect(res.entry.authProfileOverrideSource).toBeUndefined();
|
|
||||||
expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts explicit allowlisted provider/model refs from sessions.patch", async () => {
|
test.each([
|
||||||
const store: Record<string, SessionEntry> = {};
|
{
|
||||||
const cfg = {
|
name: "accepts explicit allowlisted provider/model refs from sessions.patch",
|
||||||
agents: {
|
catalog: [
|
||||||
defaults: {
|
|
||||||
model: { primary: "openai/gpt-5.2" },
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const res = await applySessionsPatchToStore({
|
|
||||||
cfg,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" },
|
|
||||||
loadGatewayModelCatalog: async () => [
|
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
|
{
|
||||||
expect(res.ok).toBe(true);
|
name: "accepts explicit allowlisted refs absent from bundled catalog",
|
||||||
if (!res.ok) {
|
catalog: [
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.providerOverride).toBe("anthropic");
|
|
||||||
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts explicit allowlisted refs absent from bundled catalog", async () => {
|
|
||||||
const store: Record<string, SessionEntry> = {};
|
|
||||||
const cfg = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: { primary: "openai/gpt-5.2" },
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const res = await applySessionsPatchToStore({
|
|
||||||
cfg,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", model: "anthropic/claude-sonnet-4-6" },
|
|
||||||
loadGatewayModelCatalog: async () => [
|
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||||
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
|
])("$name", async ({ catalog }) => {
|
||||||
expect(res.ok).toBe(true);
|
const entry = expectPatchOk(
|
||||||
if (!res.ok) {
|
await runPatch({
|
||||||
return;
|
cfg: createAllowlistedAnthropicModelCfg(),
|
||||||
}
|
patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" },
|
||||||
expect(res.entry.providerOverride).toBe("anthropic");
|
loadGatewayModelCatalog: async () => catalog,
|
||||||
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6");
|
}),
|
||||||
|
);
|
||||||
|
expect(entry.providerOverride).toBe("anthropic");
|
||||||
|
expect(entry.modelOverride).toBe("claude-sonnet-4-6");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sets spawnDepth for subagent sessions", async () => {
|
test("sets spawnDepth for subagent sessions", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const entry = expectPatchOk(
|
||||||
const res = await applySessionsPatchToStore({
|
await runPatch({
|
||||||
cfg: {} as OpenClawConfig,
|
storeKey: "agent:main:subagent:child",
|
||||||
store,
|
patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
|
||||||
storeKey: "agent:main:subagent:child",
|
}),
|
||||||
patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
|
);
|
||||||
});
|
expect(entry.spawnDepth).toBe(2);
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
if (!res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.entry.spawnDepth).toBe(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects spawnDepth on non-subagent sessions", async () => {
|
test("rejects spawnDepth on non-subagent sessions", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const result = await runPatch({
|
||||||
const res = await applySessionsPatchToStore({
|
patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
|
||||||
cfg: {} as OpenClawConfig,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", spawnDepth: 1 },
|
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expectPatchError(result, "spawnDepth is only supported");
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.error.message).toContain("spawnDepth is only supported");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes exec/send/group patches", async () => {
|
test("normalizes exec/send/group patches", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const entry = expectPatchOk(
|
||||||
const res = await applySessionsPatchToStore({
|
await runPatch({
|
||||||
cfg: {} as OpenClawConfig,
|
patch: {
|
||||||
store,
|
key: MAIN_SESSION_KEY,
|
||||||
storeKey: "agent:main:main",
|
execHost: " NODE ",
|
||||||
patch: {
|
execSecurity: " ALLOWLIST ",
|
||||||
key: "agent:main:main",
|
execAsk: " ON-MISS ",
|
||||||
execHost: " NODE ",
|
execNode: " worker-1 ",
|
||||||
execSecurity: " ALLOWLIST ",
|
sendPolicy: "DENY" as unknown as "allow",
|
||||||
execAsk: " ON-MISS ",
|
groupActivation: "Always" as unknown as "mention",
|
||||||
execNode: " worker-1 ",
|
},
|
||||||
sendPolicy: "DENY" as unknown as "allow",
|
}),
|
||||||
groupActivation: "Always" as unknown as "mention",
|
);
|
||||||
},
|
expect(entry.execHost).toBe("node");
|
||||||
});
|
expect(entry.execSecurity).toBe("allowlist");
|
||||||
expect(res.ok).toBe(true);
|
expect(entry.execAsk).toBe("on-miss");
|
||||||
if (!res.ok) {
|
expect(entry.execNode).toBe("worker-1");
|
||||||
return;
|
expect(entry.sendPolicy).toBe("deny");
|
||||||
}
|
expect(entry.groupActivation).toBe("always");
|
||||||
expect(res.entry.execHost).toBe("node");
|
|
||||||
expect(res.entry.execSecurity).toBe("allowlist");
|
|
||||||
expect(res.entry.execAsk).toBe("on-miss");
|
|
||||||
expect(res.entry.execNode).toBe("worker-1");
|
|
||||||
expect(res.entry.sendPolicy).toBe("deny");
|
|
||||||
expect(res.entry.groupActivation).toBe("always");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid execHost values", async () => {
|
test("rejects invalid execHost values", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const result = await runPatch({
|
||||||
const res = await applySessionsPatchToStore({
|
patch: { key: MAIN_SESSION_KEY, execHost: "edge" },
|
||||||
cfg: {} as OpenClawConfig,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", execHost: "edge" },
|
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expectPatchError(result, "invalid execHost");
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.error.message).toContain("invalid execHost");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid sendPolicy values", async () => {
|
test("rejects invalid sendPolicy values", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const result = await runPatch({
|
||||||
const res = await applySessionsPatchToStore({
|
patch: { key: MAIN_SESSION_KEY, sendPolicy: "ask" as unknown as "allow" },
|
||||||
cfg: {} as OpenClawConfig,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", sendPolicy: "ask" as unknown as "allow" },
|
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expectPatchError(result, "invalid sendPolicy");
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.error.message).toContain("invalid sendPolicy");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid groupActivation values", async () => {
|
test("rejects invalid groupActivation values", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const result = await runPatch({
|
||||||
const res = await applySessionsPatchToStore({
|
patch: { key: MAIN_SESSION_KEY, groupActivation: "never" as unknown as "mention" },
|
||||||
cfg: {} as OpenClawConfig,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:main:main",
|
|
||||||
patch: { key: "agent:main:main", groupActivation: "never" as unknown as "mention" },
|
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expectPatchError(result, "invalid groupActivation");
|
||||||
if (res.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(res.error.message).toContain("invalid groupActivation");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows target agent own model for subagent session even when missing from global allowlist", async () => {
|
test("allows target agent own model for subagent session even when missing from global allowlist", async () => {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
|||||||
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
|
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
|
||||||
|
|
||||||
let cfg: Record<string, unknown> = {};
|
let cfg: Record<string, unknown> = {};
|
||||||
|
const alwaysAuthorized = async () => ({ ok: true as const });
|
||||||
|
const disableDefaultMemorySlot = () => false;
|
||||||
|
const noPluginToolMeta = () => undefined;
|
||||||
|
const noWarnLog = () => {};
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
loadConfig: () => cfg,
|
loadConfig: () => cfg,
|
||||||
@@ -15,19 +19,19 @@ vi.mock("../config/sessions.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./auth.js", () => ({
|
vi.mock("./auth.js", () => ({
|
||||||
authorizeHttpGatewayConnect: async () => ({ ok: true }),
|
authorizeHttpGatewayConnect: alwaysAuthorized,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../logger.js", () => ({
|
vi.mock("../logger.js", () => ({
|
||||||
logWarn: () => {},
|
logWarn: noWarnLog,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/config-state.js", () => ({
|
vi.mock("../plugins/config-state.js", () => ({
|
||||||
isTestDefaultMemorySlotDisabled: () => false,
|
isTestDefaultMemorySlotDisabled: disableDefaultMemorySlot,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../plugins/tools.js", () => ({
|
vi.mock("../plugins/tools.js", () => ({
|
||||||
getPluginToolMeta: () => undefined,
|
getPluginToolMeta: noPluginToolMeta,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../agents/openclaw-tools.js", () => {
|
vi.mock("../agents/openclaw-tools.js", () => {
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ function buildNestedEnvShellCommand(params: {
|
|||||||
return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload];
|
return [...Array(params.depth).fill(params.envExecutable), "/bin/sh", "-c", params.payload];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; cwd: string }) {
|
||||||
|
const analysis = analyzeArgvCommand({
|
||||||
|
argv: params.argv,
|
||||||
|
cwd: params.cwd,
|
||||||
|
env: makePathEnv(params.envPath),
|
||||||
|
});
|
||||||
|
const allowlistEval = evaluateExecAllowlist({
|
||||||
|
analysis,
|
||||||
|
allowlist: [{ pattern: params.envPath }],
|
||||||
|
safeBins: normalizeSafeBins([]),
|
||||||
|
cwd: params.cwd,
|
||||||
|
});
|
||||||
|
return { analysis, allowlistEval };
|
||||||
|
}
|
||||||
|
|
||||||
describe("exec approvals allowlist matching", () => {
|
describe("exec approvals allowlist matching", () => {
|
||||||
const baseResolution = {
|
const baseResolution = {
|
||||||
rawExecutable: "rg",
|
rawExecutable: "rg",
|
||||||
@@ -288,16 +303,9 @@ describe("exec approvals command resolution", () => {
|
|||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
fs.chmodSync(envPath, 0o755);
|
fs.chmodSync(envPath, 0o755);
|
||||||
}
|
}
|
||||||
|
const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({
|
||||||
const analysis = analyzeArgvCommand({
|
|
||||||
argv: [envPath, "-S", 'sh -c "echo pwned"'],
|
argv: [envPath, "-S", 'sh -c "echo pwned"'],
|
||||||
cwd: dir,
|
envPath: envPath,
|
||||||
env: makePathEnv(binDir),
|
|
||||||
});
|
|
||||||
const allowlistEval = evaluateExecAllowlist({
|
|
||||||
analysis,
|
|
||||||
allowlist: [{ pattern: envPath }],
|
|
||||||
safeBins: normalizeSafeBins([]),
|
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,20 +325,13 @@ describe("exec approvals command resolution", () => {
|
|||||||
const envPath = path.join(binDir, "env");
|
const envPath = path.join(binDir, "env");
|
||||||
fs.writeFileSync(envPath, "#!/bin/sh\n");
|
fs.writeFileSync(envPath, "#!/bin/sh\n");
|
||||||
fs.chmodSync(envPath, 0o755);
|
fs.chmodSync(envPath, 0o755);
|
||||||
|
const { analysis, allowlistEval } = analyzeEnvWrapperAllowlist({
|
||||||
const analysis = analyzeArgvCommand({
|
|
||||||
argv: buildNestedEnvShellCommand({
|
argv: buildNestedEnvShellCommand({
|
||||||
envExecutable: envPath,
|
envExecutable: envPath,
|
||||||
depth: 5,
|
depth: 5,
|
||||||
payload: "echo pwned",
|
payload: "echo pwned",
|
||||||
}),
|
}),
|
||||||
cwd: dir,
|
envPath,
|
||||||
env: makePathEnv(binDir),
|
|
||||||
});
|
|
||||||
const allowlistEval = evaluateExecAllowlist({
|
|
||||||
analysis,
|
|
||||||
allowlist: [{ pattern: envPath }],
|
|
||||||
safeBins: normalizeSafeBins([]),
|
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,28 @@ const mocks = vi.hoisted(() => ({
|
|||||||
loadOpenClawPlugins: vi.fn(),
|
loadOpenClawPlugins: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const TEST_WORKSPACE_ROOT = "/tmp/openclaw-test-workspace";
|
||||||
|
|
||||||
|
function normalizeChannel(value?: string) {
|
||||||
|
return value?.trim().toLowerCase() ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passthroughPluginAutoEnable(config: unknown) {
|
||||||
|
return { config, changes: [] as unknown[] };
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock("../../channels/plugins/index.js", () => ({
|
vi.mock("../../channels/plugins/index.js", () => ({
|
||||||
getChannelPlugin: mocks.getChannelPlugin,
|
getChannelPlugin: mocks.getChannelPlugin,
|
||||||
normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined,
|
normalizeChannelId: normalizeChannel,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
vi.mock("../../agents/agent-scope.js", () => ({
|
||||||
resolveDefaultAgentId: () => "main",
|
resolveDefaultAgentId: () => "main",
|
||||||
resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace",
|
resolveAgentWorkspaceDir: () => TEST_WORKSPACE_ROOT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||||
applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }),
|
applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../plugins/loader.js", () => ({
|
vi.mock("../../plugins/loader.js", () => ({
|
||||||
|
|||||||
@@ -182,6 +182,39 @@ describe("runGatewayUpdate", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createGlobalNpmUpdateRunner(params: {
|
||||||
|
pkgRoot: string;
|
||||||
|
nodeModules: string;
|
||||||
|
onBaseInstall?: () => Promise<CommandResult>;
|
||||||
|
onOmitOptionalInstall?: () => Promise<CommandResult>;
|
||||||
|
}) {
|
||||||
|
const baseInstallKey = "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error";
|
||||||
|
const omitOptionalInstallKey =
|
||||||
|
"npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error";
|
||||||
|
|
||||||
|
return async (argv: string[]): Promise<CommandResult> => {
|
||||||
|
const key = argv.join(" ");
|
||||||
|
if (key === `git -C ${params.pkgRoot} rev-parse --show-toplevel`) {
|
||||||
|
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||||
|
}
|
||||||
|
if (key === "npm root -g") {
|
||||||
|
return { stdout: params.nodeModules, stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
if (key === "pnpm root -g") {
|
||||||
|
return { stdout: "", stderr: "", code: 1 };
|
||||||
|
}
|
||||||
|
if (key === baseInstallKey) {
|
||||||
|
return (await params.onBaseInstall?.()) ?? { stdout: "ok", stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
if (key === omitOptionalInstallKey) {
|
||||||
|
return (
|
||||||
|
(await params.onOmitOptionalInstall?.()) ?? { stdout: "", stderr: "not found", code: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { stdout: "", stderr: "", code: 0 };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
it("skips git update when worktree is dirty", async () => {
|
it("skips git update when worktree is dirty", async () => {
|
||||||
await setupGitCheckout();
|
await setupGitCheckout();
|
||||||
const { runner, calls } = createRunner({
|
const { runner, calls } = createRunner({
|
||||||
@@ -392,23 +425,14 @@ describe("runGatewayUpdate", () => {
|
|||||||
await seedGlobalPackageRoot(pkgRoot);
|
await seedGlobalPackageRoot(pkgRoot);
|
||||||
|
|
||||||
let stalePresentAtInstall = true;
|
let stalePresentAtInstall = true;
|
||||||
const runCommand = async (argv: string[]) => {
|
const runCommand = createGlobalNpmUpdateRunner({
|
||||||
const key = argv.join(" ");
|
nodeModules,
|
||||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
pkgRoot,
|
||||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
onBaseInstall: async () => {
|
||||||
}
|
|
||||||
if (key === "npm root -g") {
|
|
||||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
|
||||||
}
|
|
||||||
if (key === "pnpm root -g") {
|
|
||||||
return { stdout: "", stderr: "", code: 1 };
|
|
||||||
}
|
|
||||||
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
|
|
||||||
stalePresentAtInstall = await pathExists(staleDir);
|
stalePresentAtInstall = await pathExists(staleDir);
|
||||||
return { stdout: "ok", stderr: "", code: 0 };
|
return { stdout: "ok", stderr: "", code: 0 };
|
||||||
}
|
},
|
||||||
return { stdout: "", stderr: "", code: 0 };
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
||||||
|
|
||||||
@@ -423,33 +447,22 @@ describe("runGatewayUpdate", () => {
|
|||||||
await seedGlobalPackageRoot(pkgRoot);
|
await seedGlobalPackageRoot(pkgRoot);
|
||||||
|
|
||||||
let firstAttempt = true;
|
let firstAttempt = true;
|
||||||
const runCommand = async (argv: string[]) => {
|
const runCommand = createGlobalNpmUpdateRunner({
|
||||||
const key = argv.join(" ");
|
nodeModules,
|
||||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
pkgRoot,
|
||||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
onBaseInstall: async () => {
|
||||||
}
|
|
||||||
if (key === "npm root -g") {
|
|
||||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
|
||||||
}
|
|
||||||
if (key === "pnpm root -g") {
|
|
||||||
return { stdout: "", stderr: "", code: 1 };
|
|
||||||
}
|
|
||||||
if (key === "npm i -g openclaw@latest --no-fund --no-audit --loglevel=error") {
|
|
||||||
firstAttempt = false;
|
firstAttempt = false;
|
||||||
return { stdout: "", stderr: "node-gyp failed", code: 1 };
|
return { stdout: "", stderr: "node-gyp failed", code: 1 };
|
||||||
}
|
},
|
||||||
if (
|
onOmitOptionalInstall: async () => {
|
||||||
key === "npm i -g openclaw@latest --omit=optional --no-fund --no-audit --loglevel=error"
|
|
||||||
) {
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(pkgRoot, "package.json"),
|
path.join(pkgRoot, "package.json"),
|
||||||
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
JSON.stringify({ name: "openclaw", version: "2.0.0" }),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
return { stdout: "ok", stderr: "", code: 0 };
|
return { stdout: "ok", stderr: "", code: 0 };
|
||||||
}
|
},
|
||||||
return { stdout: "", stderr: "", code: 0 };
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
const result = await runWithCommand(runCommand, { cwd: pkgRoot });
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,61 @@ describe("formatSystemRunAllowlistMissMessage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleSystemRunInvoke mac app exec host routing", () => {
|
describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||||
|
function createLocalRunResult(stdout = "local-ok") {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stdout,
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectInvokeOk(
|
||||||
|
sendInvokeResult: ReturnType<typeof vi.fn>,
|
||||||
|
params?: { payloadContains?: string },
|
||||||
|
) {
|
||||||
|
expect(sendInvokeResult).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ok: true,
|
||||||
|
...(params?.payloadContains
|
||||||
|
? { payloadJSON: expect.stringContaining(params.payloadContains) }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectInvokeErrorMessage(
|
||||||
|
sendInvokeResult: ReturnType<typeof vi.fn>,
|
||||||
|
params: { message: string; exact?: boolean },
|
||||||
|
) {
|
||||||
|
expect(sendInvokeResult).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ok: false,
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: params.exact ? params.message : expect.stringContaining(params.message),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectApprovalRequiredDenied(params: {
|
||||||
|
sendNodeEvent: ReturnType<typeof vi.fn>;
|
||||||
|
sendInvokeResult: ReturnType<typeof vi.fn>;
|
||||||
|
}) {
|
||||||
|
expect(params.sendNodeEvent).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"exec.denied",
|
||||||
|
expect.objectContaining({ reason: "approval-required" }),
|
||||||
|
);
|
||||||
|
expectInvokeErrorMessage(params.sendInvokeResult, {
|
||||||
|
message: "SYSTEM_RUN_DENIED: approval required",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] {
|
function buildNestedEnvShellCommand(params: { depth: number; payload: string }): string[] {
|
||||||
return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload];
|
return [...Array(params.depth).fill("/usr/bin/env"), "/bin/sh", "-c", params.payload];
|
||||||
}
|
}
|
||||||
@@ -45,6 +100,44 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withPathTokenCommand<T>(params: {
|
||||||
|
tmpPrefix: string;
|
||||||
|
run: (ctx: { link: string; expected: string }) => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix));
|
||||||
|
const binDir = path.join(tmp, "bin");
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
const link = path.join(binDir, "poccmd");
|
||||||
|
fs.symlinkSync("/bin/echo", link);
|
||||||
|
const expected = fs.realpathSync(link);
|
||||||
|
const oldPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
|
||||||
|
try {
|
||||||
|
return await params.run({ link, expected });
|
||||||
|
} finally {
|
||||||
|
if (oldPath === undefined) {
|
||||||
|
delete process.env.PATH;
|
||||||
|
} else {
|
||||||
|
process.env.PATH = oldPath;
|
||||||
|
}
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCommandPinnedToCanonicalPath(params: {
|
||||||
|
runCommand: ReturnType<typeof vi.fn>;
|
||||||
|
expected: string;
|
||||||
|
commandTail: string[];
|
||||||
|
cwd?: string;
|
||||||
|
}) {
|
||||||
|
expect(params.runCommand).toHaveBeenCalledWith(
|
||||||
|
[params.expected, ...params.commandTail],
|
||||||
|
params.cwd,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function runSystemInvoke(params: {
|
async function runSystemInvoke(params: {
|
||||||
preferMacAppExecHost: boolean;
|
preferMacAppExecHost: boolean;
|
||||||
runViaResponse?: ExecHostResponse | null;
|
runViaResponse?: ExecHostResponse | null;
|
||||||
@@ -53,26 +146,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
security?: "full" | "allowlist";
|
security?: "full" | "allowlist";
|
||||||
ask?: "off" | "on-miss" | "always";
|
ask?: "off" | "on-miss" | "always";
|
||||||
approved?: boolean;
|
approved?: boolean;
|
||||||
|
runCommand?: ReturnType<typeof vi.fn>;
|
||||||
|
runViaMacAppExecHost?: ReturnType<typeof vi.fn>;
|
||||||
|
sendInvokeResult?: ReturnType<typeof vi.fn>;
|
||||||
|
sendExecFinishedEvent?: ReturnType<typeof vi.fn>;
|
||||||
|
sendNodeEvent?: ReturnType<typeof vi.fn>;
|
||||||
|
skillBinsCurrent?: () => Promise<Array<{ name: string; resolvedPath: string }>>;
|
||||||
}) {
|
}) {
|
||||||
const runCommand = vi.fn(
|
const runCommand =
|
||||||
async (
|
params.runCommand ??
|
||||||
_command: string[],
|
vi.fn(async (_command: string[], _cwd?: string, _env?: Record<string, string>) =>
|
||||||
_cwd?: string,
|
createLocalRunResult(),
|
||||||
_env?: Record<string, string>,
|
);
|
||||||
_timeoutMs?: number,
|
const runViaMacAppExecHost =
|
||||||
) => ({
|
params.runViaMacAppExecHost ?? vi.fn(async () => params.runViaResponse ?? null);
|
||||||
success: true,
|
const sendInvokeResult = params.sendInvokeResult ?? vi.fn(async () => {});
|
||||||
stdout: "local-ok",
|
const sendExecFinishedEvent = params.sendExecFinishedEvent ?? vi.fn(async () => {});
|
||||||
stderr: "",
|
const sendNodeEvent = params.sendNodeEvent ?? vi.fn(async () => {});
|
||||||
timedOut: false,
|
|
||||||
truncated: false,
|
|
||||||
exitCode: 0,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const runViaMacAppExecHost = vi.fn(async () => params.runViaResponse ?? null);
|
|
||||||
const sendInvokeResult = vi.fn(async () => {});
|
|
||||||
const sendExecFinishedEvent = vi.fn(async () => {});
|
|
||||||
|
|
||||||
await handleSystemRunInvoke({
|
await handleSystemRunInvoke({
|
||||||
client: {} as never,
|
client: {} as never,
|
||||||
@@ -83,7 +173,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
sessionKey: "agent:main:main",
|
sessionKey: "agent:main:main",
|
||||||
},
|
},
|
||||||
skillBins: {
|
skillBins: {
|
||||||
current: async () => [],
|
current: params.skillBinsCurrent ?? (async () => []),
|
||||||
},
|
},
|
||||||
execHostEnforced: false,
|
execHostEnforced: false,
|
||||||
execHostFallbackAllowed: true,
|
execHostFallbackAllowed: true,
|
||||||
@@ -93,7 +183,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
sanitizeEnv: () => undefined,
|
sanitizeEnv: () => undefined,
|
||||||
runCommand,
|
runCommand,
|
||||||
runViaMacAppExecHost,
|
runViaMacAppExecHost,
|
||||||
sendNodeEvent: async () => {},
|
sendNodeEvent,
|
||||||
buildExecEventPayload: (payload) => payload,
|
buildExecEventPayload: (payload) => payload,
|
||||||
sendInvokeResult,
|
sendInvokeResult,
|
||||||
sendExecFinishedEvent,
|
sendExecFinishedEvent,
|
||||||
@@ -110,12 +200,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
|
|
||||||
expect(runViaMacAppExecHost).not.toHaveBeenCalled();
|
expect(runViaMacAppExecHost).not.toHaveBeenCalled();
|
||||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeOk(sendInvokeResult, { payloadContains: "local-ok" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
payloadJSON: expect.stringContaining("local-ok"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses mac app exec host when explicitly preferred", async () => {
|
it("uses mac app exec host when explicitly preferred", async () => {
|
||||||
@@ -146,12 +231,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeOk(sendInvokeResult, { payloadContains: "app-ok" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
payloadJSON: expect.stringContaining("app-ok"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => {
|
it("forwards canonical cmdText to mac app exec host for positional-argv shell wrappers", async () => {
|
||||||
@@ -188,14 +268,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
});
|
});
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: expect.stringContaining("allowlist miss"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +276,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
expect(runArgs).toBeDefined();
|
expect(runArgs).toBeDefined();
|
||||||
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
|
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
|
||||||
expect(runArgs?.slice(1)).toEqual(["a", "b"]);
|
expect(runArgs?.slice(1)).toEqual(["a", "b"]);
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeOk(sendInvokeResult);
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies semantic env wrappers in allowlist mode", async () => {
|
it("denies semantic env wrappers in allowlist mode", async () => {
|
||||||
@@ -217,139 +286,76 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
command: ["env", "FOO=bar", "tr", "a", "b"],
|
command: ["env", "FOO=bar", "tr", "a", "b"],
|
||||||
});
|
});
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: expect.stringContaining("allowlist miss"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.runIf(process.platform !== "win32")(
|
it.runIf(process.platform !== "win32")(
|
||||||
"pins PATH-token executable to canonical path for approval-based runs",
|
"pins PATH-token executable to canonical path for approval-based runs",
|
||||||
async () => {
|
async () => {
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-path-pin-"));
|
await withPathTokenCommand({
|
||||||
const binDir = path.join(tmp, "bin");
|
tmpPrefix: "openclaw-approval-path-pin-",
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
run: async ({ expected }) => {
|
||||||
const link = path.join(binDir, "poccmd");
|
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
||||||
fs.symlinkSync("/bin/echo", link);
|
preferMacAppExecHost: false,
|
||||||
const expected = fs.realpathSync(link);
|
command: ["poccmd", "-n", "SAFE"],
|
||||||
const oldPath = process.env.PATH;
|
approved: true,
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
|
security: "full",
|
||||||
try {
|
ask: "off",
|
||||||
const { runCommand, sendInvokeResult } = await runSystemInvoke({
|
});
|
||||||
preferMacAppExecHost: false,
|
expectCommandPinnedToCanonicalPath({
|
||||||
command: ["poccmd", "-n", "SAFE"],
|
runCommand,
|
||||||
approved: true,
|
expected,
|
||||||
security: "full",
|
commandTail: ["-n", "SAFE"],
|
||||||
ask: "off",
|
});
|
||||||
});
|
expectInvokeOk(sendInvokeResult);
|
||||||
expect(runCommand).toHaveBeenCalledWith(
|
},
|
||||||
[expected, "-n", "SAFE"],
|
});
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (oldPath === undefined) {
|
|
||||||
delete process.env.PATH;
|
|
||||||
} else {
|
|
||||||
process.env.PATH = oldPath;
|
|
||||||
}
|
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it.runIf(process.platform !== "win32")(
|
it.runIf(process.platform !== "win32")(
|
||||||
"pins PATH-token executable to canonical path for allowlist runs",
|
"pins PATH-token executable to canonical path for allowlist runs",
|
||||||
async () => {
|
async () => {
|
||||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-allowlist-path-pin-"));
|
|
||||||
const binDir = path.join(tmp, "bin");
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
|
||||||
const link = path.join(binDir, "poccmd");
|
|
||||||
fs.symlinkSync("/bin/echo", link);
|
|
||||||
const expected = fs.realpathSync(link);
|
|
||||||
const oldPath = process.env.PATH;
|
|
||||||
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`;
|
|
||||||
const runCommand = vi.fn(async () => ({
|
const runCommand = vi.fn(async () => ({
|
||||||
success: true,
|
...createLocalRunResult(),
|
||||||
stdout: "local-ok",
|
|
||||||
stderr: "",
|
|
||||||
timedOut: false,
|
|
||||||
truncated: false,
|
|
||||||
exitCode: 0,
|
|
||||||
error: null,
|
|
||||||
}));
|
}));
|
||||||
const sendInvokeResult = vi.fn(async () => {});
|
const sendInvokeResult = vi.fn(async () => {});
|
||||||
const sendNodeEvent = vi.fn(async () => {});
|
await withPathTokenCommand({
|
||||||
try {
|
tmpPrefix: "openclaw-allowlist-path-pin-",
|
||||||
await withTempApprovalsHome({
|
run: async ({ link, expected }) => {
|
||||||
approvals: {
|
await withTempApprovalsHome({
|
||||||
version: 1,
|
approvals: {
|
||||||
defaults: {
|
version: 1,
|
||||||
security: "allowlist",
|
defaults: {
|
||||||
ask: "off",
|
security: "allowlist",
|
||||||
askFallback: "deny",
|
ask: "off",
|
||||||
},
|
askFallback: "deny",
|
||||||
agents: {
|
},
|
||||||
main: {
|
agents: {
|
||||||
allowlist: [{ pattern: link }],
|
main: {
|
||||||
|
allowlist: [{ pattern: link }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
run: async () => {
|
||||||
run: async () => {
|
await runSystemInvoke({
|
||||||
await handleSystemRunInvoke({
|
preferMacAppExecHost: false,
|
||||||
client: {} as never,
|
|
||||||
params: {
|
|
||||||
command: ["poccmd", "-n", "SAFE"],
|
command: ["poccmd", "-n", "SAFE"],
|
||||||
sessionKey: "agent:main:main",
|
security: "allowlist",
|
||||||
},
|
ask: "off",
|
||||||
skillBins: {
|
runCommand,
|
||||||
current: async () => [],
|
sendInvokeResult,
|
||||||
},
|
});
|
||||||
execHostEnforced: false,
|
},
|
||||||
execHostFallbackAllowed: true,
|
});
|
||||||
resolveExecSecurity: () => "allowlist",
|
expectCommandPinnedToCanonicalPath({
|
||||||
resolveExecAsk: () => "off",
|
runCommand,
|
||||||
isCmdExeInvocation: () => false,
|
expected,
|
||||||
sanitizeEnv: () => undefined,
|
commandTail: ["-n", "SAFE"],
|
||||||
runCommand,
|
});
|
||||||
runViaMacAppExecHost: vi.fn(async () => null),
|
expectInvokeOk(sendInvokeResult);
|
||||||
sendNodeEvent,
|
},
|
||||||
buildExecEventPayload: (payload) => payload,
|
});
|
||||||
sendInvokeResult,
|
|
||||||
sendExecFinishedEvent: vi.fn(async () => {}),
|
|
||||||
preferMacAppExecHost: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(runCommand).toHaveBeenCalledWith(
|
|
||||||
[expected, "-n", "SAFE"],
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (oldPath === undefined) {
|
|
||||||
delete process.env.PATH;
|
|
||||||
} else {
|
|
||||||
process.env.PATH = oldPath;
|
|
||||||
}
|
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -374,14 +380,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
ask: "off",
|
ask: "off",
|
||||||
});
|
});
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeErrorMessage(sendInvokeResult, { message: "canonical cwd" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: expect.stringContaining("canonical cwd"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -407,14 +406,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
ask: "off",
|
ask: "off",
|
||||||
});
|
});
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeErrorMessage(sendInvokeResult, { message: "no symlink path components" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: expect.stringContaining("no symlink path components"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -435,17 +427,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
security: "full",
|
security: "full",
|
||||||
ask: "off",
|
ask: "off",
|
||||||
});
|
});
|
||||||
expect(runCommand).toHaveBeenCalledWith(
|
expectCommandPinnedToCanonicalPath({
|
||||||
[fs.realpathSync(script), "--flag"],
|
runCommand,
|
||||||
fs.realpathSync(tmp),
|
expected: fs.realpathSync(script),
|
||||||
undefined,
|
commandTail: ["--flag"],
|
||||||
undefined,
|
cwd: fs.realpathSync(tmp),
|
||||||
);
|
});
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeOk(sendInvokeResult);
|
||||||
expect.objectContaining({
|
|
||||||
ok: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -454,58 +442,24 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`);
|
const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`);
|
||||||
const runCommand = vi.fn(async () => {
|
const runCommand = vi.fn(async () => {
|
||||||
fs.writeFileSync(marker, "executed");
|
fs.writeFileSync(marker, "executed");
|
||||||
return {
|
return createLocalRunResult();
|
||||||
success: true,
|
|
||||||
stdout: "local-ok",
|
|
||||||
stderr: "",
|
|
||||||
timedOut: false,
|
|
||||||
truncated: false,
|
|
||||||
exitCode: 0,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
const sendInvokeResult = vi.fn(async () => {});
|
const sendInvokeResult = vi.fn(async () => {});
|
||||||
const sendNodeEvent = vi.fn(async () => {});
|
const sendNodeEvent = vi.fn(async () => {});
|
||||||
|
|
||||||
await handleSystemRunInvoke({
|
await runSystemInvoke({
|
||||||
client: {} as never,
|
|
||||||
params: {
|
|
||||||
command: ["./sh", "-lc", "/bin/echo approved-only"],
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
},
|
|
||||||
skillBins: {
|
|
||||||
current: async () => [],
|
|
||||||
},
|
|
||||||
execHostEnforced: false,
|
|
||||||
execHostFallbackAllowed: true,
|
|
||||||
resolveExecSecurity: () => "allowlist",
|
|
||||||
resolveExecAsk: () => "on-miss",
|
|
||||||
isCmdExeInvocation: () => false,
|
|
||||||
sanitizeEnv: () => undefined,
|
|
||||||
runCommand,
|
|
||||||
runViaMacAppExecHost: vi.fn(async () => null),
|
|
||||||
sendNodeEvent,
|
|
||||||
buildExecEventPayload: (payload) => payload,
|
|
||||||
sendInvokeResult,
|
|
||||||
sendExecFinishedEvent: vi.fn(async () => {}),
|
|
||||||
preferMacAppExecHost: false,
|
preferMacAppExecHost: false,
|
||||||
|
command: ["./sh", "-lc", "/bin/echo approved-only"],
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "on-miss",
|
||||||
|
runCommand,
|
||||||
|
sendInvokeResult,
|
||||||
|
sendNodeEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(fs.existsSync(marker)).toBe(false);
|
expect(fs.existsSync(marker)).toBe(false);
|
||||||
expect(sendNodeEvent).toHaveBeenCalledWith(
|
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
|
||||||
expect.anything(),
|
|
||||||
"exec.denied",
|
|
||||||
expect.objectContaining({ reason: "approval-required" }),
|
|
||||||
);
|
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "SYSTEM_RUN_DENIED: approval required",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(marker);
|
fs.unlinkSync(marker);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -514,15 +468,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => {
|
it("denies ./skill-bin even when autoAllowSkills trust entry exists", async () => {
|
||||||
const runCommand = vi.fn(async () => ({
|
const runCommand = vi.fn(async () => createLocalRunResult());
|
||||||
success: true,
|
|
||||||
stdout: "local-ok",
|
|
||||||
stderr: "",
|
|
||||||
timedOut: false,
|
|
||||||
truncated: false,
|
|
||||||
exitCode: 0,
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
const sendInvokeResult = vi.fn(async () => {});
|
const sendInvokeResult = vi.fn(async () => {});
|
||||||
const sendNodeEvent = vi.fn(async () => {});
|
const sendNodeEvent = vi.fn(async () => {});
|
||||||
|
|
||||||
@@ -541,47 +487,22 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
const skillBinPath = path.join(tempHome, "skill-bin");
|
const skillBinPath = path.join(tempHome, "skill-bin");
|
||||||
fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 });
|
fs.writeFileSync(skillBinPath, "#!/bin/sh\necho should-not-run\n", { mode: 0o755 });
|
||||||
fs.chmodSync(skillBinPath, 0o755);
|
fs.chmodSync(skillBinPath, 0o755);
|
||||||
await handleSystemRunInvoke({
|
await runSystemInvoke({
|
||||||
client: {} as never,
|
|
||||||
params: {
|
|
||||||
command: ["./skill-bin", "--help"],
|
|
||||||
cwd: tempHome,
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
},
|
|
||||||
skillBins: {
|
|
||||||
current: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }],
|
|
||||||
},
|
|
||||||
execHostEnforced: false,
|
|
||||||
execHostFallbackAllowed: true,
|
|
||||||
resolveExecSecurity: () => "allowlist",
|
|
||||||
resolveExecAsk: () => "on-miss",
|
|
||||||
isCmdExeInvocation: () => false,
|
|
||||||
sanitizeEnv: () => undefined,
|
|
||||||
runCommand,
|
|
||||||
runViaMacAppExecHost: vi.fn(async () => null),
|
|
||||||
sendNodeEvent,
|
|
||||||
buildExecEventPayload: (payload) => payload,
|
|
||||||
sendInvokeResult,
|
|
||||||
sendExecFinishedEvent: vi.fn(async () => {}),
|
|
||||||
preferMacAppExecHost: false,
|
preferMacAppExecHost: false,
|
||||||
|
command: ["./skill-bin", "--help"],
|
||||||
|
cwd: tempHome,
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "on-miss",
|
||||||
|
skillBinsCurrent: async () => [{ name: "skill-bin", resolvedPath: skillBinPath }],
|
||||||
|
runCommand,
|
||||||
|
sendInvokeResult,
|
||||||
|
sendNodeEvent,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendNodeEvent).toHaveBeenCalledWith(
|
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
|
||||||
expect.anything(),
|
|
||||||
"exec.denied",
|
|
||||||
expect.objectContaining({ reason: "approval-required" }),
|
|
||||||
);
|
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "SYSTEM_RUN_DENIED: approval required",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies env -S shell payloads in allowlist mode", async () => {
|
it("denies env -S shell payloads in allowlist mode", async () => {
|
||||||
@@ -591,14 +512,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
command: ["env", "-S", 'sh -c "echo pwned"'],
|
command: ["env", "-S", 'sh -c "echo pwned"'],
|
||||||
});
|
});
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
expectInvokeErrorMessage(sendInvokeResult, { message: "allowlist miss" });
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: expect.stringContaining("allowlist miss"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => {
|
it("denies semicolon-chained shell payloads in allowlist mode without explicit approval", async () => {
|
||||||
@@ -615,14 +529,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
command,
|
command,
|
||||||
});
|
});
|
||||||
expect(runCommand, payload).not.toHaveBeenCalled();
|
expect(runCommand, payload).not.toHaveBeenCalled();
|
||||||
expect(sendInvokeResult, payload).toHaveBeenCalledWith(
|
expectInvokeErrorMessage(sendInvokeResult, {
|
||||||
expect.objectContaining({
|
message: "SYSTEM_RUN_DENIED: approval required",
|
||||||
ok: false,
|
exact: true,
|
||||||
error: expect.objectContaining({
|
});
|
||||||
message: "SYSTEM_RUN_DENIED: approval required",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -652,49 +562,23 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
|||||||
},
|
},
|
||||||
run: async ({ tempHome }) => {
|
run: async ({ tempHome }) => {
|
||||||
const marker = path.join(tempHome, "pwned.txt");
|
const marker = path.join(tempHome, "pwned.txt");
|
||||||
await handleSystemRunInvoke({
|
await runSystemInvoke({
|
||||||
client: {} as never,
|
|
||||||
params: {
|
|
||||||
command: buildNestedEnvShellCommand({
|
|
||||||
depth: 5,
|
|
||||||
payload: `echo PWNED > ${marker}`,
|
|
||||||
}),
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
},
|
|
||||||
skillBins: {
|
|
||||||
current: async () => [],
|
|
||||||
},
|
|
||||||
execHostEnforced: false,
|
|
||||||
execHostFallbackAllowed: true,
|
|
||||||
resolveExecSecurity: () => "allowlist",
|
|
||||||
resolveExecAsk: () => "on-miss",
|
|
||||||
isCmdExeInvocation: () => false,
|
|
||||||
sanitizeEnv: () => undefined,
|
|
||||||
runCommand,
|
|
||||||
runViaMacAppExecHost: vi.fn(async () => null),
|
|
||||||
sendNodeEvent,
|
|
||||||
buildExecEventPayload: (payload) => payload,
|
|
||||||
sendInvokeResult,
|
|
||||||
sendExecFinishedEvent: vi.fn(async () => {}),
|
|
||||||
preferMacAppExecHost: false,
|
preferMacAppExecHost: false,
|
||||||
|
command: buildNestedEnvShellCommand({
|
||||||
|
depth: 5,
|
||||||
|
payload: `echo PWNED > ${marker}`,
|
||||||
|
}),
|
||||||
|
security: "allowlist",
|
||||||
|
ask: "on-miss",
|
||||||
|
runCommand,
|
||||||
|
sendInvokeResult,
|
||||||
|
sendNodeEvent,
|
||||||
});
|
});
|
||||||
expect(fs.existsSync(marker)).toBe(false);
|
expect(fs.existsSync(marker)).toBe(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(runCommand).not.toHaveBeenCalled();
|
expect(runCommand).not.toHaveBeenCalled();
|
||||||
expect(sendNodeEvent).toHaveBeenCalledWith(
|
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
|
||||||
expect.anything(),
|
|
||||||
"exec.denied",
|
|
||||||
expect.objectContaining({ reason: "approval-required" }),
|
|
||||||
);
|
|
||||||
expect(sendInvokeResult).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
ok: false,
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "SYSTEM_RUN_DENIED: approval required",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user