brl-subagent 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proc.ts ADDED
@@ -0,0 +1,390 @@
1
+ // Purpose: Production process primitives — Linux/POSIX /proc reads, feature detection, and the default recovery deps.
2
+ /**
3
+ * Option B U1 — production process wiring for the recovery engine.
4
+ *
5
+ * The recovery decision engine (`src/recovery-engine.ts`) is pure over the
6
+ * injected `RecoveryDeps`. This module supplies the REAL deps: the Linux/POSIX
7
+ * `/proc` start-time token (`/proc/<pid>/stat` field 22), the child-marker scan
8
+ * (`/proc/<pid>/environ`), cross-platform pid liveness, and the default
9
+ * `RecoveryDeps` assembly.
10
+ *
11
+ * PLATFORM BOUNDARY (required): the start token and marker scan are Linux/POSIX
12
+ * reads. `isProcAvailable()` feature-detects them (cached; the answer cannot
13
+ * change mid-process). Where they are unavailable, `pidAlive` still works but
14
+ * on its own cannot rule out pid reuse — so the boot scan skips entirely and
15
+ * nothing is killed or marked unverified (`recoverProduction`, src/recovery.ts).
16
+ *
17
+ * `__setProcAvailableForTest` is the injectable seam that lets a test exercise
18
+ * that platform guard without mocking `node:fs` module-wide.
19
+ */
20
+
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import type { ProcessOwner } from "./types";
24
+ import { SIGKILL_GRACE_MS, SIGKILL_POLL_MS } from "./types";
25
+ import { CHILD_MARKER_ENV_KEY } from "./sanitize";
26
+ import type { RecoveryDeps } from "./recovery-engine";
27
+ import { supportsProcessGroupKill } from "./kill-escalation";
28
+
29
+ const PROC_ROOT = "/proc";
30
+
31
+ let procAvailable: boolean | undefined;
32
+ let procAvailableOverride: boolean | undefined;
33
+
34
+ /**
35
+ * TEST-ONLY: force the `/proc` feature-detection answer (undefined restores the
36
+ * real detection and clears the cache). The production scan consults this
37
+ * through `isProcAvailable`, so the platform guard is exercisable in-repo.
38
+ */
39
+ export function __setProcAvailableForTest(value: boolean | undefined): void {
40
+ procAvailableOverride = value;
41
+ procAvailable = undefined;
42
+ }
43
+
44
+ /**
45
+ * Feature-detect the Linux/POSIX `/proc` surface this module needs. Cached:
46
+ * the answer cannot change mid-process. Exported so the entry point can skip
47
+ * the whole scan with one log line on platforms without /proc.
48
+ */
49
+ export function isProcAvailable(): boolean {
50
+ if (procAvailableOverride !== undefined) return procAvailableOverride;
51
+ if (procAvailable === undefined) {
52
+ try {
53
+ procAvailable = fs.existsSync(path.join(PROC_ROOT, "self", "stat"));
54
+ } catch {
55
+ procAvailable = false;
56
+ }
57
+ }
58
+ return procAvailable;
59
+ }
60
+
61
+ /** The outcome of a `/proc/<pid>/stat` read: the fields, or why it failed. */
62
+ type StatFieldsRead =
63
+ | { ok: true; fields: string[] }
64
+ | { ok: false; code: string | undefined };
65
+
66
+ /**
67
+ * The `readStatFields` primitive WITH the failure cause preserved: `code` is the
68
+ * errno of a failed read (`"ENOENT"` when the pid is absent) or undefined when
69
+ * /proc is unavailable, the pid is invalid, or the read failed for a reason
70
+ * other than a missing file. `pidGone` needs the cause to separate "the pid was
71
+ * REAPED" (ENOENT — absence-of-process evidence) from "the read failed and we
72
+ * cannot tell" (everything else).
73
+ */
74
+ function readStatFieldsDetailed(pid: number): StatFieldsRead {
75
+ if (!isProcAvailable() || !Number.isInteger(pid) || pid <= 0) {
76
+ return { ok: false, code: undefined };
77
+ }
78
+ try {
79
+ const stat = fs.readFileSync(path.join(PROC_ROOT, String(pid), "stat"), "utf-8");
80
+ const close = stat.lastIndexOf(")");
81
+ if (close < 0) return { ok: false, code: undefined };
82
+ const rest = stat.slice(close + 1).trim();
83
+ if (!rest) return { ok: false, code: undefined };
84
+ return { ok: true, fields: rest.split(/\s+/) };
85
+ } catch (err) {
86
+ return { ok: false, code: (err as NodeJS.ErrnoException).code };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * The whitespace-split `/proc/<pid>/stat` fields AFTER the `(comm)` field, or
92
+ * undefined when /proc is unavailable, the pid is invalid, or the process
93
+ * vanished. Shared by the start-token, process-group and group-liveness readers.
94
+ * The comm field is skipped by slicing at the LAST `)` so a name containing
95
+ * spaces or parens cannot shift the parse.
96
+ */
97
+ function readStatFields(pid: number): string[] | undefined {
98
+ const read = readStatFieldsDetailed(pid);
99
+ return read.ok ? read.fields : undefined;
100
+ }
101
+
102
+ /**
103
+ * Read a process's start-time token — `/proc/<pid>/stat` field 22 (1-indexed),
104
+ * i.e. index 19 of the whitespace-split fields AFTER the `(comm)` field.
105
+ * Returns undefined when /proc is unavailable, the pid is invalid, or the
106
+ * process vanished.
107
+ */
108
+ export function readStartToken(pid: number): string | undefined {
109
+ const token = readStatFields(pid)?.[19];
110
+ return token && /^\d+$/.test(token) ? token : undefined;
111
+ }
112
+
113
+ /**
114
+ * Read a process's process-group id — `/proc/<pid>/stat` field 5 (index 2 after
115
+ * `(comm)`). Returns undefined when /proc is unavailable, the pid is invalid, or
116
+ * the process vanished.
117
+ */
118
+ export function readProcessGroup(pid: number): number | undefined {
119
+ const pgid = Number(readStatFields(pid)?.[2]);
120
+ return Number.isInteger(pgid) && pgid > 0 ? pgid : undefined;
121
+ }
122
+
123
+ /**
124
+ * #299 fix A.2 — the THREE-state answer to "does process group `pgid` still have
125
+ * a LIVE member?":
126
+ *
127
+ * - `true` — at least one live member was observed.
128
+ * - `false` — a COMPLETED inspection found no live member: POSITIVELY empty.
129
+ * - `undefined` — the inspection was UNAVAILABLE or PARTIAL. Absence of
130
+ * evidence is never evidence of absence, so callers must treat `undefined`
131
+ * as UNKNOWN — never as empty.
132
+ *
133
+ * `/proc` host-level unavailability is the documented platform boundary, not
134
+ * `undefined`: with no `/proc` at all the caller degrades to leader liveness
135
+ * (this returns `false`). `undefined` is reserved for an inspection that FAILED
136
+ * on a `/proc`-capable host — a `/proc` listing failure, or a member's stat read
137
+ * that failed for a reason other than the process being gone (`EACCES`, `EIO`, a
138
+ * parse anomaly). A member that genuinely vanished during the scan is skipped
139
+ * (ENOENT — it cannot be a live member).
140
+ */
141
+ export type GroupMembership = boolean | undefined;
142
+
143
+ /**
144
+ * #299 fix A.4 — the kernel's process-state letters, i.e. the valid values of
145
+ * field 3 of `/proc/<pid>/stat`. A state token OUTSIDE this set means the
146
+ * record was mis-parsed (a truncated or field-shifted read), so the inspection
147
+ * is PARTIAL rather than a positive non-membership. The set is the union of the
148
+ * Linux `task_state_array`: R (running), S (sleeping), D (uninterruptible
149
+ * sleep), Z (zombie), T (stopped), t (tracing stop), X/x (dead), K (wakekill),
150
+ * W (waking, pre-3.8), P (parked), I (idle).
151
+ */
152
+ const PROCESS_STATE_LETTERS = new Set(["R", "S", "D", "Z", "T", "t", "X", "x", "K", "W", "P", "I"]);
153
+
154
+ /**
155
+ * The kernel's `PID_MAX_LIMIT` on 64-bit Linux. A pgrp above this is not a real
156
+ * process-group id, so a record carrying one is mis-parsed — the inspection is
157
+ * PARTIAL, not a positive non-membership. `pid_max` can be configured lower; a
158
+ * value above the kernel CEILING can never be genuine at all.
159
+ */
160
+ const PID_MAX_LIMIT = 4_194_304;
161
+
162
+ /**
163
+ * #299 fix A.5 — the SINGLE source of the `/proc/<pid>/stat` PREFIX contract,
164
+ * shared by every group-membership read. Given the whitespace-split fields after
165
+ * `(comm)`, it returns `{ state, pgrp }` ONLY when the record is WELL-FORMED
166
+ * enough to trust, and `undefined` (malformed/truncated/shifted) otherwise.
167
+ *
168
+ * The contract is deliberately strict, because `groupHasMembers` turns a clean
169
+ * scan into the actionable claim "the group is POSITIVELY empty" (an escalation
170
+ * early-exit and a clean survivor report). Every field the prefix relies on is
171
+ * therefore validated by SHAPE and ALIGNMENT:
172
+ *
173
+ * - `fields[0]` (state) must be a kernel process-state letter — numerics,
174
+ * punctuation, empty and multi-char tokens are all malformed.
175
+ * - `fields[1]` (ppid) must be a non-negative integer — this is the ALIGNMENT
176
+ * check. A valid state followed by a non-numeric ppid (`R S 1 9999`) is a
177
+ * SHIFTED record whose "pgrp" slot happens to hold a real integer; trusting
178
+ * it would read a malformed record as a justified non-member.
179
+ * - `fields[2]` (pgrp) must be an integer in `[0, PID_MAX_LIMIT]` — negatives
180
+ * and oversized values are impossible pgrps. `0` is LEGAL: kernel threads
181
+ * carry pgrp 0 and must not taint an otherwise-clean scan.
182
+ */
183
+ function parseStatPrefix(fields: string[]): { state: string; pgrp: number } | undefined {
184
+ const state = fields[0];
185
+ const ppid = fields[1];
186
+ const pgrpField = fields[2];
187
+ if (state === undefined || !PROCESS_STATE_LETTERS.has(state)) return undefined;
188
+ // Strict decimal shape: `Number("1e3")`/`Number("0x10")`/`Number("")` would
189
+ // otherwise sneak through as integers, so validate the TOKEN, not the cast.
190
+ if (ppid === undefined || !/^\d+$/.test(ppid)) return undefined;
191
+ if (pgrpField === undefined || !/^\d+$/.test(pgrpField)) return undefined;
192
+ const pgrp = Number(pgrpField);
193
+ if (pgrp > PID_MAX_LIMIT) return undefined;
194
+ return { state, pgrp };
195
+ }
196
+
197
+ /** The states whose processes are NOT live group members (an unreaped corpse
198
+ * cannot hold a group non-empty): `Z` (zombie), `X`/`x` (dead). Only ever
199
+ * consulted for a record that `parseStatPrefix` already accepted. */
200
+ const NON_MEMBER_STATES = new Set(["Z", "X", "x"]);
201
+
202
+ /**
203
+ * Scans `/proc` for processes whose pgrp (field 5) equals `pgid`, skipping the
204
+ * leader itself and non-live states (`Z`/`X`/`x` — an unreaped corpse is not a
205
+ * live member and must not hold the group non-empty). A group's leader exiting
206
+ * does NOT empty the group, so this is what lets `escalateKill` keep waiting for
207
+ * a member's cleanup window.
208
+ *
209
+ * The return contract is EXACT (see `GroupMembership`): `false` may be returned
210
+ * ONLY after a COMPLETE scan in which every record parsed as a well-formed
211
+ * prefix. Any unparseable record taints the scan to `undefined` (unknown) — the
212
+ * check lives in `parseStatPrefix`, so there is no fall-through that can turn
213
+ * malformed input into a positively-empty `false`.
214
+ */
215
+ export function groupHasMembers(pgid: number): GroupMembership {
216
+ // Platform boundary: no /proc at all → the caller falls back to leader
217
+ // liveness (documented non-Linux boundary). Same for an invalid pgid.
218
+ if (!Number.isInteger(pgid) || pgid <= 0 || !isProcAvailable()) return false;
219
+ let entries: string[];
220
+ try {
221
+ entries = fs.readdirSync(PROC_ROOT);
222
+ } catch {
223
+ // A listing failure on a /proc-capable host leaves the scan PARTIAL: we
224
+ // cannot tell whether a member exists, so the answer is unknown.
225
+ return undefined;
226
+ }
227
+ for (const entry of entries) {
228
+ if (!/^\d+$/.test(entry)) continue;
229
+ const pid = Number(entry);
230
+ if (pid === pgid) continue; // the leader (possibly a zombie) is not a member
231
+ const read = readStatFieldsDetailed(pid);
232
+ if (!read.ok) {
233
+ // A member gone mid-scan is skippable (it cannot be a live member); any
234
+ // OTHER failure makes the inspection partial → unknown.
235
+ if (read.code === "ENOENT") continue;
236
+ return undefined;
237
+ }
238
+ // #299 fix A.5: ONE strict prefix validation. A truncated, shifted or
239
+ // otherwise malformed record (too few fields, non-letter state, non-numeric
240
+ // ppid, impossible pgrp) makes the inspection PARTIAL → unknown, never a
241
+ // positively-empty `false`. Well-formed dead states are skipped; a live
242
+ // member is a match.
243
+ const parsed = parseStatPrefix(read.fields);
244
+ if (parsed === undefined) return undefined;
245
+ if (NON_MEMBER_STATES.has(parsed.state)) continue;
246
+ if (parsed.pgrp === pgid) return true;
247
+ }
248
+ return false;
249
+ }
250
+
251
+ /**
252
+ * #299 review Major 1 — signal-time identity re-check. True only when the LIVE
253
+ * process `pid` carries `CHILD_MARKER_ENV_KEY=<marker>` in its environment.
254
+ * `findByMarker` proves the pid was ours at SCAN time; this proves it is ours at
255
+ * the SIGNAL time, closing the scan→signal pid-reuse window (a pid that exited
256
+ * and was reused by a foreign process no longer carries the marker). Returns
257
+ * false when /proc is unavailable, the pid is invalid, or the process vanished.
258
+ *
259
+ * RESIDUAL: this narrows the reuse window to the read→`kill` syscall boundary —
260
+ * the same exposure the direct signals have carried since Option A. It does NOT
261
+ * eliminate it (the kernel may reuse the pid between this read and the signal).
262
+ */
263
+ export function pidHasMarker(pid: number, marker: string): boolean {
264
+ if (!marker || !isProcAvailable() || !Number.isInteger(pid) || pid <= 0) return false;
265
+ const needle = `${CHILD_MARKER_ENV_KEY}=${marker}`;
266
+ try {
267
+ const environ = fs.readFileSync(path.join(PROC_ROOT, String(pid), "environ"), "utf-8");
268
+ return environ.split("\0").includes(needle);
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ /** The CURRENT process's owner identity (pid + start token; empty string when unavailable). */
275
+ export function currentProcessOwner(): ProcessOwner {
276
+ return { pid: process.pid, start: readStartToken(process.pid) ?? "" };
277
+ }
278
+
279
+ /**
280
+ * #299 D4: the ONE production process-group signal (`kill(-pgid, sig)`), shared
281
+ * by the recovery deps and `runner.ts`'s child/marker group targets. ESRCH means
282
+ * the group is already empty — the desired end state, so it is swallowed; every
283
+ * other errno is rethrown for the caller's own tolerance. On a platform without
284
+ * process groups neither caller wires this in (`supportsProcessGroupKill`).
285
+ */
286
+ export function killProcessGroup(pgid: number, signal: NodeJS.Signals): void {
287
+ try {
288
+ process.kill(-pgid, signal);
289
+ } catch (err) {
290
+ if ((err as NodeJS.ErrnoException).code !== "ESRCH") throw err;
291
+ }
292
+ }
293
+
294
+ /** Cross-platform pid liveness (`kill -0`). EPERM means the process exists. */
295
+ export function pidAlive(pid: number): boolean {
296
+ if (!Number.isInteger(pid) || pid <= 0) return false;
297
+ try {
298
+ process.kill(pid, 0);
299
+ return true;
300
+ } catch (err) {
301
+ return (err as NodeJS.ErrnoException).code === "EPERM";
302
+ }
303
+ }
304
+
305
+ /**
306
+ * #299 review Major 1: true when the pid is GONE — it no longer exists OR is a
307
+ * not-yet-reaped zombie (`Z`/`X`), which `pidAlive` (kill -0) still reports as
308
+ * alive. The signal-time guard uses this to separate a REUSED pid (alive, marker
309
+ * gone) from a merely dead/zombie one, so a zombie leader's pgid is still
310
+ * signaled to reach its surviving members (D5). Without /proc the zombie
311
+ * distinction is unavailable and this degrades to `!pidAlive`.
312
+ *
313
+ * #299 re-review A.1: a POSITIVE liveness sample immediately followed by ENOENT
314
+ * on `/proc/<pid>/stat` means the pid was REAPED between the two reads — it is
315
+ * GONE. `/proc/<pid>/stat` is readable for any process that still exists (a
316
+ * not-yet-reaped zombie included), so ENOENT is absence-of-process evidence;
317
+ * classifying it as "not gone" made the guard treat our own reaped leader as a
318
+ * live FOREIGN pid and refuse BOTH the group SIGTERM and the final group
319
+ * SIGKILL while its group still had live members. Every OTHER read failure
320
+ * (EACCES, a parse anomaly, /proc unavailable) stays conservatively not-gone.
321
+ *
322
+ * PLATFORM BOUNDARY (hidepid=2): on a `/proc` mounted with `hidepid=2` a foreign
323
+ * LIVE pid we do not own can read `kill -0` EPERM (alive) and then
324
+ * `/proc/<pid>/stat` ENOENT (hidden — it looks GONE). The signal guard may then
325
+ * ATTEMPT a group signal despite the marker mismatch. This is a documented
326
+ * platform boundary, NOT a signal-safety guarantee: the kernel's EPERM on the
327
+ * signal constrains the unprivileged blast radius, and the group signal still
328
+ * targets only the pgid we derived (it does not widen the target set).
329
+ */
330
+ export function pidGone(pid: number): boolean {
331
+ if (!pidAlive(pid)) return true;
332
+ const read = readStatFieldsDetailed(pid);
333
+ if (!read.ok) return read.code === "ENOENT";
334
+ const state = read.fields[0];
335
+ return state === "Z" || state === "X";
336
+ }
337
+
338
+ /**
339
+ * Pids whose environment carries the marker. Scans `/proc/<pid>/environ` (NUL
340
+ * separated). Skips this process; unreadable/vanished processes are ignored.
341
+ * Returns [] — never a kill — when /proc is unavailable.
342
+ */
343
+ export function findByMarker(marker: string): number[] {
344
+ if (!marker || !isProcAvailable()) return [];
345
+ const found: number[] = [];
346
+ let entries: string[];
347
+ try {
348
+ entries = fs.readdirSync(PROC_ROOT);
349
+ } catch {
350
+ return [];
351
+ }
352
+ for (const entry of entries) {
353
+ if (!/^\d+$/.test(entry)) continue;
354
+ const pid = Number(entry);
355
+ if (pid === process.pid) continue;
356
+ if (pidHasMarker(pid, marker)) found.push(pid);
357
+ }
358
+ return found;
359
+ }
360
+
361
+ /** The real production dependencies (Linux/POSIX /proc reads + process signals). */
362
+ export function defaultRecoveryDeps(): RecoveryDeps {
363
+ return {
364
+ pidAlive,
365
+ startTokenOf: readStartToken,
366
+ findByMarker,
367
+ kill: (pid, signal) => {
368
+ process.kill(pid, signal);
369
+ },
370
+ // D4 (#299): group-reaching targets on POSIX. The marker identifies the
371
+ // orphan tree; the process group is only the kill mechanism that reaches
372
+ // env-scrubbed descendants. Omitted on Windows (D6) so `reapPids` keeps
373
+ // today's direct pid targets.
374
+ killGroup: supportsProcessGroupKill() ? killProcessGroup : undefined,
375
+ // #299 review Major 2: a group's leader exiting does not empty the group,
376
+ // so the group target's death check counts live members.
377
+ groupHasMembers,
378
+ // #299 review Major 1: signal-time marker re-check for marker-derived
379
+ // targets (closes the scan→signal pid-reuse window).
380
+ verifyMarker: pidHasMarker,
381
+ // #299 review Major 1: the guard separates a reused pid (alive, marker
382
+ // gone) from a dead/zombie one, so a zombie leader's group is still killed.
383
+ pidGone,
384
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
385
+ graceMs: SIGKILL_GRACE_MS,
386
+ // Item 4 (#299): poll so the boot scan returns as soon as its orphans die
387
+ // instead of always blocking the full grace window (a ~5 s session_start).
388
+ pollMs: SIGKILL_POLL_MS,
389
+ };
390
+ }