diff --git a/src/service/desktop-supervision.d.mts b/src/service/desktop-supervision.d.mts index c62b08c0acc..c7e8d3cd353 100644 --- a/src/service/desktop-supervision.d.mts +++ b/src/service/desktop-supervision.d.mts @@ -27,18 +27,18 @@ export interface WindowsProcessReadOptions { /** Called with the parent image before row validation, so partial Desktop evidence keeps its veto. */ onExecutable?: (path: string) => void; timeoutMs?: number; - env?: NodeJS.ProcessEnv; + resolveSystemDirectory?: () => string; } export interface DesktopSupervisionDeps { targetPid?: number; platform?: NodeJS.Platform; - run?: (command: string, args: string[], timeoutMs?: number) => string; + run?: (command: string, args: string[], timeoutMs?: number, env?: NodeJS.ProcessEnv) => string; proc?: ProcReader; readPid?: () => number | null; readRuntimePortPid?: () => number | null; windowsProcesses?: (pid: number, options: Pick) => WindowsProcessPair | null; - env?: NodeJS.ProcessEnv; + resolveWindowsSystemDirectory?: () => string; now?: () => number; } @@ -46,7 +46,10 @@ export function desktopSupervisionPaths(): { pid: string; runtimePort: string }; export const procfs: ProcReader; export function processIdentity(pid: number, execute?: DesktopSupervisionDeps["run"], onExecutable?: (path: string) => void): { parent: number; executable: string } | null; -export function windowsPowerShellPath(env?: NodeJS.ProcessEnv): string; +export function windowsPowerShellPath(resolveSystemDirectory?: () => string): string; +/** Pure consistency check over loaded-module paths; throws unless exactly one well-formed + * namesake per KnownDLL shares one parent directory. Exported for tests. */ +export function windowsSystemDirectoryFromSharedObjects(loaded: unknown): string; export function windowsProcessPair(pid: number, execute?: DesktopSupervisionDeps["run"], options?: WindowsProcessReadOptions): WindowsProcessPair; export function inspectDesktopSupervision(deps?: DesktopSupervisionDeps): SupervisionEvidence; export function createSupervisionLatch(): { observe(evidence: SupervisionEvidence): boolean }; diff --git a/src/service/desktop-supervision.mjs b/src/service/desktop-supervision.mjs index a147fd7c2bd..562303d6c02 100644 --- a/src/service/desktop-supervision.mjs +++ b/src/service/desktop-supervision.mjs @@ -1,12 +1,14 @@ import { execFileSync } from "node:child_process"; import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; import { basename, dirname, join, resolve, win32 } from "node:path"; // Node >=18 also imports this module from bin/ocx.mjs; do not import TS or Bun APIs. -function run(command, args, timeout = 750) { +function run(command, args, timeout = 750, env) { return execFileSync(command, args, { - encoding: "utf8", timeout, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + encoding: "utf8", timeout, env, maxBuffer: 128 * 1024, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }).trim(); } @@ -80,22 +82,95 @@ const WINDOWS_ACQUISITION_TIMEOUT_MS = 3_000; const WINDOWS_MIN_ACQUISITION_MS = 250; const WINDOWS_DESKTOP_IMAGE = "opencodex-desktop.exe"; -/** Mirrors src/update/npm-invocation.mjs: plain ESM cannot reach the FFI system-directory resolver. */ -export function windowsPowerShellPath(env = process.env) { - const root = env.SystemRoot ?? env.windir; - const base = root && /^[A-Za-z]:\\/.test(root.replaceAll("/", "\\")) ? root : "C:\\Windows"; - return win32.join(base, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +/** Bun resolves the system directory via FFI; the Node lane derives it from loaded KnownDLL paths. */ +export function windowsPowerShellPath(resolveSystemDirectory = resolveWindowsSystemDirectory) { + const system = resolveSystemDirectory(); + return win32.join(system, "WindowsPowerShell", "v1.0", "powershell.exe"); +} + +const WINDOWS_REPORT_PATH_LIMIT = 260; // Node's module-name buffer is WCHAR[MAX_PATH] (src/debug_utils.cc). +const WINDOWS_KNOWN_DLLS = ["kernel32.dll", "ntdll.dll"]; + +// A resolved system directory is stable for the process lifetime; only success is +// memoized, so failures keep the existing retry-on-next-call recovery contract. +let windowsSystemDirectoryCache = null; + +function resolveWindowsSystemDirectory() { + if (process.platform !== "win32") throw new Error("trusted Windows resolver unavailable"); + if (windowsSystemDirectoryCache !== null) return windowsSystemDirectoryCache; + const directory = process.versions.bun ? resolveWindowsSystemDirectoryViaFfi() : resolveWindowsSystemDirectoryViaSharedObjects(); + return (windowsSystemDirectoryCache = directory); +} + +function resolveWindowsSystemDirectoryViaFfi() { + const { dlopen, ptr } = createRequire(import.meta.url)("bun:ffi"); + const lib = dlopen("kernel32.dll", { GetSystemDirectoryW: { args: ["ptr", "u32"], returns: "u32" } }); + try { + let size = 260; + for (let attempt = 0; attempt < 4; attempt++) { + const buffer = new Uint16Array(size); + const written = lib.symbols.GetSystemDirectoryW(ptr(buffer.buffer), size); + if (written === 0) throw new Error("GetSystemDirectoryW failed"); + if (written >= size) { size = written + 1; continue; } + return String.fromCharCode(...buffer.subarray(0, written)).replace(/[/\\]+$/, ""); + } + throw new Error("Windows system directory is too long"); + } finally { + lib.close(); + } +} + +/** Pure consistency check over loaded-module paths — no env, no filesystem. Exported for tests. */ +export function windowsSystemDirectoryFromSharedObjects(loaded) { + if (!Array.isArray(loaded)) throw new Error("trusted Windows resolver unavailable"); + const parents = new Map(); // lowercased -> first-seen casing (kernel32 is processed first) + for (const known of WINDOWS_KNOWN_DLLS) { + // Collect every raw namesake first: a malformed or truncated namesake must + // cause rejection, not disappear behind a format filter. + const namesakes = loaded.filter(path => typeof path === "string" && win32.basename(path).toLowerCase() === known); + for (const candidate of namesakes) { + if (candidate.length >= WINDOWS_REPORT_PATH_LIMIT || !/^[A-Za-z]:[\\/]/.test(candidate)) + throw new Error("trusted Windows resolver unavailable"); + } + if (namesakes.length !== 1) throw new Error("trusted Windows resolver unavailable"); + const parent = win32.dirname(win32.normalize(namesakes[0])); + if (!parents.has(parent.toLowerCase())) parents.set(parent.toLowerCase(), parent); + } + if (parents.size !== 1) throw new Error("trusted Windows resolver unavailable"); + return [...parents.values()][0]; +} + +// The report object carries environment secrets: read .sharedObjects only — never +// log, serialize, store or pass it on. Existence checks stay outside the pure validator. +// An unavailable or ambiguous report stays the bounded unknown residual. +function resolveWindowsSystemDirectoryViaSharedObjects() { + const loaded = process.report?.getReport?.()?.sharedObjects; + const directory = windowsSystemDirectoryFromSharedObjects(loaded); + for (const required of [...WINDOWS_KNOWN_DLLS, win32.join("WindowsPowerShell", "v1.0", "powershell.exe")]) { + try { accessSync(win32.join(directory, required), constants.F_OK); } + catch { throw new Error("trusted Windows resolver unavailable"); } + } + return directory; +} + +function windowsPowerShellEnv(systemDirectory) { + const root = win32.dirname(systemDirectory); + return { SystemRoot: root, WINDIR: root, PATH: systemDirectory, PATHEXT: ".COM;.EXE;.BAT;.CMD" }; } function windowsProcessScript(pid) { // pid is a validated safe integer; it only ever reaches a numeric WQL filter. return [ "$ErrorActionPreference='Stop'", + "$PSModuleAutoLoadingPreference='None'", + // Utility loads first: the CimCmdlets manifest needs Set-Alias, which autoload 'None' cannot supply. + "Import-Module ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -Force", + "Import-Module ([IO.Path]::Combine($PSHOME,'Modules','CimCmdlets','CimCmdlets.psd1')) -Force", "function Row($p) { if ($null -eq $p) { return $null }; [ordered]@{ pid=[string]$p.ProcessId; parent=[string]$p.ParentProcessId; exe=$p.ExecutablePath; session=[string]$p.SessionId; created=$(if ($p.CreationDate) { [string]$p.CreationDate.ToUniversalTime().Ticks } else { $null }) } }", - `$c=Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=${pid}'`, + `$c=CimCmdlets\\Get-CimInstance -ClassName Win32_Process -Filter 'ProcessId=${pid}'`, "$p=$null", - "if ($c -and [uint32]$c.ParentProcessId -gt 0) { $p=Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId=' + [uint32]$c.ParentProcessId) }", - "[ordered]@{ child=(Row $c); parent=(Row $p) } | ConvertTo-Json -Compress", + "if ($c -and [uint32]$c.ParentProcessId -gt 0) { $p=CimCmdlets\\Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId=' + [uint32]$c.ParentProcessId) }", + "[ordered]@{ child=(Row $c); parent=(Row $p) } | Microsoft.PowerShell.Utility\\ConvertTo-Json -Compress", ].join("; "); } @@ -120,9 +195,11 @@ function windowsRow(row) { /** One bounded Win32_Process read of a process and its parent. */ export function windowsProcessPair(pid, execute = run, options = {}) { if (!validPid(pid)) throw new Error("invalid pid"); - const output = execute(windowsPowerShellPath(options.env), [ + const resolveSystemDirectory = options.resolveSystemDirectory ?? resolveWindowsSystemDirectory; + const systemDirectory = resolveSystemDirectory(); + const output = execute(windowsPowerShellPath(() => systemDirectory), [ "-NoProfile", "-NoLogo", "-NonInteractive", "-Command", windowsProcessScript(pid), - ], options.timeoutMs ?? WINDOWS_ACQUISITION_TIMEOUT_MS); + ], options.timeoutMs ?? WINDOWS_ACQUISITION_TIMEOUT_MS, windowsPowerShellEnv(systemDirectory)); const raw = JSON.parse(output); if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid process pair"); // A Desktop parent seen before a later field fails validation must keep its veto. @@ -154,7 +231,7 @@ export function inspectDesktopSupervision(deps = {}) { const now = deps.now ?? Date.now; const deadline = now() + WINDOWS_INSPECTION_BUDGET_MS; const readWindowsPair = deps.windowsProcesses - ?? ((pid, options) => windowsProcessPair(pid, execute, { ...options, env: deps.env })); + ?? ((pid, options) => windowsProcessPair(pid, execute, { ...options, resolveSystemDirectory: deps.resolveWindowsSystemDirectory })); function identity(pid, parent = false) { if (platform === "darwin") return processIdentity(pid, execute, parent ? seen : undefined); const executable = proc.exe(pid); diff --git a/structure/INDEX.md b/structure/INDEX.md index 78f111d9ca4..627bf5c58fe 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -136,6 +136,7 @@ Background service, docs, release, and design discipline. | [`desktop-terminal-command.md`](desktop-terminal-command.md) | Desktop-owned ocx command on PATH: record and journal, POSIX shim and shell blocks, Windows user Path, private-path ACL policy, and the local Terminal command page. | | [`desktop-start-at-login.md`](desktop-start-at-login.md) | macOS tray Start at Login judgment shared with Startup safety, its parity fixture, click rules, and the guarded launch-origin migration. | | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | Service install/repair, platform launchers, tray, and sidecar processes. | +| [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | Durable Desktop ownership read, takeover-consent handling, live-supervision evidence, the Windows probe's trusted system-directory acquisition, and the supervision latch blocking contract consumed by service and update guards. | | [`ops/service-logging.md`](ops/service-logging.md) | Service-only console timestamps and bounded copy-truncate log retention. | | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | Opt-in sentinel that turns a hung but listening managed service proxy into an exit its manager restarts. | | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | Manager-first recovery after a failed post-update service refresh, and the non-zero unsupervised fallback. | @@ -155,13 +156,20 @@ A source area can be described by more than one doc, because these docs are orga | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `app/` | [`overview.md`](overview.md)
[`companion.md`](companion.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `bin/ocx.mjs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/` | [`desktop-shell.md`](desktop-shell.md)
[`companion.md`](companion.md) | +| `desktop/src-tauri/src/claim.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/src-tauri/src/cli_command.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_record.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_windows.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | -| `desktop/src-tauri/src/first_run.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `desktop/src-tauri/src/first_run.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md)
[`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/identity.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/src-tauri/src/login_registration.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `desktop/src-tauri/src/ownership.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/resolve.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/runtime_stop.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `desktop/src-tauri/src/startup.rs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `desktop/src-tauri/src/tray_login.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `desktop/ui/cli.html` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/ui/cli.js` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | @@ -196,9 +204,11 @@ A source area can be described by more than one doc, because these docs are orga | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/hermes.md`](clients/hermes.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/cli/codex-provider.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | +| `src/cli/desktop-runtime-guidance.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/cli/link-apply-restart.ts` | [`remote-link.md`](remote-link.md) | | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/cli/status-codex-settings.ts` | [`remote-link.md`](remote-link.md) | +| `src/cli/update-restart.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/client/link-home.ts` | [`remote-link.md`](remote-link.md) | | `src/client/link-parity-reporter.ts` | [`remote-link.md`](remote-link.md) | @@ -310,7 +320,11 @@ A source area can be described by more than one doc, because these docs are orga | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/service/` | [`runtime.md`](runtime.md) | -| `src/service/desktop-startup.ts` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `src/service/cli.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-command-guard.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-startup.ts` | [`desktop-start-at-login.md`](desktop-start-at-login.md)
[`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-supervision.d.mts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/desktop-supervision.mjs` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/service/health-sentinel-launch.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-linux.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-main.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | @@ -318,6 +332,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/service/health-sentinel-probe.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-windows.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/logging.ts` | [`ops/service-logging.md`](ops/service-logging.md) | +| `src/service/orchestration.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/repair.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/sidecar/` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/sidecar/anthropic-binding.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/stall-timeout.ts` | [`runtime.md`](runtime.md) | @@ -328,8 +344,10 @@ A source area can be described by more than one doc, because these docs are orga | `src/types/account-auto-pause.ts` | [`account-auto-pause.md`](account-auto-pause.md) | | `src/types/provider.ts` | [`pricing.md`](pricing.md) | | `src/update/` | [`runtime.md`](runtime.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/update/index.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/update/job-supervised-restart.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/update/job.ts` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | +| `src/update/restart-ownership.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/update/supervised-recovery.mjs` | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`companion.md`](companion.md) | | `src/usage/cost.ts` | [`pricing.md`](pricing.md) | diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index a1f20696868..3fecb250675 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -300,134 +300,15 @@ decision is appended to ## Runtime ownership, from the app's side -`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted -once into the app's config directory and never rewritten, exclusively so two launches racing each -other answer to the same one. It exists because the recorded claim names the owning *installation*, -so the app needs a value of its own to compare against; an id kept only in the shared record would -be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another -installation's. The cost is that a reinstall which keeps the directory keeps its consent and one -that loses it asks again. - -`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the -comparison, all of which are defined by -[background-service runtime ownership](runtime.md#background-service-runtime-ownership) and not -here. The shell does not read the record: resolving a claim means reading every state path and -failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second -weaker implementation of a question core already answers is the mistake this tree has made before. -The bundled CLI answers ownership and takeover compatibility through `ocx resolve --json`. -It also answers how the live runtime's version compares to the bundled CLI's -(`versionSkew.relation`; future relation strings read as unknown without discarding the live answer), and the shell acts on the direction instead of reparsing the -warning: `proxy-newer` makes a supported takeover a downgrade, so the run attaches as a -guest with the versions, downgrade risk and verbatim CLI warning rather than asking consent to it. Every other guest path — held -consent, an unreadable owner, a blocked takeover, a declined prompt, a recovery — appends -the CLI's warning to its phase detail, and the consent panel shows it beside the subject. -Unknown ownership never means "nobody owns it". A supported offer shows the endpoint, home -and owner. After consent, the shell resolves again and refuses a changed answer without -invoking stop. It passes the approved token, endpoint and PID to the CLI's opt-in guarded stop. -That command checks the evidence and manager-to-PID binding under its ownership mutation lease -before action; it stops the manager or approved PID, waits within a bounded deadline for PID -exit and endpoint silence, and only then requires definitive manager inactivity. A manager -that remains active or becomes unreadable produces terminal `manager-still-active`, not a stop -receipt. The shell also treats `approval-changed`, unreadable output and child timeout as -terminal before its own silence wait or claim. Only parsed `stopped` or validated exit-79 -`history-incomplete` proceeds to the refused-probe receipt and `ocx service claim`, which -rechecks the approved subject and compatibility. Declining attaches as a guest; a failed claim -does not pretend a stopped runtime was restored. - -Live supervision is a third independent fact, alongside liveness and durable ownership. -On macOS and Linux, the read-only probe verifies that the runtime's direct parent is -`opencodex-desktop` and the child executable is that app's sibling `ocx`; two complete -process snapshots must agree. The target runtime PID, `ocx.pid`, and the PID in -`runtime-port.json` must agree wherever present; mismatches make supervision unknown. -The attestation secret is never included in supervision evidence. On Windows, `ocx.pid` and `runtime-port.json` must both name the runtime PID; its direct parent must be a live `opencodex-desktop.exe`; both images must have drive-qualified paths, with the runtime matching the Desktop image's sibling `ocx.exe` case-insensitively; parent and child must share a session; and the parent must have been created strictly before the child. -Two snapshots must agree, with one bounded PowerShell `Win32_Process` read per snapshot and a six-second budget for the inspection. Inconclusive evidence is `unknown`, retaining `desktopSeen` when a Desktop image was observed. Windows Start at Login is not verified, so live supervision never grants startup protection there. - -Command guards use this live evidence independently of login registration and durable ownership. -Each guarded service command, Node/Bun update run and dashboard restart decision uses one -`createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: `desktop` or -`unknown` with `desktopSeen: true` blocks; only a later positive `none` clears it. A later -plain `unknown` or `unsupported` cannot clear a prior block. Without a prior block, plain -unknown and unsupported preserve the existing durable-owner and liveness decisions. -Service install, repair, start and restart refuse before mutation, including registration staging. -Update gates re-read supervision before stop, package replacement, restoration and recovery; -they refuse even for a separate npm/Bun install. Use the app updater for its bundle, or quit -Desktop before updating the CLI install. Service stop and uninstall remain available. -These decisions follow [runtime ownership](runtime.md#background-service-runtime-ownership). - -The bypass ledger classifies these guards as **early warning**, not enforcement: - -| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | -| --- | --- | --- | --- | -| Service install/repair/start/restart | CLI before mutation / E3 | Older PATH CLIs or inconclusive evidence without Desktop seen can allow a second supervisor. | With no prior block, none/unsupported preserve ordinary command behavior. | -| Node/Bun update, including pre-stop and recovery | CLI / E3 | Same limits; Desktop can start after the last check inside the stop window, leaving a short race. | None preserves the ordinary update decision. | -| Dashboard restart veto | Server route / E3 | Older runtimes lack this guard. | None preserves the ordinary restart decision. | -| Stop guidance | Notice only / E1 | Desktop may restart the stopped proxy. | `stop --json` neither probes nor emits the notice. | - -When ownership is `none`, `src/service/desktop-startup.ts` can report -`supervisor: { supervisorPid, runtimePid, app }` with `owned: false`. Restart safety -credits this live supervision only when that same app's Start at Login registration is -verified, the proxy is running, and the diagnostic is fresh and viable. This evidence -never creates or changes an ownership claim; unknown ownership and CLI-owned claims do -not enter the unowned diagnostic branch. Unverified login registration produces Desktop -recovery guidance with no recommended shell command. - -Older runtimes cannot report this evidence. When an attested live startup verdict has -no desktop diagnostic but a local probe verifies Desktop supervision of that live PID, -status and doctor use the shared selector's local verdict; status JSON records -`startupSource: "local-supervision-override"`. The service summary uses that selected -verdict too. On Windows, the local reading may be owned or unowned but must bind to the same runtime and supervisor PIDs; macOS and Linux require an unowned local reading. A live verdict that already carries desktop diagnostics remains authoritative. +Durable ownership reads, takeover consent and live-supervision evidence — including +the Windows probe's trusted system-directory acquisition and the shared +`createSupervisionLatch` blocking contract — are owned by +[Desktop supervision](ops/desktop-supervision.md#runtime-ownership-from-the-apps-side). ### Desktop runtime ownership acceptance -The consent surface labels the exact ownership subject it is about to record. A relaunch of the -same desktop installation reuses consent when the recorded `owner` and app-local `installId` -still match; the generation is deliberately not part of that comparison, because the recorded -claim this app holds is its own consent, not a freshness token. -Package update and service repair also preserve that grant and its generation ceiling through -`preservedConsent`; they do not perform a new subject comparison. The write path is stricter than the relaunch -path: a different `owner`, different `installId`, moved `consentGeneration` or unreadable ownership record is not reuse -there — a pending approval is revalidated against the full -subject, so a grant, a release or a re-grant that moved the generation between the prompt and -the write cannot be claimed by the stale approval. On relaunch the same list narrows to the -comparison itself: a different `owner` or `installId` makes the app ask again, and an -unreadable record refuses closed rather than reading as unowned. -Uninstall or an explicit handback releases only the live claim and keeps the generation -ceiling, so a later grant cannot be mistaken for the old one. A runtime still attached to an -old package-owned registration is only attachable as a guest until an ownership-aware CLI -records protocol support; the shell must not treat that attachment as durable takeover consent. - -`desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, -before the tray is built so its checkbox reads the resulting state. A menu bar app -that is not running has no menu bar item, so leaving autostart off by default left an -installed app absent after a reboot. The marker in the app config directory is written -before the login item is touched and is never removed, so a user who turns the setting -off keeps it off; writing it afterwards would let a failed enable retry on every launch. -The behaviour is not macOS-only. The autostart plugin implements the macOS and Linux -registrations and reads or disables the current-user Windows Run entry. Shell enables -on Windows write a quoted executable path followed by `--autostart`, preserving the -plugin's registration name and Task Manager explicit-enable semantics. A Windows-specific -launch-origin migration marker revisits older registrations once, only when already -enabled; entries disabled in the tray or Task Manager stay disabled. - -The macOS tray item and the macOS side of this migration judge the login item the way Startup -safety does; see [Desktop Start at Login](desktop-start-at-login.md). - -The WidgetKit extension in `app/` needs three things that Xcode's app-extension target -would supply on its own, and SwiftPM has no such target: `@main` on -`OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and -`-application-extension` — the compiler spelling of `APPLICATION_EXTENSION_API_ONLY` — all -in `app/Package.swift`. Any one missing yields a widget that never appears: without -`@main` the linker drops the bundle and the extension registers with nothing to offer, and -without the entry override ExtensionFoundation traps during bootstrap. Nothing observable -distinguishes these from a working widget, because the bundle still builds, signs and -registers. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to register -an unsandboxed plug-in at all — which is why the shell writes its snapshot into the -extension's own container rather than a shared App Group, which ad-hoc signing cannot use. - -`desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone -Bun targets and prepares the external binary plus dashboard resources used by -Tauri. Generated files under desktop/src-tauri/binaries/ and -desktop/src-tauri/resources/ remain ignored. +Consent subject comparison, generation ceilings and relaunch reuse are described in +[Desktop supervision](ops/desktop-supervision.md#desktop-runtime-ownership-acceptance). ### Packaged native keyring binding @@ -442,6 +323,11 @@ resolution and never probe beside the shared Bun or Node executable. Compiled in their asset root from the executable's canonical real path, so a symlinked launcher still finds the addon shipped with the real binary. +`desktop/scripts/prepare-sidecar.ts` maps Rust target triples to the standalone +Bun targets and prepares the external binary plus dashboard resources used by +Tauri. Generated files under desktop/src-tauri/binaries/ and +desktop/src-tauri/resources/ remain ignored. + The macOS bundle verifier launches the signed sidecar from a disposable unrelated directory and requires its bounded, load-only keyring probe to expose both native constructors. It does not read or write an OS credential, which would make an ad-hoc CI identity depend on a consent dialog. @@ -554,6 +440,18 @@ The schema version is `1`; the Rust writer refreshes it every five minutes after immediate first write. The WidgetKit appex reads this privacy-safe file and performs no network access. +The WidgetKit extension in `app/` needs three things that Xcode's app-extension target +would supply on its own, and SwiftPM has no such target: `@main` on +`OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and +`-application-extension` — the compiler spelling of `APPLICATION_EXTENSION_API_ONLY` — all +in `app/Package.swift`. Any one missing yields a widget that never appears: without +`@main` the linker drops the bundle and the extension registers with nothing to offer, and +without the entry override ExtensionFoundation traps during bootstrap. Nothing observable +distinguishes these from a working widget, because the bundle still builds, signs and +registers. `com.apple.security.app-sandbox` is also mandatory — `pkd` refuses to register +an unsandboxed plug-in at all — which is why the shell writes its snapshot into the +extension's own container rather than a shared App Group, which ad-hoc signing cannot use. + ## The tray icon opens a usage popup `app/Sources/NativeTray/` defines the macOS SwiftUI display model and AppKit panel library. diff --git a/structure/manifest.json b/structure/manifest.json index 273909d8ebf..adfdfff2aac 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -927,6 +927,33 @@ "src/update/" ] }, + { + "path": "ops/desktop-supervision.md", + "tier": 6, + "title": "Desktop Supervision", + "scope": "Durable Desktop ownership read, takeover-consent handling, live-supervision evidence, the Windows probe's trusted system-directory acquisition, and the supervision latch blocking contract consumed by service and update guards.", + "documents": [ + "bin/ocx.mjs", + "desktop/src-tauri/src/claim.rs", + "desktop/src-tauri/src/first_run.rs", + "desktop/src-tauri/src/identity.rs", + "desktop/src-tauri/src/ownership.rs", + "desktop/src-tauri/src/resolve.rs", + "desktop/src-tauri/src/runtime_stop.rs", + "desktop/src-tauri/src/startup.rs", + "src/cli/desktop-runtime-guidance.ts", + "src/cli/update-restart.ts", + "src/service/cli.ts", + "src/service/desktop-command-guard.ts", + "src/service/desktop-startup.ts", + "src/service/desktop-supervision.d.mts", + "src/service/desktop-supervision.mjs", + "src/service/orchestration.ts", + "src/service/repair.ts", + "src/update/index.ts", + "src/update/restart-ownership.ts" + ] + }, { "path": "ops/service-logging.md", "tier": 6, diff --git a/structure/ops/desktop-supervision.md b/structure/ops/desktop-supervision.md new file mode 100644 index 00000000000..440e32578e1 --- /dev/null +++ b/structure/ops/desktop-supervision.md @@ -0,0 +1,177 @@ +# Desktop supervision + +This document describes durable Desktop ownership, takeover consent and live +supervision. `src/service/desktop-supervision.mjs` (types in +`src/service/desktop-supervision.d.mts`) owns the platform supervision probe, +including Windows system-directory acquisition, and `createSupervisionLatch`; +durable ownership and consent remain with the owners described below. + +## Runtime ownership, from the app's side + +`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted +once into the app's config directory and never rewritten, exclusively so two launches racing each +other answer to the same one. It exists because the recorded claim names the owning *installation*, +so the app needs a value of its own to compare against; an id kept only in the shared record would +be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another +installation's. The cost is that a reinstall which keeps the directory keeps its consent and one +that loses it asks again. + +`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the +comparison, all of which are defined by +[background-service runtime ownership](../runtime.md#background-service-runtime-ownership) and not +here. The shell does not read the record: resolving a claim means reading every state path and +failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second +weaker implementation of a question core already answers is the mistake this tree has made before. +The bundled CLI answers ownership and takeover compatibility through `ocx resolve --json` +(`desktop/src-tauri/src/resolve.rs`, inside the launch sequence that +`desktop/src-tauri/src/startup.rs` drives). +It also answers how the live runtime's version compares to the bundled CLI's +(`versionSkew.relation`; future relation strings read as unknown without discarding the live answer), and the shell acts on the direction instead of reparsing the +warning: `proxy-newer` makes a supported takeover a downgrade, so the run attaches as a +guest with the versions, downgrade risk and verbatim CLI warning rather than asking consent to it. Every other guest path — held +consent, an unreadable owner, a blocked takeover, a declined prompt, a recovery — appends +the CLI's warning to its phase detail, and the consent panel shows it beside the subject. +Unknown ownership never means "nobody owns it". A supported offer shows the endpoint, home +and owner. After consent, the shell resolves again and refuses a changed answer without +invoking stop. It passes the approved token, endpoint and PID to the CLI's opt-in guarded +stop (`desktop/src-tauri/src/runtime_stop.rs`). +That command checks the evidence and manager-to-PID binding under its ownership mutation lease +before action; it stops the manager or approved PID, waits within a bounded deadline for PID +exit and endpoint silence, and only then requires definitive manager inactivity. A manager +that remains active or becomes unreadable produces terminal `manager-still-active`, not a stop +receipt. The shell also treats `approval-changed`, unreadable output and child timeout as +terminal before its own silence wait or claim. Only parsed `stopped` or validated exit-79 +`history-incomplete` proceeds to the refused-probe receipt and `ocx service claim` +(`desktop/src-tauri/src/claim.rs`), which rechecks the approved subject and compatibility. +Declining attaches as a guest; a failed claim does not pretend a stopped runtime was restored. + +Live supervision is a third independent fact, alongside liveness and durable ownership. +On macOS and Linux, the read-only probe verifies that the runtime's direct parent is +`opencodex-desktop` and the child executable is that app's sibling `ocx`; two complete +process snapshots must agree. The target runtime PID, `ocx.pid`, and the PID in +`runtime-port.json` must agree wherever present; mismatches make supervision unknown. +The attestation secret is never included in supervision evidence. On Windows, `ocx.pid` and `runtime-port.json` must both name the runtime PID; its direct parent must be a live `opencodex-desktop.exe`; both images must have drive-qualified paths, with the runtime matching the Desktop image's sibling `ocx.exe` case-insensitively; parent and child must share a session; and the parent must have been created strictly before the child. +Two snapshots must agree. Each acquisition is one bounded PowerShell `Win32_Process` +read of the runtime row and its parent's row, emitted as JSON. Both snapshots share a +six-second scheduling budget. Before each acquisition, the caller requires at least +250 ms remaining and assigns the PowerShell subprocess a timeout of at most three +seconds. Synchronous system-directory resolution occurs before that subprocess starts +and has no separate timeout, so these are not hard wall-clock bounds for the complete +inspection. Process evidence is +re-read on every probe; only a successfully resolved system directory is memoized, and +only for the process lifetime, so a failed resolution keeps the retry-on-next-call +recovery contract. + +The directory is resolved before the child spawns and no longer comes from the +environment. `SystemRoot`, `windir` and `PATH` are caller-supplied on this path, and +the fixed `C:\Windows` fallback they used to feed was guesswork, so either could answer +a wrong or tampered `powershell.exe`. `src/service/desktop-supervision.mjs` picks by +runtime instead: on Bun, `bun:ffi` calls kernel32 `GetSystemDirectoryW` directly; on +Node, it derives the directory from `process.report.getReport().sharedObjects` and +accepts it only when `kernel32.dll` and `ntdll.dll` each appear exactly once, +drive-qualified, under one shared parent and below the report's 260-character +module-name buffer — a namesake at the limit may already be truncated and is refused — +and only after the candidate passes F_OK existence checks for both DLLs and +`WindowsPowerShell\v1.0\powershell.exe`. The report object also carries the process's +environment values, so the resolver reads only `.sharedObjects` and never logs, +serializes, stores or forwards the report. `bin/ocx.mjs` imports this module under +Node >=18, so it keeps no TypeScript or Bun-only imports; `bun:ffi` is reached through +`createRequire` inside the Bun branch alone. The Node lane assumes the diagnostic +report enumerates each loaded DLL by path — `getReport` has existed since Node 11.8.0 +(, field layout in +) — and is verified positively only on Node +24.18.0 / Windows x64 and Bun 1.4.4 / Windows x64; the focused tests inject the +resolver and PowerShell output, and Linux CI runs no Windows acquisition, so neither is +native evidence. WoW64, ARM64, a real Desktop-positive supervisor, compiled packaging +and live installs remain unverified. + +The spawned PowerShell receives a minimal environment — `SystemRoot` and `WINDIR` set +to the system directory's parent, `PATH` to the system directory alone and a fixed +`PATHEXT` — with nothing else inherited. Its script disables module autoloading and +imports `Microsoft.PowerShell.Utility` before `CimCmdlets`, because the CimCmdlets +manifest calls `Set-Alias`, which autoload-off cannot supply; the query and the JSON +conversion stay module-qualified. + +Missing pid records, an unavailable or ambiguous report, a duplicated or truncated +namesake, an FFI failure and every other inconclusive read all answer `unknown` — +retaining `desktopSeen` when a Desktop image was observed — never proven `none`, and +callers do not fail closed on plain `unknown`. A Desktop image seen inside otherwise +failed evidence keeps the block; without it, an inconclusive read can still allow an +action a complete probe would have vetoed, which is the early-warning residual the +guard table below records. Windows Start at Login is not verified, so live supervision +never grants startup protection there. + +Command guards use this live evidence independently of login registration and durable ownership. +Each guarded service command, Node/Bun update run and dashboard restart decision uses one +`createSupervisionLatch()` from `src/service/desktop-supervision.mjs`: `desktop` or +`unknown` with `desktopSeen: true` blocks; only a later positive `none` clears it. A later +plain `unknown` or `unsupported` cannot clear a prior block. Without a prior block, plain +unknown and unsupported preserve the existing durable-owner and liveness decisions. +Service install, repair, start and restart refuse before mutation, including registration staging. +Update gates re-read supervision before stop, package replacement, restoration and recovery; +they refuse even for a separate npm/Bun install. Use the app updater for its bundle, or quit +Desktop before updating the CLI install. Service stop and uninstall remain available. +`src/service/desktop-command-guard.ts` carries that refusal for `src/service/cli.ts`, +`src/service/orchestration.ts` and `src/service/repair.ts`; the update lanes take the same +latch in `src/update/index.ts` and `src/update/restart-ownership.ts`, the update-restart +surface in `src/cli/update-restart.ts`, and stop guidance in +`src/cli/desktop-runtime-guidance.ts`. +These decisions follow [runtime ownership](../runtime.md#background-service-runtime-ownership). + +The bypass ledger classifies these guards as **early warning**, not enforcement: + +| Guard | Surface / tier | Known limit and residual risk | Pass-through assertion | +| --- | --- | --- | --- | +| Service install/repair/start/restart | CLI before mutation / E3 | Older PATH CLIs or inconclusive evidence without Desktop seen can allow a second supervisor. | With no prior block, none/unsupported preserve ordinary command behavior. | +| Node/Bun update, including pre-stop and recovery | CLI / E3 | Same limits; Desktop can start after the last check inside the stop window, leaving a short race. | None preserves the ordinary update decision. | +| Dashboard restart veto | Server route / E3 | Older runtimes lack this guard. | None preserves the ordinary restart decision. | +| Stop guidance | Notice only / E1 | Desktop may restart the stopped proxy. | `stop --json` neither probes nor emits the notice. | + +When ownership is `none`, `src/service/desktop-startup.ts` can report +`supervisor: { supervisorPid, runtimePid, app }` with `owned: false`. Restart safety +credits this live supervision only when that same app's Start at Login registration is +verified, the proxy is running, and the diagnostic is fresh and viable. This evidence +never creates or changes an ownership claim; unknown ownership and CLI-owned claims do +not enter the unowned diagnostic branch. Unverified login registration produces Desktop +recovery guidance with no recommended shell command. + +Older runtimes cannot report this evidence. When an attested live startup verdict has +no desktop diagnostic but a local probe verifies Desktop supervision of that live PID, +status and doctor use the shared selector's local verdict; status JSON records +`startupSource: "local-supervision-override"`. The service summary uses that selected +verdict too. On Windows, the local reading may be owned or unowned but must bind to the same runtime and supervisor PIDs; macOS and Linux require an unowned local reading. A live verdict that already carries desktop diagnostics remains authoritative. + +### Desktop runtime ownership acceptance + +The consent surface labels the exact ownership subject it is about to record. A relaunch of the +same desktop installation reuses consent when the recorded `owner` and app-local `installId` +still match; the generation is deliberately not part of that comparison, because the recorded +claim this app holds is its own consent, not a freshness token. +Package update and service repair also preserve that grant and its generation ceiling through +`preservedConsent`; they do not perform a new subject comparison. The write path is stricter than the relaunch +path: a different `owner`, different `installId`, moved `consentGeneration` or unreadable ownership record is not reuse +there — a pending approval is revalidated against the full +subject, so a grant, a release or a re-grant that moved the generation between the prompt and +the write cannot be claimed by the stale approval. On relaunch the same list narrows to the +comparison itself: a different `owner` or `installId` makes the app ask again, and an +unreadable record refuses closed rather than reading as unowned. +Uninstall or an explicit handback releases only the live claim and keeps the generation +ceiling, so a later grant cannot be mistaken for the old one. A runtime still attached to an +old package-owned registration is only attachable as a guest until an ownership-aware CLI +records protocol support; the shell must not treat that attachment as durable takeover consent. + +`desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, +before the tray is built so its checkbox reads the resulting state. A menu bar app +that is not running has no menu bar item, so leaving autostart off by default left an +installed app absent after a reboot. The marker in the app config directory is written +before the login item is touched and is never removed, so a user who turns the setting +off keeps it off; writing it afterwards would let a failed enable retry on every launch. +The behaviour is not macOS-only. The autostart plugin implements the macOS and Linux +registrations and reads or disables the current-user Windows Run entry. Shell enables +on Windows write a quoted executable path followed by `--autostart`, preserving the +plugin's registration name and Task Manager explicit-enable semantics. A Windows-specific +launch-origin migration marker revisits older registrations once, only when already +enabled; entries disabled in the tray or Task Manager stay disabled. + +The macOS tray item and the macOS side of this migration judge the login item the way Startup +safety does; see [Desktop Start at Login](../desktop-start-at-login.md). diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 7182595e80a..309caeb0697 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -427,8 +427,7 @@ Plain unknown/unsupported evidence preserves existing decisions unless an earlie `src/update/runtime-ownership.mjs` denies all three update authorities and failed-update recovery when blocked. A restoration refusal after a completed package swap does not undo that update. Service install, repair, start and restart refuse before mutation, including Windows XML staging, -when live Desktop supervision is verified. Windows probes use bounded process snapshots; `ocx stop` shows the Desktop notice. Limits are listed -in [desktop supervision](../desktop-shell.md#runtime-ownership-from-the-apps-side). +when live Desktop supervision is verified; `ocx stop` shows the Desktop notice. Windows probe acquisition and residual limits: [desktop supervision](desktop-supervision.md#runtime-ownership-from-the-apps-side). The lease is released before any service-manager-mediated start (`service repair` in recovery or the post-install refresh): the manager's `ocx start` child cannot join it, and holding it through the repair's health wait keeps that proxy from starting (#5760). The recovery decision @@ -595,6 +594,6 @@ src/update/async-check.ts uses the existing owner-bound registry target with a b The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot. -Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; its protection requires fresh matching identity, enabled login registration, and live supervision. Only ownership `none` enters the unowned branch: verified supervision adds `supervisor: { supervisorPid, runtimePid, app }` with `owned: false`, and protection requires that same app's verified login registration and a fresh, running, viable diagnostic. Unknown ownership and CLI-owned claims receive no supervision credit. The independent process evidence requires two matching snapshots and correlation of the target, PID-file and runtime-port PIDs, as described in [desktop supervision](../desktop-shell.md#runtime-ownership-from-the-apps-side); it never becomes ownership. An unloaded macOS job receives credit only from two matching plist/target/enabled snapshots; a boolean plist Disabled default combines with explicit launchctl overrides, and malformed or changing evidence refuses credit. On macOS, ownership and PID are re-read before crediting an owned result; on Linux, the complete owned evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. +Desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, the login registration (macOS: launchd; Linux: the `~/.config/autostart/OpenCodex.desktop` entry that auto-launch writes, credited only when `XDG_CONFIG_HOME` is unset or `~/.config`; it must launch an unquoted absolute `opencodex-desktop` with exactly `--autostart`, without conditional keys, and not be hidden or disabled) and exact parent/child executable paths (Linux: `/proc//exe` and the parent pid from `/proc//stat`) without mutating them. A durable desktop claim survives a failed identity or supervision check; its protection requires fresh matching identity, enabled login registration, and live supervision. Only ownership `none` enters the unowned branch: verified supervision adds `supervisor: { supervisorPid, runtimePid, app }` with `owned: false`, and protection requires that same app's verified login registration and a fresh, running, viable diagnostic. Unknown ownership and CLI-owned claims receive no supervision credit. The independent process evidence requires two matching snapshots and correlation of the target, PID-file and runtime-port PIDs, as described in [desktop supervision](desktop-supervision.md#runtime-ownership-from-the-apps-side); it never becomes ownership. An unloaded macOS job receives credit only from two matching plist/target/enabled snapshots; a boolean plist Disabled default combines with explicit launchctl overrides, and malformed or changing evidence refuses credit. On macOS, ownership and PID are re-read before crediting an owned result; on Linux, the complete owned evidence chain is read twice and both reads must agree. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index a53110ea3cf..fb9889d199c 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -24,6 +24,7 @@ const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); const COMPATIBILITY = repoPath("src/service/ownership-compatibility.ts"); const DESKTOP_SHELL_DOC = repoPath("structure/desktop-shell.md"); +const DESKTOP_SUPERVISION_DOC = repoPath("structure/ops/desktop-supervision.md"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -35,6 +36,7 @@ describe("desktop install identity", () => { const state = code(STATE); const compatibility = code(COMPATIBILITY); const desktopShellDoc = readFileSync(DESKTOP_SHELL_DOC, "utf8"); + const desktopSupervisionDoc = readFileSync(DESKTOP_SUPERVISION_DOC, "utf8"); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -136,13 +138,19 @@ describe("desktop install identity", () => { }); test("the desktop acceptance text names the consent reuse boundaries", () => { - expect(desktopShellDoc).toContain("Desktop runtime ownership acceptance"); - expect(desktopShellDoc).toMatch(/same desktop installation reuses consent/i); - expect(desktopShellDoc).toMatch(/Package update and service repair also preserve that grant/i); - expect(desktopShellDoc).toMatch(/different `owner`, different `installId`, moved\s+`consentGeneration`/i); - expect(desktopShellDoc).toMatch(/unreadable ownership record is not reuse/i); - expect(desktopShellDoc).toMatch(/Uninstall or an explicit handback releases only the live claim/i); - expect(desktopShellDoc).toMatch(/old package-owned registration is only attachable as a guest/i); + // The acceptance contract moved to ops/desktop-supervision.md; the shell doc must keep + // both old anchors and their forwarding links so inbound references still resolve. + expect(desktopShellDoc).toContain("## Runtime ownership, from the app's side"); + expect(desktopShellDoc).toContain("### Desktop runtime ownership acceptance"); + expect(desktopShellDoc).toContain("ops/desktop-supervision.md#runtime-ownership-from-the-apps-side"); + expect(desktopShellDoc).toContain("ops/desktop-supervision.md#desktop-runtime-ownership-acceptance"); + expect(desktopSupervisionDoc).toContain("### Desktop runtime ownership acceptance"); + expect(desktopSupervisionDoc).toMatch(/same desktop installation reuses consent/i); + expect(desktopSupervisionDoc).toMatch(/Package update and service repair also preserve that grant/i); + expect(desktopSupervisionDoc).toMatch(/different `owner`, different `installId`, moved\s+`consentGeneration`/i); + expect(desktopSupervisionDoc).toMatch(/unreadable ownership record is not reuse/i); + expect(desktopSupervisionDoc).toMatch(/Uninstall or an explicit handback releases only the live claim/i); + expect(desktopSupervisionDoc).toMatch(/old package-owned registration is only attachable as a guest/i); }); test("service refresh preserves consent and handback releases only the live claim", () => { diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index f6a13112add..c080b10a866 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -231,6 +231,7 @@ describe("GET /api/settings", () => { test("reports redacted codexRuntime diagnostics and clamp correlation", async () => { const { chmodSync } = await import("node:fs"); const { + peekCodexRuntimeProcessCache, persistEffortClamp, resetCodexRuntimeResolveCacheForTests, resolveCodexRuntimeAsync, @@ -261,7 +262,12 @@ describe("GET /api/settings", () => { process.env.PATH = ""; // Settings serve the runtime stale-while-revalidate; land the probe first so this // asserts the validated projection rather than the cold deferred answer. - await resolveCodexRuntimeAsync(); + const resolveStarted = performance.now(); + const resolved = await resolveCodexRuntimeAsync(); + const resolveMs = Math.round(performance.now() - resolveStarted); + // Read-only peek (never a probe) so the version assertion below can report + // whether the memo the GET is expected to serve was actually published. + const cachePeek = peekCodexRuntimeProcessCache(); const body = await (await getSettings(baseConfig()))!.json() as { codexRuntime?: { path?: string; @@ -276,7 +282,38 @@ describe("GET /api/settings", () => { // OPENCODEX_HOME lives under the OS user profile; username must stay redacted on all OS. expect(body.codexRuntime?.path?.toLowerCase()).not.toMatch(/[/\\]users[/\\][^/\\[\]]+[/\\]/i); expect(body.codexRuntime?.path?.toLowerCase()).not.toContain("alice"); - expect(body.codexRuntime?.version).toBe("0.133.0"); + // Failure-only context for the macOS 0.133.0 -> null control result. The + // message renders only when this assertion fails and stays path-free: + // fixed reason buckets instead of raw output, and no commands or env. + const knownReasons = new Set([ + "path does not exist", + "program not found (ENOENT)", + "not a spawnable Codex launcher on this platform", + "Codex app bundle identity mismatch or unavailable", + "unrecognized --version output", + "probe sandbox unavailable", + ]); + const probeReasonSummary = (reason: string): string => { + if (reason.startsWith("failed --version")) { + return reason.includes("deadline") ? "failed --version (deadline)" : "failed --version"; + } + return knownReasons.has(reason) ? reason : "other"; + }; + const diagnostics = JSON.stringify({ + resolveMs, + resolved: { + source: resolved.runtime.source, + version: resolved.runtime.version, + failures: resolved.failures.map(item => ({ source: item.source, reason: probeReasonSummary(item.reason) })), + }, + cache: { kind: cachePeek.kind, epoch: cachePeek.epoch }, + projection: { + source: body.codexRuntime?.source ?? null, + version: body.codexRuntime?.version ?? null, + warningPresent: typeof body.codexRuntime?.warning === "string", + }, + }); + expect(body.codexRuntime?.version, `codexRuntime diagnostics: ${diagnostics}`).toBe("0.133.0"); expect(body.codexRuntime?.source).toBe("environment"); expect(body.codexRuntime?.catalogClamp).toEqual({ active: true, diff --git a/tests/service/service-desktop-supervision-windows.test.ts b/tests/service/service-desktop-supervision-windows.test.ts index 57df12156bb..4c5906a514c 100644 --- a/tests/service/service-desktop-supervision-windows.test.ts +++ b/tests/service/service-desktop-supervision-windows.test.ts @@ -8,6 +8,7 @@ import { assertNoDesktopSupervision, desktopServiceCommandRefusal } from "../../ import { desktopStartupOwnership, diagnoseDesktopStartup, diagnoseWindowsDesktopStartup } from "../../src/service/desktop-startup"; import { createSupervisionLatch, inspectDesktopSupervision, windowsPowerShellPath, windowsProcessPair, + windowsSystemDirectoryFromSharedObjects, type DesktopSupervisionDeps, type WindowsProcessRow, } from "../../src/service/desktop-supervision.mjs"; import type { ServiceOwnershipResolution } from "../../src/service/state"; @@ -166,30 +167,40 @@ test("one inspection shares a 6 s budget: slow successful reads pass, an exhaust expect(exhausted.state.reads).toBe(1); }); -test("the default reader runs one fixed PowerShell under the system root with a numeric filter", () => { - const calls: { command: string; args: string[]; timeout?: number }[] = []; +test("the default reader uses a trusted system directory, disables autoloading and sanitizes its environment", () => { + const calls: { command: string; args: string[]; timeout?: number; env?: NodeJS.ProcessEnv }[] = []; const output = JSON.stringify({ child: { pid: "100", parent: "200", exe: PROXY, session: "1", created: "638950000000000002" }, parent: { pid: "200", parent: "4", exe: APP, session: "1", created: "638950000000000001" }, }); - const run = (command: string, args: string[], timeout?: number) => { calls.push({ command, args, timeout }); return output; }; - expect(windowsProcessPair(100, run, { env: { SystemRoot: "D:\\Win" }, timeoutMs: 1_234 })).toEqual({ + const run = (command: string, args: string[], timeout?: number, env?: NodeJS.ProcessEnv) => { + calls.push({ command, args, timeout, env }); return output; + }; + expect(windowsProcessPair(100, run, { resolveSystemDirectory: () => "D:\\Win\\System32", timeoutMs: 1_234 })).toEqual({ child: { pid: 100, parent: 200, exe: PROXY, session: 1, created: "638950000000000002" }, parent: { pid: 200, parent: 4, exe: APP, session: 1, created: "638950000000000001" }, }); expect(calls[0]!.command).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); expect(calls[0]!.args.slice(0, 4)).toEqual(["-NoProfile", "-NoLogo", "-NonInteractive", "-Command"]); expect(calls[0]!.args[4]).toContain("-Filter 'ProcessId=100'"); + expect(calls[0]!.args[4]).toContain("$PSModuleAutoLoadingPreference='None'"); + // Utility must load before CimCmdlets: CimCmdlets' own import calls Set-Alias and autoload is off. + expect(calls[0]!.args[4]!.indexOf("Microsoft.PowerShell.Utility.psd1")) + .toBeLessThan(calls[0]!.args[4]!.indexOf("CimCmdlets.psd1")); + expect(calls[0]!.args[4]).toContain("CimCmdlets\\Get-CimInstance"); + expect(calls[0]!.env).toEqual({ + SystemRoot: "D:\\Win", WINDIR: "D:\\Win", PATH: "D:\\Win\\System32", PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); expect(calls[0]!.timeout).toBe(1_234); - for (const env of [{}, { SystemRoot: "Windows" }, { SystemRoot: "\\\\server\\share" }]) { - expect(windowsPowerShellPath(env)).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - } + expect(windowsPowerShellPath(() => "C:\\Windows\\System32")) + .toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); for (const pid of [0, -1, 1.5, Number.NaN]) expect(() => windowsProcessPair(pid, run)).toThrow("invalid pid"); expect(calls).toHaveLength(1); }); test("through the default reader, a Desktop image with malformed evidence keeps desktopSeen; unreadable output does not", () => { - const base = { platform: "win32" as const, readPid: () => 100, readRuntimePortPid: () => 100 }; + const base = { platform: "win32" as const, readPid: () => 100, readRuntimePortPid: () => 100, + resolveWindowsSystemDirectory: () => "C:\\Windows\\System32" }; const malformed = JSON.stringify({ child: { pid: "100", parent: "200", exe: PROXY, session: "1", created: "638950000000000002" }, parent: { pid: "200", parent: "4", exe: APP, session: "1", created: "not-ticks" }, @@ -290,3 +301,81 @@ test("the ownership-only Windows projection runs no process probe", () => { expect(f.state.reads).toBe(0); }); +test("the Node sharedObjects resolver rejects duplicates, malformed, truncated and disagreeing enumerations", () => { + const dir = "C:\\Windows\\System32"; + const good = ["C:\\Windows\\System32\\KERNEL32.DLL", "c:\\windows\\system32\\ntdll.dll", "C:\\ocx\\node.exe"]; + // A consistent same-parent enumeration (mixed case, mixed separators) resolves. + expect(windowsSystemDirectoryFromSharedObjects(good).toLowerCase()).toBe(dir.toLowerCase()); + expect(windowsSystemDirectoryFromSharedObjects(["C:/Windows/System32/kernel32.dll", "C:\\Windows\\System32\\ntdll.dll"]).toLowerCase()) + .toBe(dir.toLowerCase()); + // Environment tampering cannot steer this resolver — it never reads process.env. + const saved = process.env.SystemRoot; + try { + process.env.SystemRoot = "D:\\Evil"; + expect(windowsSystemDirectoryFromSharedObjects(good).toLowerCase()).toBe(dir.toLowerCase()); + } finally { + if (saved === undefined) delete process.env.SystemRoot; else process.env.SystemRoot = saved; + } + // An unavailable report is the bounded unknown residual, not a success. + for (const loaded of [undefined, null, 42, "C:\\Windows\\System32"]) { + expect(() => windowsSystemDirectoryFromSharedObjects(loaded)).toThrow("trusted Windows resolver unavailable"); + } + // Same-directory duplicate namesakes — contradictions are rejected. + expect(() => windowsSystemDirectoryFromSharedObjects([...good, "C:\\Windows\\System32\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); + expect(() => windowsSystemDirectoryFromSharedObjects([...good, "D:\\x\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); + // Malformed namesakes (bare name, UNC, rooted, drive-relative) must not hide behind the format filter. + for (const namesake of ["kernel32.dll", "\\\\evil\\share\\kernel32.dll", "\\System32\\kernel32.dll", "C:kernel32.dll"]) { + expect(() => windowsSystemDirectoryFromSharedObjects([...good, namesake])).toThrow("trusted Windows resolver unavailable"); + } + // A namesake at or over the enumeration buffer limit may be truncated — reject rather than trust. + expect(() => windowsSystemDirectoryFromSharedObjects([...good, "D:\\" + "x".repeat(300) + "\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); + // kernel32 and ntdll must share one parent directory. + expect(() => windowsSystemDirectoryFromSharedObjects(["C:\\Windows\\System32\\kernel32.dll", "D:\\Other\\ntdll.dll"])) + .toThrow("trusted Windows resolver unavailable"); + // An incomplete enumeration (missing ntdll) cannot prove a directory. + expect(() => windowsSystemDirectoryFromSharedObjects(["C:\\Windows\\System32\\kernel32.dll"])) + .toThrow("trusted Windows resolver unavailable"); +}); + +test("the Node sharedObjects resolver drives the real probe path and fails closed when it rejects", () => { + const output = JSON.stringify({ + child: { pid: "100", parent: "200", exe: PROXY, session: "1", created: "638950000000000002" }, + parent: { pid: "200", parent: "4", exe: APP, session: "1", created: "638950000000000001" }, + }); + const calls: { command: string; args: string[]; timeout?: number; env?: NodeJS.ProcessEnv }[] = []; + const run = (command: string, args: string[], timeout?: number, env?: NodeJS.ProcessEnv) => { + calls.push({ command, args, timeout, env }); return output; + }; + // Positive: a consistent Node enumeration supplies the system directory for the real + // windowsProcessPair path — PowerShell runs at the derived location, not from env or PATH. + const loaded = ["C:\\Windows\\System32\\kernel32.dll", "c:\\windows\\system32\\ntdll.dll", "C:\\Program Files\\nodejs\\node.exe"]; + const deps = { + platform: "win32" as const, readPid: () => 100, readRuntimePortPid: () => 100, + resolveWindowsSystemDirectory: () => windowsSystemDirectoryFromSharedObjects(loaded), run, + } satisfies DesktopSupervisionDeps; + expect(inspectDesktopSupervision(deps)).toEqual({ kind: "desktop", runtimePid: 100, supervisorPid: 200, app: APP, proxy: PROXY }); + expect(calls.length).toBe(2); // two agreeing snapshots, one bounded probe each + for (const call of calls) { + expect(call.command).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(call.env?.PATH).toBe("C:\\Windows\\System32"); + } + // Negative: a rejecting enumeration never reaches PowerShell — the result is the + // bounded probe-failed unknown, never proven absence, and plain unknown stays nonblocking. + const refused = ["kernel32.dll"]; // malformed namesake — no drive-qualified path + const spy: typeof run = (command, args, timeout, env) => { calls.push({ command, args, timeout, env }); return output; }; + const before = calls.length; + const evidence = inspectDesktopSupervision({ ...deps, + resolveWindowsSystemDirectory: () => windowsSystemDirectoryFromSharedObjects(refused), run: spy }); + expect(evidence).toEqual({ kind: "unknown", reason: "probe-failed", desktopSeen: false }); + expect(calls).toHaveLength(before); + expect(createSupervisionLatch().observe(evidence)).toBe(false); + expect(planUpdateRuntimeHandling({ ownership: null, serviceInstalled: false, supervision: evidence }).mayReplacePackage).toBe(true); + // A throwing resolver fails identically at the pair level: execute is never invoked. + expect(() => windowsProcessPair(100, spy, { resolveSystemDirectory: () => { throw new Error("trusted Windows resolver unavailable"); } })) + .toThrow("trusted Windows resolver unavailable"); + expect(calls).toHaveLength(before); +}); +