diff --git a/src/cli/index.ts b/src/cli/index.ts index d1439754154..eff70dd94bb 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -69,6 +69,7 @@ import { writePid, writeRuntimePort, } from "../config/process-state"; +import { describeTrackedRuntimeTarget } from "../config/tracked-runtime-inspection"; import { claimPendingTeardown, isPendingTeardownAbandoned, @@ -1020,13 +1021,7 @@ async function handleStop(approval?: StopApproval) { try { return JSON.parse(lines[0]!) as ResolveJson; } catch { return null; } }, - () => { - const pid = readPid(); - const runtime = pid === null ? null : readRuntimePort(pid); - return pid && runtime?.port ? { - pid, port: runtime.port, hostname: runtime.hostname ?? "", - } : null; - }, + describeTrackedRuntimeTarget, () => inspectGuardedManagerTarget(approval.pid, approval.port), snapshot => handleStopUnlocked(snapshot), ); diff --git a/src/cli/stop-approval.ts b/src/cli/stop-approval.ts index 4b83fef6169..5a68879e8d5 100644 --- a/src/cli/stop-approval.ts +++ b/src/cli/stop-approval.ts @@ -20,6 +20,17 @@ export interface GuardedStopSnapshot { manager: Exclude; } +/** + * What the tracked-record check can report: the verified target, null when the + * caller cannot name a cause (the pre-diagnostics shape), or the fixed cause of + * the single check that failed. The cause is a fixed sentence fragment from the + * inspection probe -- it carries no path, command line, stderr, or token. + */ +export type TrackedRuntimeCheck = + | { pid: number; port: number; hostname: string } + | { untracked: string } + | null; + function sameGuardedManager( expected: GuardedStopSnapshot["manager"], current: GuardedManagerTarget, @@ -117,7 +128,7 @@ export function managerStillActive( export async function runApprovedStop( expected: StopApproval, read: () => Promise, - tracked: () => { pid: number; port: number; hostname: string } | null, + tracked: () => TrackedRuntimeCheck, managerTarget: () => GuardedManagerTarget, stop: (snapshot: GuardedStopSnapshot) => Promise, ): Promise { @@ -130,6 +141,9 @@ export async function runApprovedStop( const mismatch = stopApprovalMismatch(expected, now); if (mismatch !== null) return approvalChanged(mismatch); if (target === null) return approvalChanged("the tracked runtime record could not be verified"); + if ("untracked" in target) { + return approvalChanged(`the tracked runtime record could not be verified (${target.untracked})`); + } if (target.pid !== expected.pid || target.port !== expected.port || target.hostname !== expected.hostname) { return approvalChanged("the tracked runtime record changed"); diff --git a/src/config/process-state.ts b/src/config/process-state.ts index f61f714b8d9..4ac5191e57c 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -45,7 +45,7 @@ export type RuntimePortState = { siblingOfPort?: number; }; -function isValidRuntimePortState(value: unknown): value is RuntimePortState { +export function isValidRuntimePortState(value: unknown): value is RuntimePortState { if (!value || typeof value !== "object") return false; const state = value as Record; const hostnameOk = state.hostname === undefined || typeof state.hostname === "string"; @@ -201,14 +201,32 @@ export function ocxStartProcessCacheSizeForTests(): number { return ocxStartProcessCache.size; } -function isLikelyOcxStartProcess(pid: number): boolean { +/** + * One observation of whether a candidate PID is an `ocx start` process. + * + * `readPid()` and `verifyPidIdentity()` ask this on every short liveness poll, and the + * read-only tracked-record inspection in `tracked-runtime-inspection.ts` asks it once per + * guarded stop, so all of them share this single cache-backed answer rather than probe + * twice: a hit performs no native command at all, and a miss performs exactly one + * {@link readProcessCommandLine} probe. Only a definite answer is cached; an unreadable + * command line is reported as `"unverifiable"`, left uncached, and re-probed on the next + * ask. `"unverifiable"` is not evidence of a permission boundary, a foreign process, or a + * different account or elevation — only that the probe could not answer this time. + */ +export type OcxStartIdentityObservation = "match" | "mismatch" | "unverifiable"; + +export function inspectOcxStartProcessIdentity(pid: number): OcxStartIdentityObservation { const cached = ocxStartProcessCache.get(pid); - if (cached !== undefined) return cached; + if (cached !== undefined) return cached ? "match" : "mismatch"; const commandLine = readProcessCommandLine(pid); - if (commandLine === undefined) return false; + if (commandLine === undefined) return "unverifiable"; const ok = isOcxStartCommandLine(commandLine); ocxStartProcessCache.set(pid, ok); - return ok; + return ok ? "match" : "mismatch"; +} + +function isLikelyOcxStartProcess(pid: number): boolean { + return inspectOcxStartProcessIdentity(pid) === "match"; } export function readPid(): number | null { diff --git a/src/config/tracked-runtime-inspection.ts b/src/config/tracked-runtime-inspection.ts new file mode 100644 index 00000000000..6cc241e5f19 --- /dev/null +++ b/src/config/tracked-runtime-inspection.ts @@ -0,0 +1,188 @@ +/** + * Read-only classification of the tracked runtime record pair a guarded stop + * re-verifies before acting. + * + * "ocx stop --expect-*" asks "is the recorded runtime still the approved one", + * which collapses several observations into one boolean: a missing or invalid + * "ocx.pid", a dead, foreign or unverifiable recorded process, and a missing, + * invalid or PID-mismatched "runtime-port.json" all read as "not verified". + * The approval-changed refusal used to report only that umbrella fact, so a + * desktop log could not tell a stale record from a permission failure. + * + * This probe walks the same checks in the same order as "readPid()" followed + * by "readRuntimePort(pid)", but reports which one failed instead. Each state + * record is still read exactly once per call -- a guarded stop asks once, and + * caching the bytes would only carry a stale answer into a decision about a + * record that may have been rewritten. The process-identity question, though, + * is answered by the same cache-backed observation "readPid()" itself makes + * ("inspectOcxStartProcessIdentity"): a cached verdict serves this probe with + * no native command at all, and a miss performs the same single command-line + * probe readPid would, so this probe can never disagree with the admission + * readPid just granted or denied for the same PID. It performs no mutation, no + * signal beyond the same "process.kill(pid, 0)" liveness read, and no stop + * decision -- the refusal itself is unchanged; only its detail gains a cause. + * + * Two distinctions are deliberate: + * + * - "permission-denied" is reported only for a real EACCES/EPERM observed on a + * record read or the liveness signal. An "unverifiable" identity observation + * -- an unreadable process command line -- is not proof of a permission + * boundary, and no account or elevation cause is inferred from it. + * - No path, command line, stderr text, or token leaves this module: the refusal + * causes below are fixed strings and the detail built from them is a fixed + * sentence. + */ +import { readFileSync } from "node:fs"; +import { + getPidPath, + getRuntimePortPath, + inspectOcxStartProcessIdentity, + isValidRuntimePortState, + parsePidFile, +} from "./process-state"; +import type { OcxStartIdentityObservation } from "./process-state"; + +/** The single check that failed, as a stable machine-readable token. */ +export type TrackedRecordRefusal = + | "pid-record-absent" + | "pid-record-permission-denied" + | "pid-record-unreadable" + | "pid-record-invalid" + | "process-not-running" + | "process-identity-mismatch" + | "process-unverifiable" + | "runtime-record-absent" + | "runtime-record-permission-denied" + | "runtime-record-unreadable" + | "runtime-record-invalid" + | "runtime-record-pid-mismatch" + | "unverified"; + +export type TrackedRecordInspection = + | { status: "verified"; pid: number; port: number; hostname: string } + | { status: "refused"; reason: TrackedRecordRefusal }; + +/** + * Injectable read-only seams. Tests must supply fakes for "signal" and either + * "identity" or the process-state exec seams ("setProcessCommandLineExecForTests" + * and friends) so no real "process.kill" or native command probe runs; the + * defaults are exactly what the production caller uses, including the shared + * identity cache. + */ +export interface TrackedRecordProbeDeps { + /** Read a state record like "readFileSync(path, 'utf-8')"; throw with errno codes. */ + readFile?: (path: string) => string; + /** The "process.kill(pid, 0)" liveness read; throws like it (ESRCH/EPERM/...). */ + signal?: (pid: number) => void; + /** The shared start-identity observation; the default consults the real cache. */ + identity?: (pid: number) => OcxStartIdentityObservation; +} + +function readRecordFile( + readFile: (path: string) => string, + path: string, + absent: TrackedRecordRefusal, + denied: TrackedRecordRefusal, + unreadable: TrackedRecordRefusal, +): { ok: true; raw: string } | { ok: false; reason: TrackedRecordRefusal } { + try { + return { ok: true, raw: readFile(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EACCES" || code === "EPERM") return { ok: false, reason: denied }; + if (code === "ENOENT") return { ok: false, reason: absent }; + return { ok: false, reason: unreadable }; + } +} + +/** + * Classify why the tracked runtime record cannot back a guarded stop, or return + * the verified target when every check still holds. One read per record, in the + * same order the stop path already performs them. + */ +export function inspectTrackedRuntimeRecord( + deps: TrackedRecordProbeDeps = {}, +): TrackedRecordInspection { + const readFile = deps.readFile ?? ((path: string) => readFileSync(path, "utf-8")); + const signal = deps.signal ?? ((pid: number) => { process.kill(pid, 0); }); + const identity = deps.identity ?? inspectOcxStartProcessIdentity; + try { + const pidFile = readRecordFile( + readFile, getPidPath(), "pid-record-absent", "pid-record-permission-denied", "pid-record-unreadable", + ); + if (!pidFile.ok) return { status: "refused", reason: pidFile.reason }; + const pid = parsePidFile(pidFile.raw); + if (pid === null) return { status: "refused", reason: "pid-record-invalid" }; + try { + signal(pid); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return { status: "refused", reason: "process-not-running" }; + // EPERM still names a live process; identity is decided by the shared + // observation below, exactly as readPid decides it. Any other signal + // error can only be reported as unverifiable. + if (code !== "EPERM") return { status: "refused", reason: "process-unverifiable" }; + } + const observation = identity(pid); + if (observation === "unverifiable") { + return { status: "refused", reason: "process-unverifiable" }; + } + if (observation === "mismatch") { + return { status: "refused", reason: "process-identity-mismatch" }; + } + const runtimeFile = readRecordFile( + readFile, getRuntimePortPath(), + "runtime-record-absent", "runtime-record-permission-denied", "runtime-record-unreadable", + ); + if (!runtimeFile.ok) return { status: "refused", reason: runtimeFile.reason }; + let parsed: unknown; + try { + parsed = JSON.parse(runtimeFile.raw); + } catch { + return { status: "refused", reason: "runtime-record-invalid" }; + } + if (!isValidRuntimePortState(parsed)) { + return { status: "refused", reason: "runtime-record-invalid" }; + } + if (parsed.pid !== pid) { + return { status: "refused", reason: "runtime-record-pid-mismatch" }; + } + return { status: "verified", pid, port: parsed.port, hostname: parsed.hostname ?? "" }; + } catch { + return { status: "refused", reason: "unverified" }; + } +} + +/** + * Fixed refusal causes for the approval-changed detail. Every entry is a + * complete sentence fragment and contains no path, command line, stderr, or + * token, so the resulting detail is safe to surface in a caller's log or UI. + */ +const TRACKED_REFUSAL_CAUSES: Record = { + "pid-record-absent": "the recorded PID file is absent", + "pid-record-permission-denied": "reading the recorded PID file was denied", + "pid-record-unreadable": "the recorded PID file could not be read", + "pid-record-invalid": "the recorded PID file is not a valid record", + "process-not-running": "the recorded process is no longer running", + "process-identity-mismatch": "the recorded process does not match the expected start command", + "process-unverifiable": "the recorded process identity could not be verified", + "runtime-record-absent": "the tracked runtime record is absent", + "runtime-record-permission-denied": "reading the tracked runtime record was denied", + "runtime-record-unreadable": "the tracked runtime record could not be read", + "runtime-record-invalid": "the tracked runtime record is not a valid record", + "runtime-record-pid-mismatch": "the tracked runtime record names a different PID", + "unverified": "the tracked runtime record could not be re-verified", +}; + +/** + * The guarded stop's tracked() callback shape: the verified target, or the + * fixed cause of the single check that failed. Read-only; decides nothing. + */ +export function describeTrackedRuntimeTarget( + deps: TrackedRecordProbeDeps = {}, +): { pid: number; port: number; hostname: string } | { untracked: string } { + const inspection = inspectTrackedRuntimeRecord(deps); + return inspection.status === "verified" + ? { pid: inspection.pid, port: inspection.port, hostname: inspection.hostname } + : { untracked: TRACKED_REFUSAL_CAUSES[inspection.reason] }; +} diff --git a/structure/config.md b/structure/config.md index 4f330eebdd9..01d9447e520 100644 --- a/structure/config.md +++ b/structure/config.md @@ -68,7 +68,7 @@ silently move the active installation. Config reads in this leaf and `src/config It owns their byte-compatible writes, parsing, expected-PID filters, cheap liveness, full OCX command identity, and snapshot-guarded removal. `RuntimePortState.attestationSecret` and `siblingOfPort` (the live owner's port, written only by a sibling instance) remain optional, owner-only state and are validated before a record is returned. `src/config.ts` re-exports the same -symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. +symbols for compatibility, but new lifecycle-only callers import the process-state leaf directly. `src/config/tracked-runtime-inspection.ts` is the guarded stop's read-only companion probe: it re-runs the same record reads in the same order, answers the start-identity question through the same cache `readPid()` consults, and reports which single check failed as a fixed refusal cause, so an absent, denied, invalid or PID-mismatched record is told apart from a dead, foreign or unverifiable process, and it decides nothing and exposes no path, command line, stderr, or token. Replacing config and process-state writes use `src/config/atomic-write.ts`. Normal Windows writes opt into the destination's single ACL timeout recovery attempt; a consumed attempt re-arms after `TIMEOUT_MEMO_REARM_MS` in `src/lib/windows-secret-acl.ts`. Default writers track temporary paths per resolved destination across synchronous, streamed and asynchronous writes. A new write refuses immediately with sanitized `ETIMEDOUT` only while one of that destination's tracked paths has an icacls child retained past its caller's timeout belt and not yet reaped, including across re-arm windows. Healthy overlapping writes proceed, including during ACL hardening or a pause before rename. Refusal consumes no recovery attempt and leaves removal ownership intact; completing one write does not drop another write's retained-child tracking. Both cleanup paths leave that recovery unconsumed. Every admitted attempt still requires a successful harden before secret bytes or publication; non-timeout failures retain their existing fail-closed behavior. `tests/windows/windows-atomic-write-acl-rearm.test.ts` covers all five harden calls, the re-arm boundary, healthy overlap, retained child admission and cleanup. The leaf preserves the shared process-wide temp sequence, optional synchronous caller destination validation before every rename attempt, symlink target resolution, no-follow directory-entry replacement for diff --git a/structure/runtime.md b/structure/runtime.md index e40e2fc0ed0..ac5b871d6c6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -59,7 +59,7 @@ The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns `ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with `endpointsToProve` and `everyEndpointProvenDown`, using the in-process `probeEndpointLiveness` from `src/server/proxy-liveness.ts`. Only unanimous definitive "dead" becomes `absent-proven`; a timeout or reset stays unknown, exits 1 and never authorises a start. The transient exclusive-bind fallback in `src/update/proxy-liveness-probe.mjs` belongs only to the npm/Bun updater. Resolve stays in-process because a compiled standalone binary cannot run a JavaScript probe with `execPath -e`. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. -`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, or the compatibility fingerprint — so a transient re-probe can be told from genuine drift in the caller's log. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. +`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. The same guard re-reads `ocx.pid` and `runtime-port.json` through `src/config/tracked-runtime-inspection.ts`, a single-pass read-only probe that walks the same checks `readPid()` and `readRuntimePort(pid)` perform and reports which one failed, answering the recorded process's start-identity question through the same cache `readPid()` fills so it cannot disagree with the admission it diagnoses. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, the compatibility fingerprint, or the tracked-record cause — an absent, denied, unreadable or invalid record, a dead, foreign or unverifiable recorded process, or a record naming a different PID — so a transient re-probe can be told from genuine drift in the caller's log. The tracked-record causes are fixed strings: denied is reported only on an observed EACCES/EPERM, an unreadable command line stays unverifiable rather than attributed to an account or elevation, and no probed path, command line, stderr or token reaches the detail. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. `ocx service status --json` (`src/service/status-json.ts`) is the passive machine form of the service report, read by peers such as GOAT's device inventory. `src/service/cli.ts` accepts `--json` for `status` only (`planServiceCommand` refuses it on every other verb before any installation probe) and answers it before `platformOps()` resolves, so no install, start, stop, repair or migration path is reachable; `skipsCodexShimAutoRestore` exempts `service status --json` so the CLI preflight repairs no Codex shim first, the collection runs inside `runPassiveRead` so the serving probe's config load neither hardens modes nor backs up a malformed file, and `--json` on any other verb is refused before the Desktop check and the Windows mutation lock; an unsupported platform or Docker gets a document instead of the text path's exit. The document reuses exactly the reads the text report makes — `diagnoseService()` (launchd `print`, systemd observation verbs, Task Scheduler `/query`, SCM/WinSW status) and, only when a registration exists, the 1.5-second loopback `confirmServiceServing` probe — plus the ownership-lease and Desktop login lines. `running` is the manager's verdict (the value `startup.serviceRunning` carries in `ocx status --json`); `serving` reports separately whether a proxy answers on the installed port. `backend` is `launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered. The systemd branch of `diagnoseService` derives through the pure `deriveSystemdServiceDiagnostic`, matching the launchd and Windows derivations. `tests/service/service-status-json.test.ts` snapshots each platform branch and records every subprocess entry point on darwin, linux and win32 against a read-only allowlist. `ocx status --json` carries an additive `install` verdict (`src/cli/status-install.ts`) mapped from the updater's own `detectInstallOwnership()`: `method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` or `unknown` (detector failure), and `evidence.basis` names the package layout, the mise backend record (tool alias and backend only) or the standalone binary without printing a path (`tests/cli/status-install-method.test.ts`). @@ -117,7 +117,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config.ts`, `src/config/persisted-mutation.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps load/save/initialize entry points and re-exports the schema-valid mutation callback, types, and one-shot test seam; the leaf owns its bounded rebase under the shared lock and a read-only authority reader for admitted writers that starts no optional ACL flight. Schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. No-follow exclusive publication reuses its private descriptor writer for raw recovery bytes, checks bigint identity and publishes by a no-replace hard link; cleanup never scrubs an already published inode. Synchronous caller validation runs immediately before each rename attempt, including Windows retries. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. The existing-secret compliance probe in `src/lib/windows-secret-acl.ts` uses the same SID-based private-entry policy as recovery-copy reuse when the opt-in single-owner ACE fast check cannot establish compliance; recovery-copy reuse always calls the SID policy directly. Retained SYSTEM and Administrators ACEs do not trigger mutation or duplicate recovery publication. The asynchronous probe in `src/lib/windows-owner-acl.ts` yields during the bounded descriptor subprocess and shares the synchronous parser, without admitting a hardening success memo. | -| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. | +| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. A transient Windows WMIC/PowerShell command-line probe failure gets one bounded retry before a PID reads as unverified. `src/config/tracked-runtime-inspection.ts` re-runs the same record reads once for the guarded stop, shares `readPid()`'s cached start-identity observation rather than re-probing it, and names the failed check as a fixed cause. | | `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Canonical identity tags arrays and objects separately, so key sorting cannot equate an object with an array of entry pairs. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` / `src/server/bind-host.ts` | Own bind availability, ephemeral-port selection and startup hostname normalization shared with liveness: blank and case-insensitive `localhost`/`localhost.` bind to IPv4 loopback; other spellings remain unchanged. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | diff --git a/tests/cli/cli-stop-json.test.ts b/tests/cli/cli-stop-json.test.ts index 5a7168e55a2..5394f8256ac 100644 --- a/tests/cli/cli-stop-json.test.ts +++ b/tests/cli/cli-stop-json.test.ts @@ -464,6 +464,20 @@ describe("approval-bound stop", () => { () => null, BOUND_STOP_MANAGER, stop); expect(untracked.summary.detail).toBe("the tracked runtime record could not be verified"); + // The same umbrella refusal now names which tracked-record check failed, so a + // stale or missing record reads differently from an unreadable or foreign process. + const absentRecord = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, + () => ({ untracked: "the recorded PID file is absent" }), BOUND_STOP_MANAGER, stop); + expect(absentRecord.summary).toMatchObject({ outcome: "approval-changed", exitCode: 1, + detail: "the tracked runtime record could not be verified (the recorded PID file is absent)" }); + expect(absentRecord.summary.message).toContain("the recorded PID file is absent"); + + const foreignPid = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, + () => ({ untracked: "the recorded process does not match the expected start command" }), + BOUND_STOP_MANAGER, stop); + expect(foreignPid.summary.detail) + .toBe("the tracked runtime record could not be verified (the recorded process does not match the expected start command)"); + const managerGone = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, TRACKED_STOP_TARGET, () => ({ kind: "unknown" as const, reason: "CIM read failed" }), stop); expect(managerGone.summary.detail).toBe("the service manager could not be re-verified (CIM read failed)"); diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index 3ff6ac9ea01..73cfd51657a 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -18,9 +18,15 @@ import { setOcxStartProcessCacheForTests, setProcessCommandLineExecForTests, setProcessCommandLinePlatformForTests, + verifyPidIdentity, writePid, writeRuntimePort, } from "../../src/config/process-state"; +import { + describeTrackedRuntimeTarget, + inspectTrackedRuntimeRecord, +} from "../../src/config/tracked-runtime-inspection"; +import type { TrackedRecordProbeDeps } from "../../src/config/tracked-runtime-inspection"; import { markSiblingStart, resetSiblingStartForTests, siblingRuntimeField } from "../../src/codex/sibling-start"; import { setTrustedWindowsSystemDirectoryResolverForTests } from "../../src/lib/windows-elevation"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -308,3 +314,204 @@ describe("proxy process-state ownership", () => { } }); }); + +describe("tracked runtime record inspection", () => { + // Injected deps keep every probe input fake: no process.kill, no WMIC/PowerShell/ps, + // and no real stop ever runs. Record reads default to the real temp-home files. The + // cache-sharing cases below drive the same exec seams production uses -- with faked + // native answers and a real signal-0 read on this process only. + const errno = (code: string): NodeJS.ErrnoException => + Object.assign(new Error("fake " + code), { code }); + const liveOcx: TrackedRecordProbeDeps = { signal: () => {}, identity: () => "match" }; + const trackedPid = () => writeFileSync(getPidPath(), "4242", "utf-8"); + const trustedProbePaths = () => { + const trustedSystem32 = join(testDir, "trusted", "System32"); + const trustedPowerShell = join(trustedSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"); + mkdirSync(dirname(trustedPowerShell), { recursive: true }); + writeFileSync(trustedPowerShell, "", { mode: 0o755 }); + setProcessCommandLinePlatformForTests("win32"); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); + return { trustedWmic: join(trustedSystem32, "wbem", "WMIC.exe"), trustedPowerShell }; + }; + // The trusted-System32 seam also reroutes icacls resolution, so these records are + // written directly like the cases above -- the parity being tested is the shared + // identity cache, not the atomic writer. + const trackedSelf = (hostname: string) => { + writeFileSync(getPidPath(), String(process.pid), "utf-8"); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: process.pid, port: 58195, hostname }), "utf-8"); + }; + + test("a verified record pair returns the tracked target", () => { + trackedPid(); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 58195, hostname: "0.0.0.0" }), "utf-8"); + expect(inspectTrackedRuntimeRecord(liveOcx)).toEqual( + { status: "verified", pid: 4242, port: 58195, hostname: "0.0.0.0" }); + expect(describeTrackedRuntimeTarget(liveOcx)).toEqual({ pid: 4242, port: 58195, hostname: "0.0.0.0" }); + }); + + test("an absent or invalid pid record is named before any process probe", () => { + const probes: string[] = []; + const deps: TrackedRecordProbeDeps = { + signal: () => { probes.push("signal"); }, + identity: () => { probes.push("identity"); return "match"; }, + }; + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "pid-record-absent" }); + expect(probes).toEqual([]); + + writeFileSync(getPidPath(), "not-a-pid", "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "pid-record-invalid" }); + expect(probes).toEqual([]); + }); + + test("pid-record read errors separate real permission denials from other failures", () => { + const denied = (code: string) => inspectTrackedRuntimeRecord({ + readFile: () => { throw errno(code); }, + signal: () => {}, + identity: () => "match", + }); + expect(denied("EACCES")).toEqual({ status: "refused", reason: "pid-record-permission-denied" }); + expect(denied("EPERM")).toEqual({ status: "refused", reason: "pid-record-permission-denied" }); + expect(denied("EIO")).toEqual({ status: "refused", reason: "pid-record-unreadable" }); + }); + + test("a dead recorded pid reports not-running; other signal errors stay unverifiable", () => { + trackedPid(); + const withSignal = (signal: (pid: number) => void) => + inspectTrackedRuntimeRecord({ signal, identity: () => "match" }); + expect(withSignal(() => { throw errno("ESRCH"); })).toEqual( + { status: "refused", reason: "process-not-running" }); + expect(withSignal(() => { throw errno("EINVAL"); })).toEqual( + { status: "refused", reason: "process-unverifiable" }); + expect(withSignal(() => { throw new Error("signal probe failed"); })).toEqual( + { status: "refused", reason: "process-unverifiable" }); + }); + + test("an unverifiable identity observation is never permission evidence", () => { + trackedPid(); + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 58195 }), "utf-8"); + // Whether the liveness signal was clean or EPERM, an identity that cannot be + // observed only means it could not be verified. The unelevated-desktop case + // behind #7771 is one possibility, not a proven cause. + for (const signal of [() => {}, () => { throw errno("EPERM"); }]) { + expect(inspectTrackedRuntimeRecord({ signal, identity: () => "unverifiable" })).toEqual( + { status: "refused", reason: "process-unverifiable" }); + } + // And a foreign process holding the recorded pid is a different fact entirely. + expect(inspectTrackedRuntimeRecord({ + signal: () => {}, + identity: () => "mismatch", + })).toEqual({ status: "refused", reason: "process-identity-mismatch" }); + }); + + test("runtime record failures distinguish absent, denied, invalid and pid-mismatch", () => { + trackedPid(); + const deps: TrackedRecordProbeDeps = { signal: () => {}, identity: () => "match" }; + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-absent" }); + + writeFileSync(getRuntimePortPath(), "{not json", "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-invalid" }); + + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 4242, port: 99999 }), "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-invalid" }); + + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 9999, port: 58195 }), "utf-8"); + expect(inspectTrackedRuntimeRecord(deps)).toEqual( + { status: "refused", reason: "runtime-record-pid-mismatch" }); + + expect(inspectTrackedRuntimeRecord({ + readFile: path => path.endsWith("ocx.pid") + ? "4242" + : (() => { throw errno("EACCES"); })(), + signal: () => {}, + identity: () => "match", + })).toEqual({ status: "refused", reason: "runtime-record-permission-denied" }); + }); + + test("refusal details are fixed sentences and never leak the observed error text", () => { + const marker = "t0ken-c0mmand-l1ne"; + const mismatched = describeTrackedRuntimeTarget({ + readFile: () => { throw Object.assign(new Error("open failed " + marker), { code: "EPERM" }); }, + signal: () => {}, + identity: () => "mismatch", + }); + expect(mismatched).toEqual({ untracked: "reading the recorded PID file was denied" }); + expect(JSON.stringify(mismatched)).not.toContain(marker); + + trackedPid(); + expect(describeTrackedRuntimeTarget(liveOcx)).toEqual( + { untracked: "the tracked runtime record is absent" }); + }); + + test("a cached start identity serves both readers after the native probe dies", () => { + const { trustedWmic } = trustedProbePaths(); + trackedSelf("0.0.0.0"); + const calls: string[] = []; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedWmic) return "CommandLine=ocx.exe start\r\n"; + throw new Error("unexpected process probe: " + executable); + }); + // The pidless healthz path fills the shared start-identity cache for this PID. + expect(verifyPidIdentity(process.pid)).toBe(process.pid); + expect(calls).toEqual([trustedWmic]); + // Every native probe is now dead: the cached verdict must still admit this + // record for both readers, and neither may ask again. + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + throw new Error("native probe unavailable"); + }); + expect(readPid()).toBe(process.pid); + expect(readRuntimePort(process.pid)?.port).toBe(58195); + expect(describeTrackedRuntimeTarget()).toEqual({ pid: process.pid, port: 58195, hostname: "0.0.0.0" }); + expect(calls).toEqual([trustedWmic]); + }); + + test("a cached foreign verdict still refuses both readers while a fresh probe could answer", () => { + trustedProbePaths(); + trackedSelf("0.0.0.0"); + setOcxStartProcessCacheForTests([[process.pid, false]]); + const calls: string[] = []; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + return "CommandLine=ocx.exe start\r\n"; + }); + expect(readPid()).toBeNull(); + expect(describeTrackedRuntimeTarget()).toEqual( + { untracked: "the recorded process does not match the expected start command" }); + expect(calls).toEqual([]); + }); + + test("an unreadable identity answer is cached by neither reader and recovers together", () => { + const { trustedWmic, trustedPowerShell } = trustedProbePaths(); + trackedSelf("0.0.0.0"); + const calls: string[] = []; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + throw new Error("native probe unavailable"); + }); + expect(readPid()).toBeNull(); + expect(describeTrackedRuntimeTarget()).toEqual( + { untracked: "the recorded process identity could not be verified" }); + // Each reader asked the identical dead sequence: WMIC, then both CIM retries. + expect(calls).toEqual([ + trustedWmic, trustedPowerShell, trustedPowerShell, + trustedWmic, trustedPowerShell, trustedPowerShell, + ]); + expect(ocxStartProcessCacheSizeForTests()).toBe(0); + // Recovery: the next miss re-probes once and the shared answer then serves both. + calls.length = 0; + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedWmic) return "CommandLine=ocx.exe start\r\n"; + throw new Error("unexpected process probe: " + executable); + }); + expect(readPid()).toBe(process.pid); + expect(describeTrackedRuntimeTarget()).toEqual({ pid: process.pid, port: 58195, hostname: "0.0.0.0" }); + expect(calls).toEqual([trustedWmic]); + }); +});