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>
This commit is contained in:
thomas.szbay
2026-07-17 03:30:51 +08:00
committed by GitHub
parent cf24f14c63
commit b06fe2a673
21 changed files with 299 additions and 146 deletions
+64 -6
View File
@@ -1,5 +1,6 @@
// Agent Core module implements kill tree behavior.
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
const DEFAULT_GRACE_MS = 3000;
const MAX_GRACE_MS = 60_000;
@@ -16,9 +17,14 @@ export type KillProcessTreeOptions = {
* first (without /F), then force-kills if process survives.
* - Unix: send SIGTERM to process group first, wait grace period, then SIGKILL.
*
* When the child was spawned with `detached: false`, pass `detached: false` to
* skip the Unix `process.kill(-pid, ...)` group-kill. That avoids signaling the
* gateway's own process group.
* Group kill (`process.kill(-pid, ...)`) is only used when the PID is verified
* as its own process group leader, unless `detached: true` is explicitly passed.
* This prevents accidentally signaling the gateway's process group when the
* child shares its parent's group.
*
* - `detached: false`: skip group kill unconditionally.
* - `detached: true`: use group kill unconditionally (trust caller).
* - `detached` omitted: use group kill only when PID is the group leader.
*/
export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): void {
if (!Number.isFinite(pid) || pid <= 0) {
@@ -35,7 +41,8 @@ export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): voi
return;
}
const useGroupKill = opts?.detached !== false;
const useGroupKill =
opts?.detached === true || (opts?.detached !== false && isProcessGroupLeader(pid));
if (opts?.force === true) {
signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
return;
@@ -68,7 +75,9 @@ export function signalProcessTree(
return;
}
signalProcessTreeUnix(pid, signal, opts?.detached !== false);
const useGroupKill =
opts?.detached === true || (opts?.detached !== false && isProcessGroupLeader(pid));
signalProcessTreeUnix(pid, signal, useGroupKill);
}
function normalizeGraceMs(value?: number): number {
@@ -87,6 +96,55 @@ function isProcessAlive(pid: number): boolean {
}
}
function parseProcessGroupId(value: unknown): number | undefined {
if (typeof value !== "string" || !/^\d+$/.test(value.trim())) {
return undefined;
}
const pgid = Number(value.trim());
return Number.isSafeInteger(pgid) && pgid > 0 ? pgid : undefined;
}
function readProcessGroupIdFromPs(pid: number): number | undefined {
try {
const res = spawnSync("ps", ["-p", String(pid), "-o", "pgid="], {
encoding: "utf8",
timeout: 500,
});
if (res.error || res.status !== 0) {
return undefined;
}
return parseProcessGroupId(res.stdout);
} catch {
return undefined;
}
}
function readProcessGroupIdFromProc(pid: number): number | undefined {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const commEnd = stat.lastIndexOf(")");
if (commEnd < 0) {
return undefined;
}
// After comm: state, ppid, pgrp. The command name may contain spaces or ')'.
const fields = stat
.slice(commEnd + 1)
.trim()
.split(/\s+/);
return parseProcessGroupId(fields[2]);
} catch {
return undefined;
}
}
/** Fail closed to direct-PID signaling when group ownership cannot be proved. */
function isProcessGroupLeader(pid: number): boolean {
// Linux exposes the fact in procfs; avoid a synchronous child process on the common path.
const procPgid = process.platform === "linux" ? readProcessGroupIdFromProc(pid) : undefined;
const pgid = procPgid ?? readProcessGroupIdFromPs(pid);
return pgid === pid;
}
function signalProcessTreeUnix(
pid: number,
signal: "SIGTERM" | "SIGKILL",
+2 -2
View File
@@ -313,7 +313,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
const onAbort = () => {
if (child?.pid) {
killProcessTree(child.pid, { force: true });
killProcessTree(child.pid, { force: true, detached: true });
}
};
@@ -354,7 +354,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
: setTimeout(() => {
timedOut = true;
if (child?.pid) {
killProcessTree(child.pid, { force: true });
killProcessTree(child.pid, { force: true, detached: true });
}
}, timeoutMs);