Files
openclaw/src/agents/shell-snapshot.spawn.test.ts
thomas.szbay b06fe2a673 fix(kill-tree): verify process group leader before using group kill to prevent gateway SIGTERM (#76259) (#94697)
* fix(kill-tree): verify process group leader before group kill to prevent gateway SIGTERM (#76259)

- Add isProcessGroupLeader() to killProcessTree/signalProcessTree: ps -p <pid> -o pgid= primary check with /proc/<pid>/stat fallback on Linux. Group kill only when the PID is its own process group leader; non-leaders fall back to single-pid kill, preventing accidental gateway SIGTERM when a non-detached child shares the gateway's process group.
- Propagate detached: true to all detached-spawn cleanup callers (exec-termination, agent-bundle LSP, mcp-stdio, bash, supervisor pty, agent-core nodejs) so detached group cleanup survives leader exit.
- Gateway/daemon cleanup paths (schtasks, restart-health) keep the leader-checked default (detached omitted).

Closes #76259

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(process): tighten process-group ownership checks

* refactor(daemon): split restart diagnostics

* refactor(daemon): isolate restart health types

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-16 12:30:51 -07:00

68 lines
2.3 KiB
TypeScript

import type { ChildProcess, SpawnOptions } from "node:child_process";
import { EventEmitter } from "node:events";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { maybeWrapCommandWithShellSnapshot } from "./shell-snapshot.js";
const { killProcessTreeMock, spawnMock } = vi.hoisted(() => ({
killProcessTreeMock: vi.fn(),
spawnMock: vi.fn(),
}));
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{ spawn: spawnMock },
);
});
vi.mock("../process/kill-tree.js", () => ({
killProcessTree: killProcessTreeMock,
}));
describe.skipIf(process.platform === "win32")("shell snapshot subprocesses", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["HOME", "OPENCLAW_EXEC_SHELL_SNAPSHOT", "OPENCLAW_STATE_DIR"]);
spawnMock.mockReset();
killProcessTreeMock.mockReset();
});
afterEach(() => {
envSnapshot.restore();
});
it("does not create output pipes for status-only shell commands", async () => {
const child = Object.assign(new EventEmitter(), { pid: 4242 }) as ChildProcess;
spawnMock.mockImplementation(() => {
queueMicrotask(() => child.emit("close", 1));
return child;
});
const home = tempDirs.make("openclaw-snapshot-spawn-home-");
const stateDir = tempDirs.make("openclaw-snapshot-spawn-state-");
setTestEnvValue("HOME", home);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
const command = "echo unchanged";
await expect(
maybeWrapCommandWithShellSnapshot({
command,
shell: "/bin/bash",
shellArgs: ["-c"],
cwd: os.tmpdir(),
env: { ...process.env },
}),
).resolves.toBe(command);
const options = spawnMock.mock.calls[0]?.[2] as SpawnOptions | undefined;
expect(options?.stdio).toBe("ignore");
expect(killProcessTreeMock).toHaveBeenCalledWith(4242, { graceMs: 0, detached: true });
});
});