diff --git a/src/cli/index.ts b/src/cli/index.ts index e5618af11aa..f7dcd16a8b0 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1193,7 +1193,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) { if (snapshot) { if (guardedStep?.effect === "approval-changed") { - return approvalChanged(); + return approvalChanged(guardedStep.detail); } if (guardedStep?.effect === "manager-still-active") { return managerStillActive(record.service, record); diff --git a/src/cli/stop-approval.ts b/src/cli/stop-approval.ts index 3265bd92e0a..4b83fef6169 100644 --- a/src/cli/stop-approval.ts +++ b/src/cli/stop-approval.ts @@ -64,19 +64,41 @@ export function parseStopApproval(argv: string[]): StopApprovalParse { } export function matchesStopApproval(expected: StopApproval, now: ResolveJson): boolean { - return now.schema === "ocx-resolve/1" && now.liveness.status === "live" - && now.liveness.pid === expected.pid && now.liveness.port === expected.port - && now.port.effective === expected.port && (now.liveness.hostname ?? "") === expected.hostname - && now.liveness.role !== "client" && now.configHome === expected.configHome - && now.cliVersion === expected.cliVersion && now.ownership.kind !== "unknown" - && now.takeover.kind === "supported" && now.takeover.token === expected.compatibilityToken; + return stopApprovalMismatch(expected, now) === null; } -export function approvalChanged(): StopOutcome { +/** + * The single guard fact that no longer holds, or null when everything still matches. + * The guarded stop collapses every re-verification into one `approval-changed` outcome; + * without the specific check, a desktop log cannot tell a genuine ownership or token + * drift from a transient probe failure on an unchanged runtime. + */ +export function stopApprovalMismatch(expected: StopApproval, now: ResolveJson): string | null { + if (now.schema !== "ocx-resolve/1") return "the resolve contract changed"; + if (now.liveness.status !== "live") return `the approved runtime is no longer live (${now.liveness.status})`; + if (now.liveness.pid !== expected.pid) { + return `the live PID is now ${now.liveness.pid ?? "unverified"} (approved ${expected.pid})`; + } + if (now.liveness.port !== expected.port) return `the live port is now ${now.liveness.port} (approved ${expected.port})`; + if (now.port.effective !== expected.port) return `the effective port is now ${now.port.effective} (approved ${expected.port})`; + if ((now.liveness.hostname ?? "") !== expected.hostname) return "the bind hostname changed"; + if (now.liveness.role === "client") return "the runtime now routes as a client"; + if (now.configHome !== expected.configHome) return "the config home changed"; + if (now.cliVersion !== expected.cliVersion) return `the resolving CLI is now ${now.cliVersion} (approved ${expected.cliVersion})`; + if (now.ownership.kind === "unknown") return "the ownership claim could not be re-verified"; + if (now.takeover.kind !== "supported") { + return `takeover compatibility is now blocked (${now.takeover.reason})`; + } + if (now.takeover.token !== expected.compatibilityToken) return "the compatibility fingerprint changed"; + return null; +} + +export function approvalChanged(detail?: string): StopOutcome { return { ok: false, summary: { schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "approval-changed", exitCode: 1, runtimeDown: false, service: "absent", proxy: "unknown", sharedTeardown: "skipped", - message: "The approved runtime or managing CLI changed; no stop was attempted.", + ...(detail ? { detail } : {}), + message: `The approved runtime or managing CLI changed${detail ? `: ${detail}` : ""}; no stop was attempted.`, } }; } @@ -104,10 +126,20 @@ export async function runApprovedStop( const now = await read(); const target = tracked(); const manager = managerTarget(); - if (!now || !matchesStopApproval(expected, now) || !target - || target.pid !== expected.pid || target.port !== expected.port - || target.hostname !== expected.hostname || manager.kind === "unknown" - || (manager.kind === "bound" && manager.pid !== expected.pid)) return approvalChanged(); + if (now === null) return approvalChanged("the runtime could not be re-resolved"); + 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 (target.pid !== expected.pid || target.port !== expected.port + || target.hostname !== expected.hostname) { + return approvalChanged("the tracked runtime record changed"); + } + if (manager.kind === "unknown") { + return approvalChanged(`the service manager could not be re-verified (${manager.reason})`); + } + if (manager.kind === "bound" && manager.pid !== expected.pid) { + return approvalChanged("a different manager owns the approved runtime"); + } snapshot = { approval: expected, manager }; } catch { return approvalChanged(); } return snapshot ? stop(snapshot) : approvalChanged(); @@ -149,13 +181,20 @@ export async function runGuardedManagerStep( managerState: () => Promise<"inactive" | "active" | "unknown">; }, ): Promise<{ service: StopServiceOutcome; effect: "stopped" | "approval-changed" | "manager-still-active" | "failed"; - proxy: "stopped" | "unknown"; handledByProxy: boolean }> { + proxy: "stopped" | "unknown"; handledByProxy: boolean; detail?: string }> { try { - if (!sameGuardedManager(snapshot.manager, io.revalidateManager())) { - return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false }; + const current = io.revalidateManager(); + if (!sameGuardedManager(snapshot.manager, current)) { + // An unprovable manager is not a proven change: name the observation failure, + // not a swap, or the stop log loses why the revalidation could not answer. + const detail = current.kind === "unknown" + ? `the service manager could not be re-verified (${current.reason})` + : "the service manager changed between approval and stop"; + return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false, detail }; } } catch { - return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false }; + return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false, + detail: "the service manager could not be re-validated" }; } const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager(); if (snapshot.manager.kind === "bound" diff --git a/src/cli/stop-report.ts b/src/cli/stop-report.ts index a9b8900222f..744c77e6d6f 100644 --- a/src/cli/stop-report.ts +++ b/src/cli/stop-report.ts @@ -76,6 +76,11 @@ export interface StopSummaryJson { sharedTeardown: StopSharedTeardownOutcome; /** One stable human-readable line for a caller's UI. */ message: string; + /** + * The single guard fact that failed on a `approval-changed` refusal, when the + * run can name one. Additive on `ocx-stop/1`; absent on every other outcome. + */ + detail?: string; } /** What handleStop returns: the pre-existing boolean plus the structured twin. */ diff --git a/src/config/process-state.ts b/src/config/process-state.ts index f865225ba3d..c9053ab1512 100644 --- a/src/config/process-state.ts +++ b/src/config/process-state.ts @@ -328,14 +328,23 @@ export function readProcessCommandLine(pid: number): string | undefined { } catch { /* WMIC missing or failed — fall through */ } - const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [ - "-NoProfile", - "-NoLogo", - "-NonInteractive", - "-Command", - `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, - ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); - return output.trim() || undefined; + // One bounded retry: a transient CIM timeout answers "unreadable" for an + // unchanged process and turns a guarded stop into a false approval-changed. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const output = processCommandLineExec(resolveTrustedWindowsPowerShellExe(), [ + "-NoProfile", + "-NoLogo", + "-NonInteractive", + "-Command", + `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`, + ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true }); + return output.trim() || undefined; + } catch { + /* timed out or CIM unavailable — try once more, then unreadable */ + } + } + return undefined; } for (const ps of ["/bin/ps", "/usr/bin/ps"]) { try { diff --git a/src/service/managing-cli.ts b/src/service/managing-cli.ts index 0696026b237..26f622e066c 100644 --- a/src/service/managing-cli.ts +++ b/src/service/managing-cli.ts @@ -26,6 +26,13 @@ import { import type { ServiceInstallState } from "./state"; const VERSION_PROBE_TIMEOUT_MS = 5000; +/** + * A `--version` spawn is the only evidence for a PATH shim; a transient timeout or a + * busy cold start turns an unchanged manager into `unknown`, which flips both the + * takeover answer and the compatibility token the next resolve compares. One bounded + * retry keeps the observation stable while remaining fail-closed on a real failure. + */ +const VERSION_PROBE_ATTEMPTS = 2; export interface ManagingCliDeps { /** Environment to scan PATH/PATHEXT in. Defaults to `process.env`. */ @@ -52,12 +59,12 @@ function versionFromOutput(stdout: string): string | null { return null; } -function probeVersion( +function probeVersionOnce( executable: string, args: readonly string[], deps: Required>, + identity: string, ): ManagingCliObservation { - const identity = [executable, ...args].join(" "); let result: SpawnSyncReturns; try { const windowsShim = @@ -65,10 +72,17 @@ function probeVersion( if (windowsShim && [executable, ...args].some(part => /[&|<>^%!"()]/.test(part))) { return { status: "unknown", reason: "the selected Windows command shim invocation cannot be probed safely" }; } + // cmd /c re-parses the remainder of its command line: with more than one quoted + // part it strips the outer quotes and truncates at the first space, so every part + // must be quoted individually inside one wrapping pair, passed verbatim. + const shimCommand = `"${[executable, ...args, "--version"].map(part => `"${part}"`).join(" ")}"`; result = deps.spawn( windowsShim ? (deps.env?.ComSpec ?? "cmd.exe") : executable, - windowsShim ? ["/c", executable, ...args, "--version"] : [...args, "--version"], - { timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", windowsHide: true }, + windowsShim ? ["/c", shimCommand] : [...args, "--version"], + { + timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", + windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}), + }, ) as SpawnSyncReturns; } catch (error) { return { @@ -91,6 +105,22 @@ function probeVersion( : { status: "observed", version, identity }; } +function probeVersion( + executable: string, + args: readonly string[], + deps: Required>, +): ManagingCliObservation { + const identity = [executable, ...args].join(" "); + let observation = probeVersionOnce(executable, args, deps, identity); + // One bounded retry, Windows only (the platform the flake family was verified on): + // a single shim-timeout answer must not flip the fingerprint of an unchanged CLI. + const attempts = deps.platform === "win32" ? VERSION_PROBE_ATTEMPTS : 1; + for (let attempt = 1; observation.status !== "observed" && attempt < attempts; attempt += 1) { + observation = probeVersionOnce(executable, args, deps, identity); + } + return observation; +} + /** * Where `ocx` resolves on PATH without spawning `where`/`which`. * diff --git a/structure/runtime.md b/structure/runtime.md index cf1e00c65e3..1ee97f46e6b 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -57,9 +57,9 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI resolve and stop contracts for embedding shells -`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 the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. 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. +`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 the updater's tri-state probe (`endpointsToProve` + `everyEndpointProvenDown` + `probeProxyLiveness`), and only a unanimous definitive "dead" becomes `absent-proven`; unknown exits 1 and never authorises a start. 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. -`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 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. 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 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. ## Native main reauth JSON output @@ -132,7 +132,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. 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. 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. | -| `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | +| `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/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. 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` | Owns bind availability and ephemeral-port selection. 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 3c336d5f9c6..900a3a2b558 100644 --- a/tests/cli/cli-stop-json.test.ts +++ b/tests/cli/cli-stop-json.test.ts @@ -341,6 +341,74 @@ describe("approval-bound stop", () => { expect(proxyStops).toBe(0); }); + test("an approval-changed refusal names the guard fact that failed", async () => { + const stop = async () => ({ ok: true, summary: summarize(record(), { exitCode: 0 }) }); + + // Token vs manager-inspection discrimination: the umbrella outcome has to say + // WHICH re-verification failed, or a transient probe and genuine drift look alike. + const tokenFlip = { ...STOP_RESOLVE, + takeover: { ...STOP_RESOLVE.takeover, token: "b".repeat(64) } } as ResolveJson; + const refused = await runApprovedStop(STOP_APPROVAL, async () => tokenFlip, + TRACKED_STOP_TARGET, BOUND_STOP_MANAGER, stop); + expect(refused.summary).toMatchObject({ outcome: "approval-changed", + detail: "the compatibility fingerprint changed" }); + expect(refused.summary.message).toContain("the compatibility fingerprint changed"); + + const pidDrift = { ...STOP_RESOLVE, + liveness: { ...STOP_RESOLVE.liveness, pid: 43 } } as ResolveJson; + const drifted = await runApprovedStop(STOP_APPROVAL, async () => pidDrift, + TRACKED_STOP_TARGET, BOUND_STOP_MANAGER, stop); + expect(drifted.summary.detail).toBe("the live PID is now 43 (approved 42)"); + + const blockedNow = { ...STOP_RESOLVE, + takeover: { kind: "blocked", reason: "managing-cli-unknown", detail: "x", + minimumCliVersion: "2.61.0" } } as ResolveJson; + const blocked = await runApprovedStop(STOP_APPROVAL, async () => blockedNow, + TRACKED_STOP_TARGET, BOUND_STOP_MANAGER, stop); + expect(blocked.summary.detail).toBe("takeover compatibility is now blocked (managing-cli-unknown)"); + + const untracked = await runApprovedStop(STOP_APPROVAL, async () => STOP_RESOLVE, + () => null, BOUND_STOP_MANAGER, stop); + expect(untracked.summary.detail).toBe("the tracked runtime record could not be verified"); + + 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)"); + + const unresolved = await runApprovedStop(STOP_APPROVAL, async () => null, + TRACKED_STOP_TARGET, BOUND_STOP_MANAGER, stop); + expect(unresolved.summary.detail).toBe("the runtime could not be re-resolved"); + + const swapped = await runGuardedManagerStep( + { approval: STOP_APPROVAL, manager: BOUND_STOP_MANAGER() }, + { + revalidateManager: () => ({ kind: "absent" }), + stopManager: () => "stopped", + signalApproved: async () => false, + settle: async () => true, + managerState: async () => "inactive", + }); + expect(swapped.effect).toBe("approval-changed"); + expect(swapped.detail).toBe("the service manager changed between approval and stop"); + + // An unprovable manager is not a proven swap: the refusal names the observation + // failure and its reason, and no stop or signal runs against an unknown target. + let managerStops = 0, signals = 0; + const unprovable = await runGuardedManagerStep( + { approval: STOP_APPROVAL, manager: BOUND_STOP_MANAGER() }, + { + revalidateManager: () => ({ kind: "unknown" as const, reason: "the process table could not be read" }), + stopManager: () => { managerStops += 1; return "stopped"; }, + signalApproved: async () => { signals += 1; return false; }, + settle: async () => true, + managerState: async () => "inactive", + }); + expect(unprovable.effect).toBe("approval-changed"); + expect(unprovable.detail).toBe("the service manager could not be re-verified (the process table could not be read)"); + expect(managerStops).toBe(0); + expect(signals).toBe(0); + }); + test("an asynchronous manager stop settles before status decides success", async () => { const order: string[] = []; let state: "active" | "inactive" = "active"; diff --git a/tests/service/managing-cli.test.ts b/tests/service/managing-cli.test.ts index 287c3f7eb96..db1b55efc3f 100644 --- a/tests/service/managing-cli.test.ts +++ b/tests/service/managing-cli.test.ts @@ -113,6 +113,75 @@ describe("Windows managing CLI selection", () => { }); }); +describe("managing CLI probe resilience", () => { + const windowsDeps = (spawn: typeof spawnSync) => ({ + platform: "win32" as const, + env: { PATH: "C:\\Program Files\\nodejs", PATHEXT: ".CMD" }, + execPath: "C:\\other\\ocx.exe", + exists: (path: string) => path === "C:\\Program Files\\nodejs\\ocx.CMD", + isFile: () => true, + ownVersion: () => "2.76.0", + spawn, + }); + + test("a transient probe failure is retried once before reading unknown", () => { + let calls = 0; + const spawn = (() => { + calls += 1; + return calls === 1 + ? { status: null, stdout: "", stderr: "", error: new Error("timed out") } + : { status: 0, stdout: "2.67.0", stderr: "" }; + }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, windowsDeps(spawn)); + expect(result.path).toMatchObject({ status: "observed", version: "2.67.0" }); + expect(calls).toBe(2); + }); + + test("a failing probe is not retried off Windows", () => { + let calls = 0; + const spawn = (() => { + calls += 1; + return { status: 1, stdout: "", stderr: "" }; + }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, { + platform: "linux", env: { PATH: "/opt" }, execPath: "/other/ocx", + exists: path => path === "/opt/ocx", isFile: () => true, + ownVersion: () => "2.76.0", spawn, + }); + expect(result.path.status).toBe("unknown"); + expect(calls).toBe(1); + }); + + test("a persistently failing probe stays fail-closed after the bounded retry", () => { + let calls = 0; + const spawn = (() => { + calls += 1; + return { status: 1, stdout: "", stderr: "" }; + }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, windowsDeps(spawn)); + expect(result.path.status).toBe("unknown"); + expect(calls).toBe(2); + }); + + test("a Windows command shim probes through one verbatim-quoted cmd /c line", () => { + const seen: { command: string; args: readonly string[]; verbatim: unknown }[] = []; + const spawn = ((command: string, args: readonly string[], options: { windowsVerbatimArguments?: boolean }) => { + seen.push({ command, args, verbatim: options?.windowsVerbatimArguments }); + return { status: 0, stdout: "2.67.0", stderr: "" }; + }) as unknown as typeof spawnSync; + const shim = "C:\\Program Files\\nodejs\\ocx.CMD"; + const result = observeManagingClis(null, windowsDeps(spawn)); + expect(result.path).toMatchObject({ status: "observed", version: "2.67.0" }); + expect(seen).toHaveLength(1); + expect(seen[0]!.command.toLowerCase()).toBe("cmd.exe"); + // cmd re-parses the /c remainder: more than one quoted part strips the outer + // quotes and truncates at the first space, so each part is quoted inside one + // wrapping pair and the whole argument is passed verbatim. + expect(seen[0]!.args).toEqual(["/c", `""${shim}" "--version""`]); + expect(seen[0]!.verbatim).toBe(true); + }); +}); + describe("managing CLI self observation", () => { test("POSIX differing case probes the selected executable", () => { const selected = "/opt/ocx"; diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index c7d31b5e8e5..3ff6ac9ea01 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -116,6 +116,28 @@ describe("proxy process-state ownership", () => { expect(isOcxCommandLine("ocx.exes start")).toBe(false); }); + test("a transient Windows command-line probe failure is retried once", () => { + const trustedSystem32 = join(testDir, "trusted", "System32"); + const trustedWmic = join(trustedSystem32, "wbem", "WMIC.exe"); + const trustedPowerShell = join(trustedSystem32, "WindowsPowerShell", "v1.0", "powershell.exe"); + const calls: string[] = []; + mkdirSync(dirname(trustedPowerShell), { recursive: true }); + writeFileSync(trustedPowerShell, "", { mode: 0o755 }); + setProcessCommandLinePlatformForTests("win32"); + setTrustedWindowsSystemDirectoryResolverForTests(() => trustedSystem32); + // WMIC absent, the first CIM read times out, the retry answers — an unchanged + // process must not read as foreign because one probe flaked. + setProcessCommandLineExecForTests(executable => { + calls.push(executable); + if (executable === trustedPowerShell && calls.filter(entry => entry === trustedPowerShell).length === 2) { + return "C:\\tools\\ocx.exe start\n"; + } + throw new Error("probe unavailable"); + }); + expect(isLikelyOcxProcess(4242)).toBe(true); + expect(calls).toEqual([trustedWmic, trustedPowerShell, trustedPowerShell]); + }); + test("the ownership probe distinguishes a real owner from a reused PID", () => { // The stop-side teardown recovery asks this about a PID recorded in a receipt. Bare // liveness said "still running" for any process that inherited the number, so the