mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* 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.
283 lines
10 KiB
TypeScript
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
|
|
}
|