mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
perf(test): slim bash tool imports
This commit is contained in:
@@ -9,6 +9,111 @@ import {
|
||||
import { applyPatch } from "./apply-patch.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
|
||||
const pinnedPathHelper = vi.hoisted(() => {
|
||||
const fs = require("node:fs/promises") as typeof import("node:fs/promises");
|
||||
const path = require("node:path") as typeof import("node:path");
|
||||
const { pipeline } = require("node:stream/promises") as typeof import("node:stream/promises");
|
||||
|
||||
async function resolvePinnedParent(params: {
|
||||
rootPath: string;
|
||||
relativeParentPath?: string;
|
||||
mkdir?: boolean;
|
||||
}): Promise<string> {
|
||||
let current = params.rootPath;
|
||||
for (const segment of (params.relativeParentPath ?? "").split("/").filter(Boolean)) {
|
||||
const next = path.join(current, segment);
|
||||
try {
|
||||
const stat = await fs.lstat(next);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new Error("symbolic link or non-directory path segment");
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !params.mkdir) {
|
||||
throw error;
|
||||
}
|
||||
await fs.mkdir(next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
runPinnedPathHelper: vi.fn(
|
||||
async (params: {
|
||||
operation: "mkdirp" | "remove";
|
||||
rootPath: string;
|
||||
relativePath: string;
|
||||
}) => {
|
||||
const segments = params.relativePath.split("/").filter(Boolean);
|
||||
const targetPath = path.join(params.rootPath, ...segments);
|
||||
if (params.operation === "mkdirp") {
|
||||
await resolvePinnedParent({
|
||||
rootPath: params.rootPath,
|
||||
relativeParentPath: params.relativePath,
|
||||
mkdir: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await resolvePinnedParent({
|
||||
rootPath: params.rootPath,
|
||||
relativeParentPath: segments.slice(0, -1).join("/"),
|
||||
mkdir: false,
|
||||
});
|
||||
const stat = await fs.lstat(targetPath);
|
||||
if (stat.isDirectory() && !stat.isSymbolicLink()) {
|
||||
await fs.rmdir(targetPath);
|
||||
return;
|
||||
}
|
||||
await fs.unlink(targetPath);
|
||||
},
|
||||
),
|
||||
runPinnedWriteHelper: vi.fn(
|
||||
async (params: {
|
||||
rootPath: string;
|
||||
relativeParentPath: string;
|
||||
basename: string;
|
||||
mkdir: boolean;
|
||||
mode: number;
|
||||
input:
|
||||
| { kind: "buffer"; data: string | Buffer; encoding?: BufferEncoding }
|
||||
| { kind: "stream"; stream: NodeJS.ReadableStream };
|
||||
}) => {
|
||||
const parentPath = await resolvePinnedParent({
|
||||
rootPath: params.rootPath,
|
||||
relativeParentPath: params.relativeParentPath,
|
||||
mkdir: params.mkdir,
|
||||
});
|
||||
const targetPath = path.join(parentPath, params.basename);
|
||||
if (params.input.kind === "buffer") {
|
||||
await fs.writeFile(targetPath, params.input.data, {
|
||||
encoding: params.input.encoding,
|
||||
mode: params.mode,
|
||||
});
|
||||
} else {
|
||||
const handle = await fs.open(targetPath, "w", params.mode);
|
||||
try {
|
||||
await pipeline(params.input.stream, handle.createWriteStream());
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const stat = await fs.stat(targetPath);
|
||||
return { dev: stat.dev, ino: stat.ino };
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/fs-pinned-path-helper.js", () => ({
|
||||
isPinnedPathHelperSpawnError: () => false,
|
||||
runPinnedPathHelper: pinnedPathHelper.runPinnedPathHelper,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/fs-pinned-write-helper.js", () => ({
|
||||
runPinnedWriteHelper: pinnedPathHelper.runPinnedWriteHelper,
|
||||
}));
|
||||
|
||||
async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-patch-"));
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/io.js";
|
||||
import { sendMessage } from "../infra/outbound/message.js";
|
||||
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
@@ -25,13 +24,144 @@ vi.mock("../infra/outbound/message.js", () => ({
|
||||
sendMessage: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/shell-env.js", async () => {
|
||||
const mod =
|
||||
await vi.importActual<typeof import("../infra/shell-env.js")>("../infra/shell-env.js");
|
||||
vi.mock("../utils/message-channel.js", () => {
|
||||
const normalizeMessageChannel = (raw?: string | null) => {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "web" || normalized === "webchat") {
|
||||
return "internal";
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
const isGatewayMessageChannel = (value: string) => Boolean(normalizeMessageChannel(value));
|
||||
return {
|
||||
...mod,
|
||||
getShellPathFromLoginShell: vi.fn(() => null),
|
||||
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
|
||||
INTERNAL_MESSAGE_CHANNEL: "internal",
|
||||
isDeliverableMessageChannel: (value: string) => {
|
||||
const channel = normalizeMessageChannel(value);
|
||||
return Boolean(channel && channel !== "internal" && channel !== "tui");
|
||||
},
|
||||
isGatewayMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
resolveGatewayMessageChannel: normalizeMessageChannel,
|
||||
resolveMessageChannel: (primary?: string | null, fallback?: string | null) =>
|
||||
normalizeMessageChannel(primary) ?? normalizeMessageChannel(fallback),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/delivery-context.js", () => ({
|
||||
normalizeDeliveryContext: (context?: {
|
||||
channel?: string | null;
|
||||
to?: string | number | null;
|
||||
accountId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = context.channel?.trim().toLowerCase();
|
||||
const to = context.to == null ? undefined : String(context.to).trim();
|
||||
const accountId = context.accountId?.trim();
|
||||
const threadId = context.threadId == null ? undefined : context.threadId;
|
||||
if (!channel && !to && !accountId && threadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
channel: channel || undefined,
|
||||
to: to || undefined,
|
||||
accountId: accountId || undefined,
|
||||
...(threadId != null && threadId !== "" ? { threadId } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../infra/exec-approval-surface.js", () => ({
|
||||
describeNativeExecApprovalClientSetup: () => null,
|
||||
listNativeExecApprovalClientLabels: () => [],
|
||||
resolveExecApprovalInitiatingSurfaceState: (params: {
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
}) => {
|
||||
const channel = params.channel ?? undefined;
|
||||
return {
|
||||
kind: "enabled",
|
||||
channel,
|
||||
channelLabel:
|
||||
channel === "tui" ? "terminal UI" : channel === "internal" ? "Web UI" : "this platform",
|
||||
accountId: params.accountId ?? undefined,
|
||||
};
|
||||
},
|
||||
supportsNativeExecApprovalClient: (channel?: string | null) =>
|
||||
!channel || channel === "internal" || channel === "tui",
|
||||
}));
|
||||
|
||||
vi.mock("../infra/shell-env.js", () => ({
|
||||
getShellPathFromLoginShell: vi.fn(() => null),
|
||||
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => {
|
||||
const stdoutFor = (command: string) => {
|
||||
if (
|
||||
command.includes("calendar events primary --today --json") ||
|
||||
command.includes("gog-wrapper")
|
||||
) {
|
||||
return '{"events":[]}\n';
|
||||
}
|
||||
if (command.includes("printf delayed-ok")) {
|
||||
return "delayed-ok";
|
||||
}
|
||||
if (command.includes("printf webchat-ok")) {
|
||||
return "webchat-ok";
|
||||
}
|
||||
if (command.includes("printf approval-one")) {
|
||||
return "approval-one";
|
||||
}
|
||||
if (command.includes("printf approval-two")) {
|
||||
return "approval-two";
|
||||
}
|
||||
if (command.includes("echo allow-always")) {
|
||||
return "allow-always\n";
|
||||
}
|
||||
if (command.includes("echo cron-ok")) {
|
||||
return "cron-ok\n";
|
||||
}
|
||||
if (command.includes("echo ok")) {
|
||||
return "ok\n";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
return {
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: async (input: { argv?: string[]; onStdout?: (chunk: string) => void }) => {
|
||||
const command = input.argv?.join(" ") ?? "";
|
||||
const stdout = stdoutFor(command);
|
||||
if (stdout) {
|
||||
input.onStdout?.(stdout);
|
||||
}
|
||||
return {
|
||||
runId: "mock-approval-run",
|
||||
startedAtMs: Date.now(),
|
||||
stdin: undefined,
|
||||
wait: async () => ({
|
||||
reason: "exit" as const,
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -256,8 +386,6 @@ describe("exec approvals", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
@@ -336,7 +464,7 @@ describe("exec approvals", () => {
|
||||
).toMatchObject({
|
||||
suppressNotifyOnExit: true,
|
||||
});
|
||||
await expect.poll(() => agentParams, { timeout: 2_000, interval: 1 }).toBeTruthy();
|
||||
await expect.poll(() => agentParams, { timeout: 2000, interval: 1 }).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips approval when node allowlist is satisfied", async () => {
|
||||
@@ -619,13 +747,20 @@ describe("exec approvals", () => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const raw = await fs.readFile(approvalsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
agents?: { main?: { allowlist?: Array<{ source?: string }> } };
|
||||
};
|
||||
return parsed.agents?.main?.allowlist?.some((entry) => entry.source === "allow-always");
|
||||
try {
|
||||
const raw = await fs.readFile(approvalsPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
agents?: { main?: { allowlist?: Array<{ source?: string }> } };
|
||||
};
|
||||
return (
|
||||
parsed.agents?.main?.allowlist?.some((entry) => entry.source === "allow-always") ===
|
||||
true
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 1_000, interval: 1 },
|
||||
{ timeout: 2000, interval: 1 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
@@ -796,7 +931,7 @@ describe("exec approvals", () => {
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1);
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1);
|
||||
expect(agentCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -837,7 +972,7 @@ describe("exec approvals", () => {
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1);
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1);
|
||||
expect(agentCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:discord:channel:123",
|
||||
@@ -900,7 +1035,7 @@ describe("exec approvals", () => {
|
||||
|
||||
resolveDecision?.({ decision: "allow-once" });
|
||||
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1);
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1);
|
||||
expect(agentCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:discord:channel:123",
|
||||
@@ -944,7 +1079,7 @@ describe("exec approvals", () => {
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1);
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1);
|
||||
expect(agentCalls[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -985,7 +1120,7 @@ describe("exec approvals", () => {
|
||||
});
|
||||
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3_000, interval: 1 }).toBe(1);
|
||||
await expect.poll(() => agentCalls.length, { timeout: 3000, interval: 1 }).toBe(1);
|
||||
expect(typeof agentCalls[0]?.message).toBe("string");
|
||||
expect(agentCalls[0]?.message).toContain("An async command did not run.");
|
||||
expect(agentCalls[0]?.message).toContain(
|
||||
@@ -1025,17 +1160,17 @@ describe("exec approvals", () => {
|
||||
const tool = createElevatedAllowlistExecTool();
|
||||
|
||||
const first = await tool.execute("call-seq-1", {
|
||||
command: "npm view diver --json",
|
||||
command: "printf approval-one",
|
||||
elevated: true,
|
||||
});
|
||||
const second = await tool.execute("call-seq-2", {
|
||||
command: "brew outdated",
|
||||
command: "printf approval-two",
|
||||
elevated: true,
|
||||
});
|
||||
|
||||
expect(first.details.status).toBe("approval-pending");
|
||||
expect(second.details.status).toBe("approval-pending");
|
||||
expect(requestCommands).toEqual(["npm view diver --json", "brew outdated"]);
|
||||
expect(requestCommands).toEqual(["printf approval-one", "printf approval-two"]);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[0]).not.toBe(requestIds[1]);
|
||||
expect(waitIds).toEqual(requestIds);
|
||||
|
||||
@@ -1,10 +1,87 @@
|
||||
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest";
|
||||
import { killProcessTree } from "../process/kill-tree.js";
|
||||
|
||||
const supervisorMockState = vi.hoisted(() => ({
|
||||
cancelReasons: [] as Array<"manual-cancel" | "overall-timeout">,
|
||||
spawnInputs: [] as Array<{ timeoutMs?: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: async (input: { timeoutMs?: number }) => {
|
||||
supervisorMockState.spawnInputs.push(input);
|
||||
const runId = `mock-run-${++counter}`;
|
||||
let settled = false;
|
||||
let settle = (_reason: "manual-cancel" | "overall-timeout", _timedOut: boolean) => {};
|
||||
const waitPromise = new Promise<{
|
||||
reason: "manual-cancel" | "overall-timeout";
|
||||
exitCode: number | null;
|
||||
exitSignal: NodeJS.Signals | number | null;
|
||||
durationMs: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
noOutputTimedOut: boolean;
|
||||
}>((resolve) => {
|
||||
settle = (reason, timedOut) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({
|
||||
reason,
|
||||
exitCode: null,
|
||||
exitSignal: null,
|
||||
durationMs: input.timeoutMs ?? 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut,
|
||||
noOutputTimedOut: false,
|
||||
});
|
||||
};
|
||||
if (input.timeoutMs !== undefined) {
|
||||
setTimeout(() => settle("overall-timeout", true), 12);
|
||||
}
|
||||
});
|
||||
return {
|
||||
runId,
|
||||
startedAtMs: Date.now(),
|
||||
stdin: undefined,
|
||||
wait: () => waitPromise,
|
||||
cancel: () => {
|
||||
supervisorMockState.cancelReasons.push("manual-cancel");
|
||||
settle("manual-cancel", false);
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/shell-env.js", () => ({
|
||||
getShellPathFromLoginShell: vi.fn(() => null),
|
||||
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock("./bash-tools.exec-host-gateway.js", () => ({
|
||||
processGatewayAllowlist: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("./bash-tools.exec-host-node.js", () => ({
|
||||
executeNodeHostCommand: vi.fn(async () => {
|
||||
throw new Error("node host not expected in background abort tests");
|
||||
}),
|
||||
}));
|
||||
|
||||
const BACKGROUND_HOLD_CMD =
|
||||
process.platform === "win32" ? 'node -e "setTimeout(() => {}, 5000)"' : "exec sleep 5";
|
||||
const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 10;
|
||||
const ABORT_WAIT_TIMEOUT_MS = process.platform === "win32" ? 1_500 : 400;
|
||||
process.platform === "win32" ? 'node -e "setTimeout(() => {}, 1000)"' : "exec sleep 1";
|
||||
const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 0;
|
||||
const POLL_INTERVAL_MS = process.platform === "win32" ? 15 : 5;
|
||||
const FINISHED_WAIT_TIMEOUT_MS = process.platform === "win32" ? 8_000 : 1_000;
|
||||
const BACKGROUND_TIMEOUT_SEC = process.platform === "win32" ? 0.2 : 0.02;
|
||||
@@ -32,6 +109,8 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
supervisorMockState.cancelReasons.length = 0;
|
||||
supervisorMockState.spawnInputs.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -78,21 +157,14 @@ async function expectBackgroundSessionSurvivesAbort(params: {
|
||||
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||
|
||||
abortController.abort();
|
||||
const startedAt = Date.now();
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const running = getSession(sessionId);
|
||||
const finished = getFinishedSession(sessionId);
|
||||
return Date.now() - startedAt >= ABORT_SETTLE_MS && !finished && running?.exited === false;
|
||||
},
|
||||
{ timeout: ABORT_WAIT_TIMEOUT_MS, interval: POLL_INTERVAL_MS },
|
||||
)
|
||||
.toBe(true);
|
||||
if (ABORT_SETTLE_MS > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ABORT_SETTLE_MS));
|
||||
}
|
||||
|
||||
const running = getSession(sessionId);
|
||||
const finished = getFinishedSession(sessionId);
|
||||
try {
|
||||
expect(supervisorMockState.cancelReasons).toEqual([]);
|
||||
expect(finished).toBeUndefined();
|
||||
expect(running?.exited).toBe(false);
|
||||
} finally {
|
||||
@@ -163,22 +235,9 @@ test("background exec without explicit timeout ignores default timeout", async (
|
||||
const result = await tool.execute("toolcall", { command: BACKGROUND_HOLD_CMD, background: true });
|
||||
expect(result.details.status).toBe("running");
|
||||
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||
const waitMs = Math.max(ABORT_SETTLE_MS + 30, BACKGROUND_TIMEOUT_SEC * 1000 + 30);
|
||||
|
||||
const startedAt = Date.now();
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const running = getSession(sessionId);
|
||||
const finished = getFinishedSession(sessionId);
|
||||
return Date.now() - startedAt >= waitMs && !finished && running?.exited === false;
|
||||
},
|
||||
{
|
||||
timeout: waitMs + ABORT_WAIT_TIMEOUT_MS,
|
||||
interval: POLL_INTERVAL_MS,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
expect(supervisorMockState.spawnInputs.at(-1)?.timeoutMs).toBeUndefined();
|
||||
expect(getFinishedSession(sessionId)).toBeUndefined();
|
||||
expect(getSession(sessionId)?.exited).toBe(false);
|
||||
|
||||
cleanupRunningSession(sessionId);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,48 @@ vi.mock("../infra/exec-approvals.js", async () => {
|
||||
return { ...mod, resolveExecApprovals: () => createExecApprovals() };
|
||||
});
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: async (input: {
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onStdout?: (chunk: string) => void;
|
||||
}) => {
|
||||
const command = input.argv?.at(-1) ?? "";
|
||||
const env = input.env ?? {};
|
||||
if (command.includes("OPENCLAW_SHELL")) {
|
||||
input.onStdout?.(env.OPENCLAW_SHELL ?? "");
|
||||
} else if (command.includes("SSLKEYLOGFILE")) {
|
||||
input.onStdout?.(env.SSLKEYLOGFILE ?? "");
|
||||
} else if (command.includes("$PATH")) {
|
||||
input.onStdout?.(env.PATH ?? "");
|
||||
} else if (command === "echo ok") {
|
||||
input.onStdout?.("ok\n");
|
||||
}
|
||||
return {
|
||||
runId: "mock-path-run",
|
||||
startedAtMs: Date.now(),
|
||||
stdin: undefined,
|
||||
wait: async () => ({
|
||||
reason: "exit" as const,
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
|
||||
|
||||
function createExecApprovals(): ExecApprovalsResolved {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { analyzeShellCommand } from "../infra/exec-approvals-analysis.js";
|
||||
import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js";
|
||||
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
|
||||
import { SafeOpenError, readFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
|
||||
import {
|
||||
getShellPathFromLoginShell,
|
||||
@@ -118,7 +117,19 @@ function getNodeErrorCode(error: unknown): string | undefined {
|
||||
return String((error as { code?: unknown }).code);
|
||||
}
|
||||
|
||||
function shouldSkipScriptPreflightPathError(error: unknown): boolean {
|
||||
type FsSafeModule = typeof import("../infra/fs-safe.js");
|
||||
|
||||
let fsSafeModulePromise: Promise<FsSafeModule> | undefined;
|
||||
|
||||
async function loadFsSafeModule(): Promise<FsSafeModule> {
|
||||
fsSafeModulePromise ??= import("../infra/fs-safe.js");
|
||||
return await fsSafeModulePromise;
|
||||
}
|
||||
|
||||
function shouldSkipScriptPreflightPathError(
|
||||
error: unknown,
|
||||
SafeOpenError: FsSafeModule["SafeOpenError"],
|
||||
): boolean {
|
||||
if (error instanceof SafeOpenError) {
|
||||
return true;
|
||||
}
|
||||
@@ -951,6 +962,7 @@ async function validateScriptFileForShellBleed(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const { SafeOpenError, readFileWithinRoot } = await loadFsSafeModule();
|
||||
for (const relOrAbsPath of target.relOrAbsPaths) {
|
||||
const absPath = path.isAbsolute(relOrAbsPath)
|
||||
? path.resolve(relOrAbsPath)
|
||||
@@ -978,7 +990,7 @@ async function validateScriptFileForShellBleed(params: {
|
||||
});
|
||||
content = safeRead.buffer.toString("utf-8");
|
||||
} catch (error) {
|
||||
if (shouldSkipScriptPreflightPathError(error)) {
|
||||
if (shouldSkipScriptPreflightPathError(error, SafeOpenError)) {
|
||||
// Preflight validation is best-effort: skip path/read failures and
|
||||
// continue to execute the command normally.
|
||||
continue;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { drainFormattedSystemEvents } from "../auto-reply/reply/session-updates.js";
|
||||
import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resetHeartbeatWakeStateForTests,
|
||||
@@ -25,13 +25,161 @@ import {
|
||||
import { createExecTool, createProcessTool } from "./bash-tools.js";
|
||||
import { resolveShellFromPath, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||
|
||||
vi.mock("../infra/channel-summary.js", () => ({
|
||||
buildChannelSummary: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/exec-approval-surface.js", () => ({
|
||||
describeNativeExecApprovalClientSetup: () => null,
|
||||
listNativeExecApprovalClientLabels: () => [],
|
||||
resolveExecApprovalInitiatingSurfaceState: (params: {
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
}) => {
|
||||
const channel = params.channel ?? undefined;
|
||||
return {
|
||||
kind: "enabled",
|
||||
channel,
|
||||
channelLabel:
|
||||
channel === "tui" ? "terminal UI" : channel === "internal" ? "Web UI" : "this platform",
|
||||
accountId: params.accountId ?? undefined,
|
||||
};
|
||||
},
|
||||
supportsNativeExecApprovalClient: (channel?: string | null) =>
|
||||
!channel || channel === "internal" || channel === "tui",
|
||||
}));
|
||||
|
||||
vi.mock("../utils/delivery-context.js", () => ({
|
||||
normalizeDeliveryContext: (context?: {
|
||||
channel?: string | null;
|
||||
to?: string | number | null;
|
||||
accountId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}) => {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = context.channel?.trim().toLowerCase();
|
||||
const to = context.to == null ? undefined : String(context.to).trim();
|
||||
const accountId = context.accountId?.trim();
|
||||
const threadId = context.threadId == null ? undefined : context.threadId;
|
||||
if (!channel && !to && !accountId && threadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
channel: channel || undefined,
|
||||
to: to || undefined,
|
||||
accountId: accountId || undefined,
|
||||
...(threadId != null && threadId !== "" ? { threadId } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./bash-tools.exec-approval-followup.js", () => ({
|
||||
buildExecApprovalFollowupPrompt: (text: string) => text,
|
||||
sendExecApprovalFollowup: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(async () => ({ ok: true })),
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/shell-env.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../infra/shell-env.js")>("../infra/shell-env.js");
|
||||
return {
|
||||
...actual,
|
||||
getShellPathFromLoginShell: vi.fn(() => null),
|
||||
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => {
|
||||
type SpawnInput = {
|
||||
argv?: string[];
|
||||
ptyCommand?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onStdout?: (chunk: string) => void;
|
||||
};
|
||||
|
||||
const immediate = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const readEnvPath = (env?: NodeJS.ProcessEnv) => env?.PATH ?? env?.Path ?? "";
|
||||
const extractCommand = (input: SpawnInput) => input.ptyCommand ?? input.argv?.at(-1) ?? "";
|
||||
const splitCommands = (command: string) =>
|
||||
command
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const stdoutForSegment = (segment: string, env?: NodeJS.ProcessEnv) => {
|
||||
if (segment === "echo $PATH" || segment === "Write-Output $env:PATH") {
|
||||
return `${readEnvPath(env)}\n`;
|
||||
}
|
||||
if (segment.startsWith("echo ")) {
|
||||
return `${segment.slice("echo ".length)}\n`;
|
||||
}
|
||||
if (segment.startsWith("Write-Output ")) {
|
||||
return `${segment.slice("Write-Output ".length)}\n`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const commandOutput = (command: string, env?: NodeJS.ProcessEnv) =>
|
||||
splitCommands(command)
|
||||
.map((segment) => stdoutForSegment(segment, env))
|
||||
.join("");
|
||||
|
||||
return {
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: async (input: SpawnInput) => {
|
||||
const command = extractCommand(input);
|
||||
const output = commandOutput(command, input.env);
|
||||
const exitCode = splitCommands(command).includes("exit 1") ? 1 : 0;
|
||||
const stagedOutput = command.includes("after")
|
||||
? output.replace(/after[^\n]*\n?/gu, "")
|
||||
: output;
|
||||
const deferredOutput = output.slice(stagedOutput.length);
|
||||
if (stagedOutput) {
|
||||
input.onStdout?.(stagedOutput);
|
||||
}
|
||||
return {
|
||||
runId: "mock-bash-run",
|
||||
startedAtMs: Date.now(),
|
||||
pid: 123,
|
||||
stdin: undefined,
|
||||
wait: async () => {
|
||||
await immediate();
|
||||
if (deferredOutput) {
|
||||
input.onStdout?.(deferredOutput);
|
||||
}
|
||||
return {
|
||||
reason: "exit" as const,
|
||||
exitCode,
|
||||
exitSignal: null,
|
||||
durationMs: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
};
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
cancelScope: vi.fn(),
|
||||
reconcileOrphans: vi.fn(),
|
||||
getRecord: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const defaultShell = isWin
|
||||
? undefined
|
||||
: process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
|
||||
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
|
||||
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 4" : "sleep 0.004";
|
||||
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 16" : "sleep 0.016";
|
||||
const POLL_INTERVAL_MS = isWin ? 15 : 2;
|
||||
const BACKGROUND_POLL_TIMEOUT_MS = isWin ? 8000 : 1200;
|
||||
const NOTIFY_EVENT_TIMEOUT_MS = isWin ? 12_000 : 5_000;
|
||||
@@ -122,6 +270,7 @@ const readNormalizedTextContent = (content: ToolTextContent) =>
|
||||
normalizeText(readTextContent(content));
|
||||
const readTrimmedLines = (content: ToolTextContent) =>
|
||||
(readTextContent(content) ?? "").split("\n").map((line) => line.trim());
|
||||
const waitOneTurn = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const readTotalLines = (details: unknown) => (details as { totalLines?: number }).totalLines;
|
||||
const readProcessStatus = (details: unknown) => (details as { status?: string }).status;
|
||||
const readProcessStatusOrRunning = (details: unknown) =>
|
||||
@@ -516,11 +665,7 @@ describe("exec tool backgrounding", () => {
|
||||
it(
|
||||
"backgrounds after yield and can be polled",
|
||||
async () => {
|
||||
const result = await executeExecCommand(
|
||||
execTool,
|
||||
joinCommands([yieldDelayCmd, shellEcho(OUTPUT_DONE)]),
|
||||
{ yieldMs: 10 },
|
||||
);
|
||||
const result = await executeExecCommand(execTool, shellEcho(OUTPUT_DONE), { yieldMs: 0 });
|
||||
|
||||
// Timing can race here: command may already be complete before the first response.
|
||||
if (result.details.status === PROCESS_STATUS_COMPLETED) {
|
||||
@@ -787,7 +932,7 @@ describe("exec backgrounded onUpdate suppression", () => {
|
||||
await execTool.execute(nextCallId(), { command }, undefined, onUpdateSpy);
|
||||
const callsAtExit = onUpdateSpy.mock.calls.length;
|
||||
// Allow a tick for any straggling stdout data events.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await waitOneTurn();
|
||||
expect(onUpdateSpy.mock.calls.length).toBe(callsAtExit);
|
||||
},
|
||||
isWin ? 10_000 : 5_000,
|
||||
@@ -806,7 +951,7 @@ describe("exec backgrounded onUpdate suppression", () => {
|
||||
]);
|
||||
// Abort almost immediately so the signal fires while the command
|
||||
// is still producing output.
|
||||
setTimeout(() => abortController.abort(), 10);
|
||||
setTimeout(() => abortController.abort(), 0);
|
||||
const result = await execTool.execute(
|
||||
nextCallId(),
|
||||
{ command },
|
||||
@@ -815,7 +960,7 @@ describe("exec backgrounded onUpdate suppression", () => {
|
||||
);
|
||||
const callsAtAbort = onUpdateSpy.mock.calls.length;
|
||||
// Allow a tick for any straggling stdout data events.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await waitOneTurn();
|
||||
// After abort, no new onUpdate calls should have been made.
|
||||
expect(onUpdateSpy.mock.calls.length).toBe(callsAtAbort);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
Reference in New Issue
Block a user