Files
openclaw/src/agents/bash-tools.exec-host-node.test.ts
2026-04-05 18:01:41 +01:00

235 lines
7.9 KiB
TypeScript

import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const preparedPlan = vi.hoisted(() => ({
argv: ["bun", "./script.ts"],
cwd: "/tmp/work",
commandText: "bun ./script.ts",
commandPreview: "bun ./script.ts",
agentId: "prepared-agent",
sessionKey: "prepared-session",
mutableFileOperand: {
argvIndex: 1,
path: "/tmp/work/script.ts",
sha256: "abc123",
},
}));
const callGatewayToolMock = vi.hoisted(() => vi.fn());
const listNodesMock = vi.hoisted(() => vi.fn());
const parsePreparedSystemRunPayloadMock = vi.hoisted(() => vi.fn());
const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
vi.fn(() => ({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "full",
hostAsk: "off",
askFallback: "deny",
})),
);
const createAndRegisterDefaultExecApprovalRequestMock = vi.hoisted(() => vi.fn());
const resolveApprovalDecisionOrUndefinedMock = vi.hoisted(() => vi.fn(async () => "allow-once"));
const createExecApprovalDecisionStateMock = vi.hoisted(() =>
vi.fn(() => ({
baseDecision: { timedOut: false },
approvedByAsk: false,
deniedReason: null,
})),
);
const buildExecApprovalPendingToolResultMock = vi.hoisted(() => vi.fn());
const registerExecApprovalRequestForHostOrThrowMock = vi.hoisted(() =>
vi.fn(async () => undefined),
);
vi.mock("../infra/exec-approvals.js", () => ({
evaluateShellAllowlist: vi.fn(() => ({
allowlistMatches: [],
analysisOk: true,
allowlistSatisfied: false,
segments: [{ resolution: null, argv: ["bun", "./script.ts"] }],
segmentAllowlistEntries: [],
})),
hasDurableExecApproval: vi.fn(() => false),
requiresExecApproval: requiresExecApprovalMock,
resolveExecApprovalAllowedDecisions: vi.fn(() => ["allow-once", "allow-always", "deny"]),
resolveExecApprovalsFromFile: vi.fn(() => ({
allowlist: [],
file: { version: 1, agents: {} },
})),
}));
vi.mock("../infra/exec-inline-eval.js", () => ({
describeInterpreterInlineEval: vi.fn(() => "inline-eval"),
detectInterpreterInlineEvalArgv: vi.fn(() => null),
}));
vi.mock("../infra/node-shell.js", () => ({
buildNodeShellCommand: vi.fn(() => ["bash", "-lc", "bun ./script.ts"]),
}));
vi.mock("../infra/system-run-approval-context.js", () => ({
parsePreparedSystemRunPayload: parsePreparedSystemRunPayloadMock,
}));
vi.mock("./bash-tools.exec-approval-request.js", () => ({
buildExecApprovalRequesterContext: vi.fn(() => ({})),
buildExecApprovalTurnSourceContext: vi.fn(() => ({})),
registerExecApprovalRequestForHostOrThrow: registerExecApprovalRequestForHostOrThrowMock,
}));
vi.mock("./bash-tools.exec-host-shared.js", () => ({
resolveExecHostApprovalContext: resolveExecHostApprovalContextMock,
buildDefaultExecApprovalRequestArgs: vi.fn(() => ({})),
createAndRegisterDefaultExecApprovalRequest: createAndRegisterDefaultExecApprovalRequestMock,
shouldResolveExecApprovalUnavailableInline: vi.fn(() => false),
buildExecApprovalFollowupTarget: vi.fn(() => ({ approvalId: "approval-1" })),
resolveApprovalDecisionOrUndefined: resolveApprovalDecisionOrUndefinedMock,
createExecApprovalDecisionState: createExecApprovalDecisionStateMock,
sendExecApprovalFollowupResult: vi.fn(async () => undefined),
buildExecApprovalPendingToolResult: buildExecApprovalPendingToolResultMock,
buildHeadlessExecApprovalDeniedMessage: vi.fn(() => "denied"),
}));
vi.mock("./bash-tools.exec-runtime.js", () => ({
DEFAULT_NOTIFY_TAIL_CHARS: 1000,
createApprovalSlug: vi.fn(() => "slug"),
normalizeNotifyOutput: vi.fn((value: string) => value),
}));
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: callGatewayToolMock,
}));
vi.mock("./tools/nodes-utils.js", () => ({
listNodes: listNodesMock,
resolveNodeIdFromList: vi.fn(() => "node-1"),
}));
vi.mock("../logger.js", () => ({
logInfo: vi.fn(),
}));
let executeNodeHostCommand: typeof import("./bash-tools.exec-host-node.js").executeNodeHostCommand;
type MockNodeInvokeParams = {
command?: string;
};
describe("executeNodeHostCommand", () => {
beforeAll(async () => {
({ executeNodeHostCommand } = await import("./bash-tools.exec-host-node.js"));
});
beforeEach(() => {
callGatewayToolMock.mockReset();
callGatewayToolMock.mockImplementation(
async (method: string, _options: unknown, params: MockNodeInvokeParams | undefined) => {
if (method !== "node.invoke") {
throw new Error(`unexpected gateway method: ${method}`);
}
if (params?.command === "system.run.prepare") {
return { payload: { plan: preparedPlan } };
}
if (params?.command === "system.run") {
return {
payload: {
success: true,
stdout: "ok",
stderr: "",
exitCode: 0,
timedOut: false,
},
};
}
throw new Error(`unexpected node invoke command: ${String(params?.command)}`);
},
);
listNodesMock.mockReset();
listNodesMock.mockResolvedValue([
{ nodeId: "node-1", commands: ["system.run"], platform: process.platform },
]);
parsePreparedSystemRunPayloadMock.mockReset();
parsePreparedSystemRunPayloadMock.mockReturnValue({ plan: preparedPlan });
requiresExecApprovalMock.mockReset();
requiresExecApprovalMock.mockReturnValue(true);
resolveExecHostApprovalContextMock.mockReset();
resolveExecHostApprovalContextMock.mockReturnValue({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
hostSecurity: "full",
hostAsk: "off",
askFallback: "deny",
});
createAndRegisterDefaultExecApprovalRequestMock.mockReset();
createAndRegisterDefaultExecApprovalRequestMock.mockImplementation(async (args?: unknown) => {
const register =
args && typeof args === "object" && "register" in args
? (args as { register?: (approvalId: string) => Promise<void> }).register
: undefined;
await register?.("approval-1");
return {
approvalId: "approval-1",
approvalSlug: "slug-1",
warningText: "",
expiresAtMs: Date.now() + 60_000,
preResolvedDecision: null,
initiatingSurface: "origin",
sentApproverDms: false,
unavailableReason: null,
};
});
resolveApprovalDecisionOrUndefinedMock.mockReset();
resolveApprovalDecisionOrUndefinedMock.mockResolvedValue("allow-once");
createExecApprovalDecisionStateMock.mockReset();
createExecApprovalDecisionStateMock.mockReturnValue({
baseDecision: { timedOut: false },
approvedByAsk: false,
deniedReason: null,
});
buildExecApprovalPendingToolResultMock.mockReset();
buildExecApprovalPendingToolResultMock.mockReturnValue({
content: [],
details: { status: "approval-pending" },
});
registerExecApprovalRequestForHostOrThrowMock.mockReset();
});
it("forwards prepared systemRunPlan on async node invoke after approval", async () => {
const result = await executeNodeHostCommand({
command: "bun ./script.ts",
workdir: "/tmp/work",
env: {},
security: "full",
ask: "off",
defaultTimeoutSec: 30,
approvalRunningNoticeMs: 0,
warnings: [],
agentId: "requested-agent",
sessionKey: "requested-session",
});
expect(result.details?.status).toBe("approval-pending");
expect(registerExecApprovalRequestForHostOrThrowMock).toHaveBeenCalledWith(
expect.objectContaining({
systemRunPlan: preparedPlan,
}),
);
await vi.waitFor(() => {
expect(callGatewayToolMock).toHaveBeenCalledTimes(2);
});
expect(callGatewayToolMock).toHaveBeenNthCalledWith(
2,
"node.invoke",
expect.anything(),
expect.objectContaining({
command: "system.run",
params: expect.objectContaining({
approved: true,
approvalDecision: "allow-once",
systemRunPlan: preparedPlan,
}),
}),
);
});
});