11// Agent Core module implements kill tree behavior.
2- import { spawn } from "node:child_process" ;
2+ import { spawn , spawnSync } from "node:child_process" ;
3+ import { readFileSync } from "node:fs" ;
34
45const DEFAULT_GRACE_MS = 3000 ;
56const MAX_GRACE_MS = 60_000 ;
@@ -16,9 +17,14 @@ export type KillProcessTreeOptions = {
1617 * first (without /F), then force-kills if process survives.
1718 * - Unix: send SIGTERM to process group first, wait grace period, then SIGKILL.
1819 *
19- * When the child was spawned with `detached: false`, pass `detached: false` to
20- * skip the Unix `process.kill(-pid, ...)` group-kill. That avoids signaling the
21- * gateway's own process group.
20+ * Group kill (`process.kill(-pid, ...)`) is only used when the PID is verified
21+ * as its own process group leader, unless `detached: true` is explicitly passed.
22+ * This prevents accidentally signaling the gateway's process group when the
23+ * child shares its parent's group.
24+ *
25+ * - `detached: false`: skip group kill unconditionally.
26+ * - `detached: true`: use group kill unconditionally (trust caller).
27+ * - `detached` omitted: use group kill only when PID is the group leader.
2228 */
2329export function killProcessTree ( pid : number , opts ?: KillProcessTreeOptions ) : void {
2430 if ( ! Number . isFinite ( pid ) || pid <= 0 ) {
@@ -35,7 +41,8 @@ export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): voi
3541 return ;
3642 }
3743
38- const useGroupKill = opts ?. detached !== false ;
44+ const useGroupKill =
45+ opts ?. detached === true || ( opts ?. detached !== false && isProcessGroupLeader ( pid ) ) ;
3946 if ( opts ?. force === true ) {
4047 signalProcessTreeUnix ( pid , "SIGKILL" , useGroupKill ) ;
4148 return ;
@@ -68,7 +75,9 @@ export function signalProcessTree(
6875 return ;
6976 }
7077
71- signalProcessTreeUnix ( pid , signal , opts ?. detached !== false ) ;
78+ const useGroupKill =
79+ opts ?. detached === true || ( opts ?. detached !== false && isProcessGroupLeader ( pid ) ) ;
80+ signalProcessTreeUnix ( pid , signal , useGroupKill ) ;
7281}
7382
7483function normalizeGraceMs ( value ?: number ) : number {
@@ -87,6 +96,70 @@ function isProcessAlive(pid: number): boolean {
8796 }
8897}
8998
99+ /**
100+ * Check whether a PID is its own process group leader.
101+ * Uses `ps -p <pid> -o pgid=` to read the process group ID and compares it
102+ * to the PID. Falls back to `false` on any error, which safely skips group
103+ * kill in environments where `ps` is unavailable.
104+ *
105+ * Linux fallback: if ps is unavailable (ENOENT), attempts to read
106+ * /proc/<pid>/stat field 5 (pgid) as a secondary check.
107+ */
108+ function isProcessGroupLeader ( pid : number ) : boolean {
109+ try {
110+ const res = spawnSync ( "ps" , [ "-p" , String ( pid ) , "-o" , "pgid=" ] , {
111+ encoding : "utf8" ,
112+ timeout : 500 ,
113+ } ) ;
114+ if ( ! res . error && res . status === 0 ) {
115+ const pgid = Number . parseInt ( res . stdout . trim ( ) , 10 ) ;
116+ if ( Number . isFinite ( pgid ) && pgid === pid ) {
117+ return true ;
118+ }
119+ }
120+ } catch {
121+ // ps failed, fall through to secondary check
122+ }
123+
124+ // Secondary: /proc/<pid>/stat (Linux only). No-op on macOS/Windows.
125+ if ( process . platform === "linux" ) {
126+ return isProcessGroupLeaderFromProc ( pid ) ;
127+ }
128+ return false ;
129+ }
130+
131+ /**
132+ * Linux-only fallback: read /proc/<pid>/stat field 5 (pgid) to check
133+ * whether the process is its own process group leader.
134+ */
135+ function isProcessGroupLeaderFromProc ( pid : number ) : boolean {
136+ try {
137+ const stat = readFileSync ( `/proc/${ pid } /stat` , { encoding : "utf8" } ) ;
138+ // /proc/<pid>/stat format: pid (comm) state ppid pgrp ...
139+ // Field 5 (1-indexed) is pgrp (process group ID)
140+ const fields = stat . split ( " " ) ;
141+ if ( fields . length >= 5 ) {
142+ // The 5th field is the process group ID (pgrp)
143+ // Format is "pid (comm) state ppid pgrp session tty_nr ..."
144+ // We need to find the actual 5th field considering parentheses in comm
145+ // Find the last ')' to reliably locate field 5
146+ const lastParenIdx = stat . lastIndexOf ( ")" ) ;
147+ if ( lastParenIdx > 0 ) {
148+ const afterComm = stat . slice ( lastParenIdx + 1 ) . trimStart ( ) ;
149+ const parts = afterComm . split ( / \s + / ) ;
150+ if ( parts . length >= 3 ) {
151+ // parts[0] = state, parts[1] = ppid, parts[2] = pgrp
152+ const pgid = Number . parseInt ( parts [ 2 ] ?? "" , 10 ) ;
153+ return Number . isFinite ( pgid ) && pgid === pid ;
154+ }
155+ }
156+ }
157+ return false ;
158+ } catch {
159+ return false ;
160+ }
161+ }
162+
90163function signalProcessTreeUnix (
91164 pid : number ,
92165 signal : "SIGTERM" | "SIGKILL" ,
0 commit comments