Files
orca/src/shared/process-table-snapshot.ts
T
Neil 8d8b9dad78 fix: keep macOS shell ownership proof within recovery budget (#18932)
* fix: keep macOS shell ownership proof within recovery budget

* fix: parse the shell-proof column set with its own anchored parser

The narrower macOS capture (`pid ppid pgid tpgid stat command`) was fed to the
shared lenient parser, whose optional tty/start pair has no `tty=` column left
to absorb it. It then eats the head of any argv shaped `python 3 app.py`
(parsing command as `app.py`, tty as `/usr/bin/python`), and turns a
command-less row into a garbage pid/stat pair. Either can flip a shell
ownership verdict, which is what gates dead-TUI recovery.

Give the column set a named constant and a parser anchored to exactly those
six columns, beside its `CHEAP_PS_ARGS` sibling. A capture that yields no rows
now raises `empty_capture` rather than reading as a machine with no processes.

Update the `confirmShellForegroundProcess` fixtures from the 4-column legacy
shape to the 6 columns the darwin reader actually emits; that describe block
already forces `platform=darwin`, so the stale fixtures were failing.
2026-09-06 18:28:54 -07:00

283 lines
10 KiB
TypeScript

export type ProcessTableRow = {
pid: number
ppid: number
/** Process group id. Optional only on rows produced by the legacy parser input shape. */
pgid?: number
/** Terminal foreground process group id (`0`/`-1` means no controlling tty). */
tpgid?: number
/** Controlling terminal name, when the host process table provides it. */
tty?: string
/** Opaque host process start marker (Linux /proc ticks or host ps marker). */
startTime?: string
stat: string
command: string
}
// Why guarded: this module is the renderer-safe half of the process-table pair, and the renderer
// runs sandboxed with contextIsolation, where a bare `process` read throws at module evaluation
// and takes the whole chunk — and the app — down with it. Only hosts ever run these argv.
const HOST_IS_DARWIN = typeof process !== 'undefined' && process.platform === 'darwin'
/** Columns used by the evidence reader. Keep command last so its spaces survive parsing. */
export const PS_ARGS = (
HOST_IS_DARWIN
? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command=']
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,tty=,etimes=,command=']
) as readonly string[]
/**
* Cheap tier: the same job-control columns without `tty=` (0.29s of the 0.34s on a
* 1,900-process Mac) or `command=` (per-pid argv read, 1.15s on Linux). Enough to prove a
* pane's subtree is unchanged since the last full capture; never enough to name a process.
* No `etimes=` on Linux: it is elapsed seconds, so it changes every tick; the stable start
* marker comes from `/proc/<pid>/stat` for the pane subtree only.
*/
export const CHEAP_PS_ARGS = (
HOST_IS_DARWIN
? ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,lstart=']
: ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=']
) as readonly string[]
/**
* Shell-proof tier: job control plus argv, dropping only the columns the shell predicate never
* reads — macOS `tty=` (0.29s of the 0.34s on a 1,900-process Mac) and the start marker. Enough
* to name a pane's foreground process; never enough to correlate a pid across captures.
*/
export const SHELL_FOREGROUND_PS_ARGS = [
'-axo',
'pid=,ppid=,pgid=,tpgid=,stat=,command='
] as readonly string[]
/**
* Parse a {@link SHELL_FOREGROUND_PS_ARGS} capture, anchored to exactly those columns.
* Not {@link parseProcessTableRows}: with no `tty=` to absorb it, that parser's optional
* tty/start pair eats the head of an argv shaped `python 3 app.py`, and a command-less zombie
* row parses into a garbage pid/stat pair.
*
* Lenient per row like its siblings, but a capture yielding none is unreadable rather than a
* machine with no processes: the shell proof must not read that as "the shell is gone".
*/
export function parseShellForegroundRows(stdout: string): ProcessTableRow[] {
const rows: ProcessTableRow[] = []
for (const rawLine of stdout.split(/\r?\n/)) {
const match = rawLine.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(.+)$/)
const pid = match ? Number(match[1]) : 0
if (match && Number.isSafeInteger(pid) && pid > 0) {
rows.push({
pid,
ppid: Number(match[2]),
pgid: Number(match[3]),
tpgid: Number(match[4]),
stat: match[5],
command: match[6]
})
}
}
if (rows.length === 0) {
throw new ProcessTableCaptureError('empty_capture')
}
return rows
}
export type CheapProcessTableRow = {
pid: number
ppid: number
pgid: number
tpgid: number
stat: string
/** Host start marker when the column set carries one (macOS `lstart`). */
startTime?: string
}
/**
* Parse a {@link CHEAP_PS_ARGS} capture. Lenient on purpose: a dropped row can only make a
* fingerprint DIFFER from the strict full-capture one, which escalates to the full capture --
* the safe direction. An empty capture is unreadable, not "no processes".
*/
export function parseCheapProcessTableRows(stdout: string): CheapProcessTableRow[] {
const rows: CheapProcessTableRow[] = []
for (const rawLine of stdout.split(/\r?\n/)) {
const match = rawLine.trim().match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)(?:\s+(.+?))?$/)
if (!match) {
continue
}
const pid = Number(match[1])
if (!Number.isSafeInteger(pid) || pid <= 0) {
continue
}
rows.push({
pid,
ppid: Number(match[2]),
pgid: Number(match[3]),
tpgid: Number(match[4]),
stat: match[5],
...(match[6] !== undefined ? { startTime: match[6] } : {})
})
}
if (rows.length === 0) {
throw new ProcessTableCaptureError('empty_capture')
}
return rows
}
// Why: execFile's 1MB default leaves ~3x headroom (326KB / 1,460 processes, and
// a single 5KB argv row is ordinary), so a busy host overflows it and then EVERY
// capture fails — a readable process table degrading into permanent
// "unverifiable". Matches the sibling reader in pty-descendant-termination.ts.
export const PS_MAX_BUFFER_BYTES = 32 * 1024 * 1024
/** How much older than its own await a TTL-cached capture may be, on top of the capture's own
* duration. Reported ages carry both, so this alone is not the staleness bound.
*
* Why here and not beside the reader that applies it: the renderer's cadence scheduler pulls a
* pane's next poll forward by at most this much, and the reader is a `node:child_process` module
* the renderer must never reach. */
export const PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS = 500
/**
* Parse legacy or evidence-shaped `ps` output into rows. Tolerates CRLF so a
* snapshot parsed on any host stays correct; `command` (last field) keeps its
* internal spaces because the regex is anchored and greedy on the tail.
*/
export function parseProcessTableRows(stdout: string): ProcessTableRow[] {
const rows: ProcessTableRow[] = []
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim()
const macStartMatch = trimmed.match(
/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(\S+\s+\S+\s+\d{1,2}\s+\S+\s+\d{4})\s+(.+)$/
)
if (macStartMatch) {
rows.push({
pid: Number(macStartMatch[1]),
ppid: Number(macStartMatch[2]),
pgid: Number(macStartMatch[3]),
tpgid: Number(macStartMatch[4]),
stat: macStartMatch[5],
tty: macStartMatch[6],
startTime: macStartMatch[7],
command: macStartMatch[8]
})
continue
}
const evidenceMatch = trimmed.match(
/^(\d+)\s+(\d+)\s+(?:(-?\d+)\s+(-?\d+)\s+)?(\S+)(?:\s+(\S+)\s+(\d+))?\s+(.+)$/
)
if (evidenceMatch) {
rows.push({
pid: Number(evidenceMatch[1]),
ppid: Number(evidenceMatch[2]),
...(evidenceMatch[3] !== undefined
? { pgid: Number(evidenceMatch[3]), tpgid: Number(evidenceMatch[4]) }
: {}),
stat: evidenceMatch[5] ?? evidenceMatch[3],
...(evidenceMatch[7] !== undefined
? { tty: evidenceMatch[6], startTime: evidenceMatch[7] }
: {}),
command: evidenceMatch[8] ?? evidenceMatch[6] ?? evidenceMatch[4]
} as ProcessTableRow)
continue
}
const legacyMatch = trimmed.match(
/^((?:\d+)\s+(?:\d+)\s+)(?:(-?\d+)\s+(-?\d+)\s+)?(\S+)\s+(.+)$/
)
if (legacyMatch) {
rows.push({
pid: Number(legacyMatch[1].trim().split(/\s+/)[0]),
ppid: Number(legacyMatch[1].trim().split(/\s+/)[1]),
...(legacyMatch[2] !== undefined
? { pgid: Number(legacyMatch[2]), tpgid: Number(legacyMatch[3]) }
: {}),
stat: legacyMatch[4],
command: legacyMatch[5]
} as ProcessTableRow)
}
}
return rows
}
export class ProcessTableCaptureError extends Error {
readonly code = 'process_table_unreadable'
constructor(readonly reason: string) {
super(`process table unreadable: ${reason}`)
this.name = 'ProcessTableCaptureError'
}
}
/**
* Parse a process-table capture for identity evidence. Unlike the historical
* parser above, every non-framing line must be valid: silently dropping one row
* could turn a truncated table into a false empty/no-agent result.
*
* Linux kernel roots legitimately report `ppid=0`, `pgid=0`, and
* `tpgid=-1`; user-space processes can also report `tpgid=0`/`-1` when no
* controlling TTY is attached. The parser therefore rejects only values
* outside the process-table domain (`pid <= 0`, `ppid < 0`, `pgid < 0`, or
* `tpgid < -1`), while retaining strict row framing and non-empty fields;
* an empty/header-only capture is unreadable as well.
*/
export function parseStrictProcessTableRows(stdout: string): ProcessTableRow[] {
const rows: ProcessTableRow[] = []
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) {
continue
}
if (/^PID\s+PPID\s+PGID\s+TPGID\s+STAT\s+COMMAND$/i.test(line)) {
continue
}
const macStartMatch = line.match(
/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)\s+(\S+)\s+(\S+\s+\S+\s+\d{1,2}\s+\S+\s+\d{4})\s+(.+)$/
)
const numericMatch = macStartMatch
? null
: line.match(/^(\d+)\s+(\d+)\s+(-?\d+)\s+(-?\d+)\s+(\S+)(?:\s+(\S+)\s+(\d+))?\s+(.+)$/)
if (!numericMatch && !macStartMatch) {
throw new ProcessTableCaptureError('malformed_row')
}
const match = numericMatch ?? macStartMatch!
const pid = Number(match[1])
const ppid = Number(match[2])
const pgid = Number(match[3])
const tpgid = Number(match[4])
if (
!Number.isSafeInteger(pid) ||
pid <= 0 ||
!Number.isSafeInteger(ppid) ||
ppid < 0 ||
!Number.isSafeInteger(pgid) ||
pgid < 0 ||
!Number.isSafeInteger(tpgid) ||
(tpgid < 0 && tpgid !== -1) ||
(match[8] ?? match[6]).length === 0
) {
throw new ProcessTableCaptureError('invalid_numeric_field')
}
rows.push({
pid,
ppid,
pgid,
tpgid,
stat: match[5],
...(numericMatch && match[7] !== undefined
? { tty: match[6], startTime: match[7] }
: macStartMatch
? { tty: match[6], startTime: match[7] }
: {}),
command: numericMatch ? (match[8] ?? match[6]) : match[8]
})
}
if (rows.length === 0) {
throw new ProcessTableCaptureError('empty_capture')
}
return rows
}
/**
* Rank a descendant row as a foreground candidate: a `+` (foreground process
* group) row always outranks a background one, then the deepest wins.
*/
export function scoreForegroundCandidateRow(row: ProcessTableRow & { depth: number }): number {
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}