mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import type { ChildProcess } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import process from "node:process";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
|
|
|
|
const spawnMock = vi.hoisted(() => vi.fn());
|
|
|
|
let attachChildProcessBridge: typeof import("./child-process-bridge.js").attachChildProcessBridge;
|
|
let resolveCommandEnv: typeof import("./exec.js").resolveCommandEnv;
|
|
let resolveProcessExitCode: typeof import("./exec.js").resolveProcessExitCode;
|
|
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
|
|
let shouldSpawnWithShell: typeof import("./exec.js").shouldSpawnWithShell;
|
|
|
|
async function loadExecModules(options?: { mockSpawn?: boolean }) {
|
|
vi.resetModules();
|
|
if (options?.mockSpawn) {
|
|
vi.doMock("node:child_process", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
return {
|
|
...actual,
|
|
spawn: spawnMock,
|
|
};
|
|
});
|
|
} else {
|
|
vi.doUnmock("node:child_process");
|
|
}
|
|
({ attachChildProcessBridge } = await import("./child-process-bridge.js"));
|
|
({ resolveCommandEnv, resolveProcessExitCode, runCommandWithTimeout, shouldSpawnWithShell } =
|
|
await import("./exec.js"));
|
|
}
|
|
|
|
describe("runCommandWithTimeout", () => {
|
|
function createSilentIdleArgv(): string[] {
|
|
return [process.execPath, "-e", "setInterval(() => {}, 1_000)"];
|
|
}
|
|
|
|
function createKilledChild(signal: NodeJS.Signals = "SIGKILL"): ChildProcess {
|
|
const child = new EventEmitter() as EventEmitter & ChildProcess;
|
|
child.stdout = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdout"]>;
|
|
child.stderr = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stderr"]>;
|
|
child.stdin = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdin"]>;
|
|
child.stdin.write = vi.fn(() => true) as NonNullable<ChildProcess["stdin"]>["write"];
|
|
child.stdin.end = vi.fn() as NonNullable<ChildProcess["stdin"]>["end"];
|
|
let killed = false;
|
|
let exitCode: number | null = null;
|
|
let signalCode: NodeJS.Signals | null = null;
|
|
Object.defineProperties(child, {
|
|
pid: {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get: () => 1234,
|
|
},
|
|
killed: {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get: () => killed,
|
|
},
|
|
exitCode: {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get: () => exitCode,
|
|
},
|
|
signalCode: {
|
|
configurable: true,
|
|
enumerable: true,
|
|
get: () => signalCode,
|
|
},
|
|
});
|
|
child.kill = vi.fn((receivedSignal?: NodeJS.Signals) => {
|
|
const resolvedSignal = receivedSignal ?? signal;
|
|
killed = true;
|
|
exitCode = null;
|
|
signalCode = resolvedSignal;
|
|
child.emit("exit", null, resolvedSignal);
|
|
child.emit("close", null, resolvedSignal);
|
|
return true;
|
|
}) as ChildProcess["kill"];
|
|
return child;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.useRealTimers();
|
|
spawnMock.mockReset();
|
|
await loadExecModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.doUnmock("node:child_process");
|
|
});
|
|
|
|
it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
|
|
expect(
|
|
shouldSpawnWithShell({
|
|
resolvedCommand: "npm.cmd",
|
|
platform: "win32",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("merges custom env with base env and drops undefined values", async () => {
|
|
const resolved = resolveCommandEnv({
|
|
argv: ["node", "script.js"],
|
|
baseEnv: {
|
|
OPENCLAW_BASE_ENV: "base",
|
|
OPENCLAW_TO_REMOVE: undefined,
|
|
},
|
|
env: {
|
|
OPENCLAW_TEST_ENV: "ok",
|
|
},
|
|
});
|
|
|
|
expect(resolved.OPENCLAW_BASE_ENV).toBe("base");
|
|
expect(resolved.OPENCLAW_TEST_ENV).toBe("ok");
|
|
expect(resolved.OPENCLAW_TO_REMOVE).toBeUndefined();
|
|
expect(resolved.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
|
|
});
|
|
|
|
it("suppresses npm fund prompts for npm argv", async () => {
|
|
const resolved = resolveCommandEnv({
|
|
argv: ["npm", "--version"],
|
|
baseEnv: {},
|
|
});
|
|
|
|
expect(resolved.NPM_CONFIG_FUND).toBe("false");
|
|
expect(resolved.npm_config_fund).toBe("false");
|
|
});
|
|
|
|
it("infers success for shimmed Windows commands when exit codes are missing", () => {
|
|
expect(
|
|
resolveProcessExitCode({
|
|
explicitCode: null,
|
|
childExitCode: null,
|
|
resolvedSignal: null,
|
|
usesWindowsExitCodeShim: true,
|
|
timedOut: false,
|
|
noOutputTimedOut: false,
|
|
killIssuedByTimeout: false,
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("does not infer success when this process already issued a timeout kill", () => {
|
|
expect(
|
|
resolveProcessExitCode({
|
|
explicitCode: null,
|
|
childExitCode: null,
|
|
resolvedSignal: null,
|
|
usesWindowsExitCodeShim: true,
|
|
timedOut: true,
|
|
noOutputTimedOut: false,
|
|
killIssuedByTimeout: true,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"kills command when no output timeout elapses",
|
|
{ timeout: 5_000 },
|
|
async () => {
|
|
vi.useFakeTimers();
|
|
const child = createKilledChild();
|
|
spawnMock.mockReturnValue(child);
|
|
await loadExecModules({ mockSpawn: true });
|
|
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
|
|
timeoutMs: 2_000,
|
|
noOutputTimeoutMs: 200,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
const result = await resultPromise;
|
|
expect(result.termination).toBe("no-output-timeout");
|
|
expect(result.noOutputTimedOut).toBe(true);
|
|
expect(result.code).not.toBe(0);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"reports global timeout termination when overall timeout elapses",
|
|
{ timeout: 5_000 },
|
|
async () => {
|
|
vi.useFakeTimers();
|
|
const child = createKilledChild();
|
|
spawnMock.mockReturnValue(child);
|
|
await loadExecModules({ mockSpawn: true });
|
|
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
|
|
timeoutMs: 200,
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
const result = await resultPromise;
|
|
expect(result.termination).toBe("timeout");
|
|
expect(result.noOutputTimedOut).toBe(false);
|
|
expect(result.code).not.toBe(0);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("attachChildProcessBridge", () => {
|
|
function createFakeChild() {
|
|
const emitter = new EventEmitter() as EventEmitter & ChildProcess;
|
|
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
|
|
emitter.kill = kill as ChildProcess["kill"];
|
|
return { child: emitter, kill };
|
|
}
|
|
|
|
it("forwards SIGTERM to the wrapped child and detaches on exit", () => {
|
|
const beforeSigterm = new Set(process.listeners("SIGTERM"));
|
|
const { child, kill } = createFakeChild();
|
|
const observedSignals: NodeJS.Signals[] = [];
|
|
|
|
const { detach } = attachChildProcessBridge(child, {
|
|
signals: ["SIGTERM"],
|
|
onSignal: (signal) => observedSignals.push(signal),
|
|
});
|
|
|
|
const afterSigterm = process.listeners("SIGTERM");
|
|
const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener));
|
|
|
|
if (!addedSigterm) {
|
|
throw new Error("expected SIGTERM listener");
|
|
}
|
|
|
|
addedSigterm("SIGTERM");
|
|
expect(observedSignals).toEqual(["SIGTERM"]);
|
|
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
child.emit("exit");
|
|
expect(process.listeners("SIGTERM")).toHaveLength(beforeSigterm.size);
|
|
|
|
// Detached already via exit; should remain a safe no-op.
|
|
detach();
|
|
});
|
|
});
|