diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 7aed87005e3..6ead0be8613 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -10,6 +10,8 @@ */ import { spawn, spawnSync } from "node:child_process"; import { createSupervisionLatch, inspectDesktopSupervision } from "../src/service/desktop-supervision.mjs"; +import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs"; +import { desktopCliRecordPath } from "../src/lib/desktop-cli-record.mjs"; import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs"; import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs"; import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; @@ -1018,6 +1020,36 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext(); +const desktopPlan = planDesktopCliHandoff({ selfPaths: [fileURLToPath(import.meta.url), process.execPath] }); +if (desktopPlan.kind === "error") { + if (desktopPlan.issue.startsWith("record-")) { + console.error(`opencodex: OpenCodex Desktop's terminal-command record at ${desktopCliRecordPath()} could not be used (${desktopPlan.issue}). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + } else { + console.error(`opencodex: Desktop CLI handoff refused (${desktopPlan.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + } + process.exit(1); +} +if (desktopPlan.kind === "handoff") { + const result = await runDesktopCliHandoff(desktopPlan, { + argv: process.argv.slice(2), env: inheritedEnv, proof: launchProof, context: launchContext, + }); + if (result.kind === "error") { + console.error(`opencodex: Desktop CLI handoff failed (${result.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`); + process.exit(127); + } + if (result.kind === "exit") { + if (result.signal) { + process.kill(process.pid, result.signal); + } else { + process.exit(result.code); + } + // Signal delivery is asynchronous; never resume package repair or Bun lookup. + await new Promise(() => {}); + } + // Only confirmed target ENOENT reaches the ordinary package path below. +} + if (process.argv[2] === "update" && installMethod === "mise") { if (installOwnership.owner) { console.error( @@ -1072,64 +1104,68 @@ const bun = bunRuntime.path; // billing and prevents it from redirecting the subscriber's OAuth bearer. // Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config // interpolation and provider settings legitimately read the project environment. -const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] - .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); -// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the -// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture -// the absolute form here, in the launcher, while the original cwd is still authoritative; -// resolving it later would silently reinterpret it against a different working directory. -// -// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver -// deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` -// would make the inspector treat it as an explicit path and stop searching PATH entirely. -const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" - ? process.env.CODEX_CLI_PATH - : null; -const preBunCodexCliPath = configuredCodexCliPath !== null - && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) - ? resolve(configuredCodexCliPath) - : configuredCodexCliPath; -const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; -const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; -const preBunCodexCliManagerRoots = Object.fromEntries( - CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { - const value = process.env[name]; - return typeof value === "string" && value !== "" ? [[name, value]] : []; - }), -); -const launchProof = randomBytes(32).toString("base64url"); -const launchContext = JSON.stringify({ - version: 1, - proof: launchProof, - anthropicEnvSlots: preBunAnthropicSlots, - codexCliInspectionEnv: codexCliUpdateInspection ? { - codexCliPath: preBunCodexCliPath, - path: preBunPath, - pathExt: preBunPathExt, - managerRoots: preBunCodexCliManagerRoots, - configDir: configDir(), - } : null, -}); -// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as -// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than -// from the live environment. Inheriting them again would spend the 32,767-character Windows -// environment block twice, so a large-but-valid shell environment could stop the Bun child -// from spawning and fail the command before it reports anything. Drop the duplicates for the -// one-shot inspection launch only; every other launch inherits the environment unchanged. -// Windows environment names are case-insensitive, but this spread produces an ordinary -// case-sensitive object, and a real Windows environment commonly spells the variable `Path`. -// Deleting only the canonical upper-case spelling would silently leave that copy behind and -// reintroduce the duplication this block exists to prevent, so match on the lowercase form. -const inheritedEnv = { ...process.env }; -if (codexCliUpdateInspection) { - const snapshotted = new Set( - ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), +function createNodeLaunchContext() { + const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] + .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); + // A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the + // ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture + // the absolute form here, in the launcher, while the original cwd is still authoritative; + // resolving it later would silently reinterpret it against a different working directory. + // + // A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver + // deliberately hands those to executable lookup along PATH. Rewriting it to `/codex` + // would make the inspector treat it as an explicit path and stop searching PATH entirely. + const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== "" + ? process.env.CODEX_CLI_PATH + : null; + const preBunCodexCliPath = configuredCodexCliPath !== null + && (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath)) + ? resolve(configuredCodexCliPath) + : configuredCodexCliPath; + const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null; + const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null; + const preBunCodexCliManagerRoots = Object.fromEntries( + CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => { + const value = process.env[name]; + return typeof value === "string" && value !== "" ? [[name, value]] : []; + }), ); - for (const name of Object.keys(inheritedEnv)) { - if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + const launchProof = randomBytes(32).toString("base64url"); + const launchContext = JSON.stringify({ + version: 1, + proof: launchProof, + anthropicEnvSlots: preBunAnthropicSlots, + codexCliInspectionEnv: codexCliUpdateInspection ? { + codexCliPath: preBunCodexCliPath, + path: preBunPath, + pathExt: preBunPathExt, + managerRoots: preBunCodexCliManagerRoots, + configDir: configDir(), + } : null, + }); + // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as + // proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than + // from the live environment. Inheriting them again would spend the 32,767-character Windows + // environment block twice, so a large-but-valid shell environment could stop the Bun child + // from spawning and fail the command before it reports anything. Drop the duplicates for the + // one-shot inspection launch only; every other launch inherits the environment unchanged. + // Windows environment names are case-insensitive, but this spread produces an ordinary + // case-sensitive object, and a real Windows environment commonly spells the variable `Path`. + // Deleting only the canonical upper-case spelling would silently leave that copy behind and + // reintroduce the duplication this block exists to prevent, so match on the lowercase form. + const inheritedEnv = { ...process.env }; + if (codexCliUpdateInspection) { + const snapshotted = new Set( + ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()), + ); + for (const name of Object.keys(inheritedEnv)) { + if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name]; + } } + return { launchProof, launchContext, inheritedEnv }; } -const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], { + +const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], { stdio: "inherit", // A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no // console to inherit. Without this flag Windows allocates a visible console for diff --git a/devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md b/devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md new file mode 100644 index 00000000000..125b806da13 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md @@ -0,0 +1,64 @@ +# 021 — wp2 execution plan (re-verified) + +wp2 implements 020 as amended by 000 (AM-1, AM-2) and 003 (AM-2 corrected, AM-5, AM-8). It runs on branch `codex/desktop-cli-handoff`, stacked on PR 1 (#6816, `codex/desktop-owned-path-cli` at bcbd0363be). This document and 003 win over 020 where they differ. + +## Stale check + +| 020 anchor | Current state on `origin/dev` 71c4ae3c8e + PR 1 | Action | +|---|---|---| +| `bin/ocx.mjs` update-help, `codexCliUpdateInspection`, mise/npm update branches, `resolveBun`, launch proof creation | Unchanged (1120 lines). #6807 (head 178f19a6f8) and #6809 (head f22a8b1ba5) are open and rebased on `dev`; both edit this file | Insert the handoff at the semantic point (after the inspection block, before the mise/npm update branch); rebase over #6807/#6809 when they land, anchoring on `const codexCliUpdateInspection` and the following mise `if` | +| `src/cli/status.ts` | #6802 merged (+70 lines: `selectStatusStartupHealth` returns `{ startup, startupSource }`, live supervision) | Add `cliCommand` as a separate additive field computed at `collectStatus` entry; do not touch `startupSource`/`serviceSummary` | +| `src/cli/doctor.ts` | #6802 changed restart-safety source and advice | New "ocx command selection" section after Paths, before response-state sections | +| `structure/runtime.md` | 600 lines (cap); #6802 changed 1 line | Edit the existing entrypoint row with zero line growth | +| `structure/ops/service-and-sidecars.md` | 541 → 541 (#6802 changed 4 lines) | +2 lines as planned | +| `src/service/managing-cli.ts`, `src/cli/launcher-context.ts` | Unchanged | Apply as planned | +| Record schema (wp1 actual) | Rust `Record`/`Bundle` use `camelCase` with `deny_unknown_fields`; fixtures in `tests/fixtures/desktop-cli-record/` (10 files, including `first-pending.json`) | TS reader consumes only `version`, `enabled`, `bundle.platform`, `bundle.kind`, `bundle.cliExecutable`, `pending`; tolerates other fields (the reader is not the writer) | + +## Amendments applied in wp2 + +- **AM-5:** the reader validates `bundle.kind` against the platform pair (`darwin`/`macos-app`, `win32`/`windows-install`, `linux`/`linux-deb`), refuses `.`/`..` segments, NUL, newline and paths over 4096 characters, and is tested against every file in `tests/fixtures/desktop-cli-record/` with the same expectations as the Rust test. `enabled:false` wins over `pending` (no target); enabled + pending is `record-pending`. Diagnostics report `cleanup-pending` for a disabled record with a pending journal. +- **AM-1:** reader bound stays 64 KiB, matching the writer cap. +- **AM-2 (corrected):** the launcher error for `record-invalid`, `record-too-large`, `record-unreadable` and `record-pending` reads: "opencodex: OpenCodex Desktop's terminal-command record at could not be used (). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1." No advice to delete the record. +- **AM-8:** after `spawn` fails with ENOENT, `lstat` the target; only a second ENOENT resumes the package path, otherwise print a fixed error and exit 127. Test: a target script whose interpreter is missing. + +## B worker split + +| Worker | Files | +|---|---| +| W4 handoff | NEW `src/lib/desktop-cli-record.mjs` + `.d.mts`, NEW `src/lib/desktop-cli-handoff.mjs` + `.d.mts`, MODIFY `bin/ocx.mjs` (hook + proof factory reuse), MODIFY `src/service/managing-cli.ts` (`OCX_NO_DESKTOP_HANDOFF=1` on internal probes), NEW `tests/cli/ocx-launcher-desktop-handoff.test.ts` | +| W5 diagnostics and docs | NEW `src/cli/cli-path-diagnostics.ts`, MODIFY `src/cli/status.ts`, `src/cli/doctor.ts`, NEW `tests/cli/cli-path-diagnostics.test.ts`, MODIFY `structure/runtime.md` (zero growth), `structure/cli-management.md`, `structure/ops/service-and-sidecars.md`, `docs-site/src/content/docs/getting-started/installation.md`, `docs-site/src/content/docs/reference/cli.md` | +| main | registrations of both new tests in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; integration | + +W5 imports the record reader from W4's `src/lib/desktop-cli-record.mjs` using the export names in 020 (`readDesktopCliRecord`, `desktopCliRecordPath`, `DESKTOP_CLI_RECORD_MAX_BYTES`, type `DesktopCliRecordRead`). + +## C checks + +| Command | Reads the change? | +|---|---| +| `bun test tests/cli/ocx-launcher-desktop-handoff.test.ts tests/cli/cli-path-diagnostics.test.ts` | Yes, both new files and their imports | +| `bun test tests/cli/ocx-launcher-runtime.test.ts tests/cli/ocx-launcher-source.test.ts tests/service/managing-cli.test.ts tests/cli/cli-status-json.test.ts tests/codex-integration/doctor.test.ts` | Yes, existing launcher/probe/status/doctor contracts around the hooks | +| `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Yes, registrations | +| `node --check bin/ocx.mjs`, `bun run typecheck`, `structure:check`, `privacy:scan`, file-size ratchet | Yes after staging | +| Manual: the dev-checkout `bin/ocx.mjs` run with an isolated `HOME` holding a valid record pointing at a fake executable prints the fake's output; with `OCX_NO_DESKTOP_HANDOFF=1` it does not | Yes, real launcher process | + +The A11 compiled check (AM-13) runs in wp3 against PR 1's shim, together with the security review of both PRs. + + +## Reflection (same architect, 01a11f35): ALIGNED + +Folded: + +- **Reader result type, fixed before B.** `DesktopCliRecordRead` is `{ state: "missing", path } | { state: "disabled", path, cleanupPending: boolean } | { state: "ready", path, record: { platform, kind, cliExecutable } } | { state: "invalid" | "unreadable", path, issue }`. W4 writes it in `.d.mts`; W5 consumes `cleanupPending` for the `cleanup-pending` diagnostic. +- **Fixture expectations map validity to consumption state**, not pass/fail: `valid-*` on its own host → `ready` (other hosts → `invalid`/`record-invalid`); `disabled-tombstone` → `disabled`, `cleanupPending:false`; `disabled-with-pending` → `disabled`, `cleanupPending:true`; `pending-enabled` and `first-pending` → `invalid`/`record-pending` (valid for Rust recovery, refused for handoff); `appimage-kind`, `relative-target`, `dotdot-target` → `invalid`/`record-invalid`. +- **Path rules match Rust exactly:** length counted in Unicode code points (`[...s].length`), Windows absolute means drive-rooted (`C:\\` or `C:/`) or UNC (`\\\\server\\share`) only; root-relative `\\ocx.exe` and drive-relative `C:ocx.exe` are refused. Extra boundary cases in the TS test: 4096 vs 4097 code points with an astral character, root-relative, drive-relative, UNC. +- **Manual smoke checks both sides:** with the record, `ocx --version` prints the fake target's marker; with `OCX_NO_DESKTOP_HANDOFF=1`, `ocx --version` prints the package version from `package.json` and exits 0. + + +## Audit (independent, 01a11f6b): NEAR-PASS + +Folded: + +- Every consumer uses the flattened reader result: `record.cliExecutable`, `record.platform`, `record.kind` (020's `record.bundle.cliExecutable` examples are superseded) in the handoff, diagnostics and test helpers; the real-launcher smoke is required in C because typecheck does not read `.mjs` bodies. +- Windows path rule restated to match Rust exactly: absolute means a drive root (`C:\\` / `C:/`) or a `\\\\` or `//` prefix; the server/share parts are not checked. TS boundary tests include `//server/share/ocx.exe` (accepted), `\\\\server` (accepted, same as Rust), `\\ocx.exe` (refused), `C:ocx.exe` (refused). +- After #6807/#6809 land, PR 2's rebase rereads `structure/runtime.md` (600-line cap), `structure/ops/service-and-sidecars.md`, `structure/cli-management.md` and the installation page, and reruns the launcher/managing-cli tests. + diff --git a/devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md b/devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md new file mode 100644 index 00000000000..8924ac77330 --- /dev/null +++ b/devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md @@ -0,0 +1,32 @@ +# 049 — wp4 outcome: both PRs on `dev` + +## Landed + +| PR | Squash on `dev` | Exact-head evidence | +|---|---|---| +| #6816 Desktop owns the `ocx` terminal command on PATH | b89bbfb083 | head 2103c18b4f (rebased on #6820 bb36029ef9). Cross-platform CI 37922442745 success on attempt 2: `macos 1/2` failed on the inherited codex-prompt-lock contender deadline (fixed on `dev` by #6808) and `windows 8/9` on an AUTH-11 15 s runner timeout; only those two jobs were rerun. Service lifecycle 37922442829 success on all three hosts. Combined tree `dev`+#6808+#6816: typecheck, structure:check, ratchet and layout guards pass. | +| #6818 Package launchers hand off to the Desktop `ocx`; status/doctor report PATH selection | this PR | Retargeted to `dev` after #6816 and rebased with `--onto`; final evidence is recorded on the PR. | + +Admin squash merges under the maintainer instruction to integrate through admin review on `dev`. Code and security reviews passed for both PRs (031, 032 and the PR 2 rounds recorded in 040). + +## Late fix in wp4 + +The CI-only failure of `disabled pending cleanup warns without selecting a target` (Linux `test 4/4`, `windows 9/9`) came from the test, not the reader. It reads a macOS record on every host, so Linux ran the macOS-only `ls -lde` ACL probe and Windows reported NTFS modes the POSIX owner/mode check rejects; both returned `record-unsafe`. The test now passes the same host-safe stats seam the handoff suite already uses and hands the read to the diagnostics. Verified on macOS and on Windows `mini` (13 pass, 2 skip). + +A `dev` regression from #6811 (`ocx stop` failing with `realpath ENOENT` when `~/.codex` is absent) held PR 1's Service lifecycle jobs red until lane L2 landed #6820. + +## Criteria + +| Criterion | Evidence | +|---|---| +| c1 app-only install gets `ocx` on PATH (macOS, Windows; deb unchanged) | #6816 Rust installer, shim and PATH writers; cargo tests on macOS (246) and Windows (191) | +| c2 Desktop `ocx` first in a new login shell; Windows user PATH order | rc-block ordering tests, `real_shell` ignored test (zsh, bash), HKCU Path order tests | +| c3 launcher handoff with recursion guard and escape hatch | #6818 `ocx-launcher-desktop-handoff.test.ts`, real-launcher smoke | +| c4 foreign-file refusal, idempotence, opt-out cleanup | Rust record/journal tests and TS record-reader safety tests | +| c5 local gates and exact-head CI | typecheck, structure:check, focused bun tests, cargo test/clippy, privacy:scan; CI above | +| c6 independent sol code and security review PASS | 032 and 040 | +| c7 admin squash merge with docs | b89bbfb083 and this PR, docs-site installation and CLI reference | + +## Not verified here + +Packaged-app click-through of the CLI page, fish on a real login, the Linux deb path, and Windows terminal pickup after a real registry write in a fresh session are left to release QA. No release, version bump, or promotion was done. diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 17cba0a6b3b..b79a8b6badc 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -7,6 +7,12 @@ opencodex installs two equivalent command names, `ocx` and `opencodex`. Both lau local HTTP server (built on Bun). Model requests go to the provider selected by routing; optional vision and web-search sidecars can also use your ChatGPT login when a routed model needs them. +## Desktop terminal command + +A stable OpenCodex Desktop install configures `ocx` for new terminals when the app starts. Open a new terminal and run `ocx status` or `ocx doctor` to inspect the first executable on PATH. On macOS and Linux deb installs, a managed block at the end of zsh, bash and fish startup files puts `~/.opencodex-desktop/bin` first on PATH and uses the Desktop-owned shim. Windows puts the bundled `ocx.exe` install directory first in the user `Path`. Existing terminals, aliases, shell caches and later PATH changes can still select another command; a conflicting Windows system PATH entry can also win. Use the app’s Terminal command settings to repair, disable or remove this configuration. AppImage is excluded. + +On macOS and Linux, a current npm/pnpm package launcher that still runs delegates eligible commands to the Desktop CLI when its record is enabled and passes ownership, permission and file-safety checks. Windows has no package-launcher handoff: new terminals select the Desktop `ocx.exe` through user `Path` order. A `record-unsafe` error means the record or its directory failed a safety check. Open OpenCodex Desktop to repair the terminal command, or use `OCX_NO_DESKTOP_HANDOFF=1` for a package invocation on macOS/Linux. Package update/removal and internal inspection keep their existing path. `OCX_NO_DESKTOP_HANDOFF=1` suppresses that package handoff for one invocation; it does not change a direct Desktop shim or PATH configuration. + ## Prerequisites | Requirement | Why | diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 6fde5049a97..2f13ff35df5 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -254,6 +254,8 @@ latter need a live target. Before live operations, run `ocx ready --json` and version mismatch. `unknown` does not confirm matching builds. Offline help, local configuration and local Lab inspection do not require startup. +`ocx status --json` includes `cliCommand`: configured Desktop intent, the expected executable, observed PATH candidates, `pathFirst`, `desktopFirstOnPath`, issue codes, and `shellResolution: "unobserved"`. The human status report prints one command-selection line. `ocx doctor` adds an “ocx command selection” section: invalid, unsafe (`record-unsafe`) or enabled-pending records and missing/unusable Desktop targets fail the check, while disabled cleanup-pending records, PATH ordering conflicts and incomplete scans warn. `OCX_NO_DESKTOP_HANDOFF=1` suppresses package-launcher handoff for one invocation; it leaves the Desktop shim and PATH configuration in place. These read-only observations do not execute candidates, resolve parent-shell aliases/functions, or identify the proxy’s runtime owner. On Windows, package-launcher handoff is disabled; user `Path` order selects the Desktop `ocx.exe`. Status and doctor still read the record and report the first PATH candidate. A possible cmd current-directory candidate is reported separately from PATH order. + Output flags are per command. `doctor` rejects `--json` with exit 2; [`v2` (family reference)](/reference/cli/agents/) supports `--json` for local and `--live` targets. Even for JSON-capable management commands, API failures diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 04ebce80766..17431d65f33 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1306,6 +1306,7 @@ "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", + "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-tool-continuations.test.ts": "providers/ollama", diff --git a/src/cli/cli-path-diagnostics.ts b/src/cli/cli-path-diagnostics.ts new file mode 100644 index 00000000000..e5d5968b6c9 --- /dev/null +++ b/src/cli/cli-path-diagnostics.ts @@ -0,0 +1,132 @@ +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord, type DesktopCliRecordRead, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES } from "../lib/desktop-cli-record.mjs"; +import { redactUserPath } from "../lib/redact"; + +export type CliCommandIssue = Extract["issue"] + | "cleanup-pending" + | "desktop-target-missing" | "desktop-target-unusable" + | "expected-command-missing" | "expected-command-unusable" + | "path-missing" | "path-first-not-desktop" | "path-unreadable" + | "path-scan-truncated" | "windows-current-directory-shadow"; +export type CliPathCandidate = { + path: string; realpath: string; pathIndex: number; extension: string; +}; +export type CliPathDiagnostics = { + configured: boolean; + recordPath: string; + packageHandoff: "enabled" | "disabled-on-windows"; + recordState: DesktopCliRecordRead["state"]; + expectedExecutable: string | null; + handoffTarget: string | null; + candidates: CliPathCandidate[]; + pathFirst: CliPathCandidate | null; + desktopFirstOnPath: boolean | null; + currentDirectoryCandidate: CliPathCandidate | null; + shellResolution: "unobserved"; + issues: CliCommandIssue[]; +}; +export type CliPathObservation = + | { kind: "usable"; realpath: string } + | { kind: "missing" | "unusable" | "unreadable" }; +export type CliPathDiagnosticOptions = { + platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; cwd?: string; home?: string; + recordRead?: DesktopCliRecordRead; + observe?: (path: string) => CliPathObservation; +}; + +export function collectCliPathDiagnostics(options: CliPathDiagnosticOptions = {}): CliPathDiagnostics { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const api = platform === "win32" ? win32 : posix; + const cwd = options.cwd ?? process.cwd(); + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + const read = options.recordRead ?? readDesktopCliRecord({ platform, env, home, recordPath: desktopCliRecordPath({ platform, env, home }) }); + const configured = read.state === "ready"; + const target = configured && read.state === "ready" ? read.record.cliExecutable : null; + const expected = configured ? (platform === "win32" ? target : api.join(home, ".opencodex-desktop", "bin", "ocx")) : null; + const issues: CliCommandIssue[] = []; + const add = (issue: CliCommandIssue): void => { if (!issues.includes(issue)) issues.push(issue); }; + if (read.state === "invalid" || read.state === "unreadable") add(read.issue); + if (read.state === "disabled" && read.cleanupPending) add("cleanup-pending"); + const observe = options.observe ?? ((path: string): CliPathObservation => { + try { + if (!statSync(path).isFile()) return { kind: "unusable" }; + accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK); + return { kind: "usable", realpath: realpathSync(path) }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return { kind: code === "ENOENT" || code === "ENOTDIR" ? "missing" : code === "EACCES" ? "unusable" : "unreadable" }; + } + }); + const key = (path: string): string => platform === "win32" ? path.toLowerCase() : path; + const same = (a: string, b: string): boolean => key(a) === key(b); + const envValue = (name: string): string | undefined => platform === "win32" + ? env[Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) ?? name] : env[name]; + const path = envValue("PATH"); + const rawExtensions = platform === "win32" + ? (envValue("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(ext => /^\.[A-Za-z0-9]+$/.test(ext)) : []; + const uniqueExtensions = [...new Set(rawExtensions.map(ext => ext.toLowerCase()))]; + const extensions = platform === "win32" ? [...uniqueExtensions.slice(0, 32), ""] : [""]; + if (uniqueExtensions.length > 32) add("path-scan-truncated"); + const directories = path === undefined ? [] : path.split(platform === "win32" ? ";" : ":"); + if (directories.length > 256) add("path-scan-truncated"); + const candidates: CliPathCandidate[] = []; + const scan = (directory: string, pathIndex: number): CliPathCandidate[] => { + const result: CliPathCandidate[] = []; + const unquoted = platform === "win32" && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory; + for (const extension of extensions) { + const candidate = api.resolve(cwd, unquoted || ".", `ocx${extension}`); + const found = observe(candidate); + if (found.kind === "unreadable") add("path-unreadable"); + if (found.kind === "usable") result.push({ path: candidate, realpath: found.realpath, pathIndex, extension }); + } + return result; + }; + for (const [index, directory] of directories.slice(0, 256).entries()) candidates.push(...scan(directory, index)); + const pathFirst = candidates[0] ?? null; + const currentDirectoryCandidate = platform === "win32" ? scan(cwd, -1)[0] ?? null : null; + let expectedRealpath: string | null = null; + if (target) { + const found = observe(target); + if (found.kind === "missing") add("desktop-target-missing"); + else if (found.kind !== "usable") add("desktop-target-unusable"); + } + if (expected) { + const found = observe(expected); + if (found.kind === "missing") add("expected-command-missing"); + else if (found.kind !== "usable") add("expected-command-unusable"); + else expectedRealpath = found.realpath; + if (!pathFirst) add("path-missing"); + else if (!same(pathFirst.path, expected) && (!expectedRealpath || !same(pathFirst.realpath, expectedRealpath))) add("path-first-not-desktop"); + if (currentDirectoryCandidate && !same(currentDirectoryCandidate.path, expected) + && (!expectedRealpath || !same(currentDirectoryCandidate.realpath, expectedRealpath))) add("windows-current-directory-shadow"); + } + return { + configured, recordPath: read.path, packageHandoff: platform === "win32" ? "disabled-on-windows" : "enabled", + recordState: read.state, expectedExecutable: expected, handoffTarget: platform === "win32" ? null : target, + candidates, pathFirst, + desktopFirstOnPath: configured && !issues.includes("path-unreadable") && !issues.includes("path-scan-truncated") + ? Boolean(pathFirst && expected && expectedRealpath + && (same(pathFirst.path, expected) || same(pathFirst.realpath, expectedRealpath))) : null, + currentDirectoryCandidate, shellResolution: "unobserved", issues, + }; +} + +export function formatCliCommandLine(value: CliPathDiagnostics): string { + const first = value.pathFirst ? redactUserPath(value.pathFirst.path).replace(/[\x00-\x1f\x7f]/g, "?") : "none observed"; + return `ocx command: PATH first=${first}; Desktop=${!value.configured ? "not configured" : value.desktopFirstOnPath === null ? "unobserved" : value.desktopFirstOnPath ? "first" : "not first"}; ${value.packageHandoff === "disabled-on-windows" ? "package handoff=disabled on Windows (user Path selects Desktop); " : ""}shell=unobserved${value.issues.length ? `; issues=${value.issues.join(",")}` : ""}`; +} + +export function formatCliStatusHealthLabel(health: string, value: CliPathDiagnostics, local: boolean): string { + return `${health}${local ? " (local)" : ""}\n ${formatCliCommandLine(value)}`; +} + +export function cliCommandDoctorChecks(value: CliPathDiagnostics): { level: "OK" | "WARN" | "FAIL"; message: string }[] { + const hard = value.issues.some(issue => issue.startsWith("record-") || issue.startsWith("desktop-target-") || issue.startsWith("expected-command-")); + const recovery = value.issues.some(issue => issue.startsWith("record-")) + ? ` The terminal-command record at ${redactUserPath(value.recordPath).replace(/[\x00-\x1f\x7f]/g, "?")} could not be used. Open OpenCodex Desktop to repair the terminal command.${value.packageHandoff === "disabled-on-windows" ? "" : " Or run with OCX_NO_DESKTOP_HANDOFF=1."}${value.issues.includes("record-too-large") ? ` The record limit is ${DESKTOP_CLI_RECORD_MAX_BYTES} bytes.` : ""}` + : value.issues.includes("cleanup-pending") ? " Open OpenCodex Desktop to finish terminal-command cleanup." : ""; + return [{ level: hard ? "FAIL" : value.issues.length ? "WARN" : "OK", message: formatCliCommandLine(value) + recovery }]; +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index df152627648..53843c6c5ac 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -7,6 +7,7 @@ * it never sets proxy env, relocates state dirs, mutates quota, or changes * networking. See devlog/_plan/260630_wsl-account-autoswitch/30_*. */ +import { cliCommandDoctorChecks, collectCliPathDiagnostics } from "./cli-path-diagnostics"; import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -1325,6 +1326,12 @@ export async function runDoctor(args: string[] = []): Promise { console.log(` ${row.exists ? "ok " : "-- "} ${row.label}: ${row.path}${flags ? ` (${flags})` : ""}`); } + console.log("\nocx command selection"); + for (const check of cliCommandDoctorChecks(collectCliPathDiagnostics())) { + console.log(` ${check.level === "OK" ? "ok " : "!! "} ${check.message}`); + if (check.level === "FAIL") recordDoctorFailure(); + } + // Runs without the proxy on purpose: the worst accumulation happens when the proxy will // not start, which is exactly when the in-process periodic reclaim never ticks. const reclaimTemps = args.includes(RECLAIM_RESPONSE_TEMPS_FLAG); diff --git a/src/cli/status.ts b/src/cli/status.ts index efac2e65688..a840277c262 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -1,3 +1,4 @@ +import { collectCliPathDiagnostics, formatCliStatusHealthLabel, type CliPathDiagnostics } from "./cli-path-diagnostics"; import type { CodexMainAccountPolicyHealth } from "../oauth/health"; import { durableBunRuntime } from "../lib/bun-runtime"; import { existsSync, readFileSync } from "node:fs"; @@ -216,6 +217,7 @@ export type CliStatusJson = { * ignores the key is unaffected, and `proxyVersion` is null when nothing is live. */ versionSkew: VersionSkew; + cliCommand: CliPathDiagnostics; startupSource: "live" | "local" | "local-supervision-override"; }; @@ -727,6 +729,7 @@ export function missingCodexCatalogLines(missingCatalogPath: string | null): str /** `mainAccountPolicy`: only `--json` asks; the human report reads the same accounts once via OAuth health. */ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {}): Promise { + const cliCommand = collectCliPathDiagnostics(); const configDiagnostics = readConfigDiagnostics(); const config = configDiagnostics.config; const claudeDesktop = { @@ -916,7 +919,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { return { proxyLabel, - healthLabel: health.label, + healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected), json: { ...(mainAccountHardLock ? { mainAccountHardLock } : {}), schemaVersion: 1, @@ -979,6 +982,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = { // fact about this install, not about the Codex runtime, and filing it there would // print it under the wrong heading (#2701). versionSkew, + cliCommand, startupSource, }, }; diff --git a/src/lib/desktop-cli-handoff.d.mts b/src/lib/desktop-cli-handoff.d.mts new file mode 100644 index 00000000000..764095f5ae1 --- /dev/null +++ b/src/lib/desktop-cli-handoff.d.mts @@ -0,0 +1,20 @@ +import type { spawn } from "node:child_process"; +import type { accessSync, lstatSync, statSync } from "node:fs"; +import type { DesktopCliRecordRead, DesktopCliRecordIssue } from "./desktop-cli-record.mjs"; +export type DesktopHandoffIssue = DesktopCliRecordIssue | "target-invalid" | "target-self" | "target-unusable" | "spawn-failed"; +export type DesktopCliHandoffPlan = + | { kind: "continue"; reason: "excluded" | "missing" | "disabled" | "target-missing" | "windows-path-only" } + | { kind: "error"; issue: DesktopHandoffIssue } + | { kind: "handoff"; target: string }; +export type DesktopCliHandoffExit = + | { kind: "continue" } + | { kind: "error"; issue: "spawn-failed" } + | { kind: "exit"; code: number; signal: NodeJS.Signals | null }; +export function desktopHandoffExcluded(argv: string[], env?: NodeJS.ProcessEnv): boolean; +export function planDesktopCliHandoff(input?: { + argv?: string[]; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; + recordRead?: DesktopCliRecordRead; selfPaths?: (string | undefined)[]; +}, deps?: { stat?: typeof statSync; access?: typeof accessSync; realpath?: (path: string) => string }): DesktopCliHandoffPlan; +export function runDesktopCliHandoff(plan: Extract, launch: { + argv: string[]; env: NodeJS.ProcessEnv; proof: string; context: string; +}, deps?: { spawn?: typeof spawn; lstat?: typeof lstatSync; parent?: Pick; platform?: NodeJS.Platform }): Promise; diff --git a/src/lib/desktop-cli-handoff.mjs b/src/lib/desktop-cli-handoff.mjs new file mode 100644 index 00000000000..ece1a407ce6 --- /dev/null +++ b/src/lib/desktop-cli-handoff.mjs @@ -0,0 +1,92 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants, lstatSync, realpathSync, statSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { readDesktopCliRecord } from "./desktop-cli-record.mjs"; +import { isCodexCliUpdateInspectionArgv } from "../update/codex-cli-update-launch-policy.mjs"; + +const PROOF_PREFIX = "--ocx-internal-launch-proof="; + +export function desktopHandoffExcluded(argv, env = process.env) { + const args = argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + return env.OCX_NO_DESKTOP_HANDOFF === "1" + || ["update", "uninstall", "remove"].includes(args[0]) + || (args[0] ?? "").startsWith("__") + || isCodexCliUpdateInspectionArgv(["node", "ocx.mjs", ...args]); +} + +export function planDesktopCliHandoff(input = {}, deps = {}) { + const platform = input.platform ?? process.platform; + if (platform === "win32") return { kind: "continue", reason: "windows-path-only" }; + const argv = input.argv ?? process.argv.slice(2); + const env = input.env ?? process.env; + if (desktopHandoffExcluded(argv, env)) return { kind: "continue", reason: "excluded" }; + const read = input.recordRead ?? readDesktopCliRecord({ env, platform: input.platform }); + if (read.state === "missing" || read.state === "disabled") { + return { kind: "continue", reason: read.state }; + } + if (read.state !== "ready") return { kind: "error", issue: read.issue }; + const api = platform === "win32" ? win32 : posix; + const target = read.record.cliExecutable; + if (!api.isAbsolute(target)) return { kind: "error", issue: "target-invalid" }; + const stat = deps.stat ?? statSync; + const access = deps.access ?? accessSync; + const realpath = deps.realpath ?? realpathSync; + try { + if (!stat(target).isFile()) return { kind: "error", issue: "target-invalid" }; + access(target, platform === "win32" ? constants.F_OK : constants.X_OK); + const physical = realpath(target); + const key = path => platform === "win32" ? path.toLowerCase() : path; + for (const self of input.selfPaths ?? [process.argv[1], process.execPath]) { + if (!self) continue; + let own; + try { own = realpath(self); } + catch (error) { if (error?.code === "ENOENT") continue; throw error; } + if (key(own) === key(physical)) return { kind: "error", issue: "target-self" }; + } + return { kind: "handoff", target: physical }; + } catch (error) { + return error?.code === "ENOENT" + ? { kind: "continue", reason: "target-missing" } + : { kind: "error", issue: "target-unusable" }; + } +} + +export function runDesktopCliHandoff(plan, launch, deps = {}) { + const spawnChild = deps.spawn ?? spawn; + const parent = deps.parent ?? process; + const platform = deps.platform ?? process.platform; + const lstat = deps.lstat ?? lstatSync; + const spawnFailure = error => { + if (error?.code === "ENOENT") { + try { lstat(plan.target); } + catch (checkError) { if (checkError?.code === "ENOENT") return { kind: "continue" }; } + } + return { kind: "error", issue: "spawn-failed" }; + }; + const argv = launch.argv.filter(arg => !arg.startsWith(PROOF_PREFIX)); + const env = { ...launch.env, OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: launch.context }; + delete env.OCX_BUN_RUNTIME_SOURCE; + delete env.OCX_BUN_RUNTIME_PATH; + return new Promise(resolveExit => { + let child; + try { + child = spawnChild(plan.target, [`${PROOF_PREFIX}${launch.proof}`, ...argv], { + stdio: "inherit", shell: false, windowsHide: true, env, + }); + } catch (error) { + resolveExit(spawnFailure(error)); + return; + } + const forwarded = platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"]; + const handlers = forwarded.map(signal => { + const handler = () => { try { child.kill(signal); } catch { /* child already exited */ } }; + parent.on(signal, handler); + return [signal, handler]; + }); + const clear = () => { for (const [signal, handler] of handlers) parent.removeListener(signal, handler); }; + let done = false; + const finish = result => { if (done) return; done = true; clear(); resolveExit(result); }; + child.once("error", error => finish(spawnFailure(error))); + child.once("exit", (code, signal) => finish({ kind: "exit", code: code ?? 1, signal })); + }); +} diff --git a/src/lib/desktop-cli-record.d.mts b/src/lib/desktop-cli-record.d.mts new file mode 100644 index 00000000000..c80910a14f9 --- /dev/null +++ b/src/lib/desktop-cli-record.d.mts @@ -0,0 +1,29 @@ +import type { Stats } from "node:fs"; + +export const DESKTOP_CLI_RECORD_MAX_BYTES: number; +export type DesktopCliRecord = Readonly<{ + platform: "darwin" | "win32" | "linux"; + kind: "macos-app" | "windows-install" | "linux-deb"; + cliExecutable: string; +}>; +export type DesktopCliRecordIssue = "record-invalid" | "record-too-large" | "record-pending" | "record-unreadable" | "record-unsafe"; +export type DesktopCliRecordRead = + | { state: "missing"; path: string } + | { state: "disabled"; path: string; cleanupPending: boolean } + | { state: "ready"; path: string; record: DesktopCliRecord } + | { state: "invalid" | "unreadable"; path: string; issue: DesktopCliRecordIssue }; +export type DesktopCliRecordOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + home?: string; + recordPath?: string; +}; +export function desktopCliRecordPath(options?: DesktopCliRecordOptions): string; +export type DesktopCliRecordDeps = { + lstat?: (path: string) => Stats; + fstat?: (fd: number) => Stats; + open?: (path: string, flags: number) => number; + euid?: number; + checkAcl?: (directory: string, path: string) => boolean; +}; +export function readDesktopCliRecord(options?: DesktopCliRecordOptions, deps?: DesktopCliRecordDeps): DesktopCliRecordRead; diff --git a/src/lib/desktop-cli-record.mjs b/src/lib/desktop-cli-record.mjs new file mode 100644 index 00000000000..020d44b86a8 --- /dev/null +++ b/src/lib/desktop-cli-record.mjs @@ -0,0 +1,102 @@ +import { spawnSync } from "node:child_process"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, posix, resolve, win32 } from "node:path"; + +export const DESKTOP_CLI_RECORD_MAX_BYTES = 64 * 1024; + +export function desktopCliRecordPath(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir()); + return (platform === "win32" ? win32 : posix).join(home, ".opencodex-desktop", "cli.json"); +} + +// Keep these lexical rules aligned with Rust's absolute_for in cli_command_record.rs. +function validTargetPath(path, platform) { + if ([...path].length > 4096 || /[\x00\r\n]/.test(path)) return false; + const absolute = platform === "win32" + ? /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("\\\\") || path.startsWith("//") + : path.startsWith("/"); + return absolute && !path.split(platform === "win32" ? /[/\\]/ : /\//) + .some(part => part === "." || part === ".."); +} + +function noMacAcl(directory, path) { + const result = spawnSync("/bin/ls", ["-lde", directory, path], { + encoding: "utf8", timeout: 1000, shell: false, windowsHide: true, + env: { ...process.env, LC_ALL: "C" }, + }); + if (result.error || result.status !== 0 || result.stderr) return false; + // Each path has one metadata line; ACL entries add numbered lines and a "+" marker. + const lines = result.stdout.trimEnd().split("\n"); + return lines.length === 2 && lines.every(line => /^[d-][rwxStTs-]{9}[.@]?\s/.test(line)); +} + +export function readDesktopCliRecord(options = {}, deps = {}) { + const platform = options.platform ?? process.platform; + const path = options.recordPath ?? desktopCliRecordPath(options); + const directory = dirname(resolve(path)); + const lstat = deps.lstat ?? lstatSync; + const fstat = deps.fstat ?? fstatSync; + const open = deps.open ?? openSync; + const unsafe = () => ({ state: "invalid", path, issue: "record-unsafe" }); + const posixHost = platform !== "win32"; + let fd; + let checkingSafety = true; + let absenceAllowed = true; + try { + const dirInfo = lstat(directory); + if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory()) return unsafe(); + const uid = posixHost ? deps.euid ?? process.geteuid?.() : undefined; + if (posixHost && (uid === undefined || dirInfo.uid !== uid || (dirInfo.mode & 0o077) !== 0)) return unsafe(); + const fileInfo = lstat(path); + absenceAllowed = false; + if (fileInfo.isSymbolicLink() || !fileInfo.isFile() + || (posixHost && (fileInfo.uid !== uid || (fileInfo.mode & 0o077) !== 0))) return unsafe(); + const flags = constants.O_RDONLY | (posixHost ? constants.O_NOFOLLOW | constants.O_NONBLOCK : 0); + fd = open(path, flags); + const info = fstat(fd); + if (!info.isFile() || ["dev", "ino", "uid", "mode"].some(key => info[key] !== fileInfo[key])) return unsafe(); + if (platform === "darwin" && !(deps.checkAcl ?? noMacAcl)(directory, resolve(path))) return unsafe(); + checkingSafety = false; + if (info.size > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + const bytes = Buffer.alloc(DESKTOP_CLI_RECORD_MAX_BYTES + 1); + let used = 0; + while (used < bytes.length) { + const n = readSync(fd, bytes, used, bytes.length - used, null); + if (n === 0) break; + used += n; + } + if (used > DESKTOP_CLI_RECORD_MAX_BYTES) { + return { state: "invalid", path, issue: "record-too-large" }; + } + let value; + try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used))); } + catch { return { state: "invalid", path, issue: "record-invalid" }; } + if (!value || typeof value !== "object" || Array.isArray(value) + || value.version !== 1 || typeof value.enabled !== "boolean") { + return { state: "invalid", path, issue: "record-invalid" }; + } + if (!value.enabled) return { state: "disabled", path, cleanupPending: value.pending != null }; + if (value.pending != null) return { state: "invalid", path, issue: "record-pending" }; + const bundle = value.bundle; + const kind = { darwin: "macos-app", win32: "windows-install", linux: "linux-deb" }[platform]; + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle) + || !kind || bundle.platform !== platform || bundle.kind !== kind + || typeof bundle.cliExecutable !== "string" || !validTargetPath(bundle.cliExecutable, platform)) { + return { state: "invalid", path, issue: "record-invalid" }; + } + return { state: "ready", path, record: { + platform, kind, cliExecutable: bundle.cliExecutable, + } }; + } catch (error) { + return error?.code === "ENOENT" && absenceAllowed + ? { state: "missing", path } + : checkingSafety ? unsafe() : { state: "unreadable", path, issue: "record-unreadable" }; + } finally { + if (fd !== undefined) closeSync(fd); + } +} diff --git a/src/service/managing-cli.ts b/src/service/managing-cli.ts index 26f622e066c..d5efce657c7 100644 --- a/src/service/managing-cli.ts +++ b/src/service/managing-cli.ts @@ -81,6 +81,7 @@ function probeVersionOnce( windowsShim ? ["/c", shimCommand] : [...args, "--version"], { timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe", + env: { ...deps.env, OCX_NO_DESKTOP_HANDOFF: "1" }, windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}), }, ) as SpawnSyncReturns; diff --git a/structure/cli-management.md b/structure/cli-management.md index bac73785776..0aef70d2f90 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -113,3 +113,9 @@ Storage policy flags in `src/cli/storage.ts` preserve omitted enable/settings an `src/cli/account-api.ts` forwards explicit quota/refresh intent to the existing per-key owner; ordinary key listing omits both. Opt-in requests reject redirects. `src/cli/account-key-quota.ts` rebuilds the public ProviderQuota projection, including custom windows and credits, with finite-field validation and no credential or private publication fields. Missing mode evidence is unverified/nonzero. Human rendering separates probe/passive/unsupported, unavailable and unmeasured from actual zero; Codex/OAuth rendering remains separate. This read can invoke upstream quota work but does not change key selection or provider aggregate caches. Account list/current rows additionally project, through `src/cli/account-next-actions.ts`, only an allow-listed server health label, a locally generated recovery command built from shell-safe selectors (never server summary/action text), and the Codex boolean `creditsAfterLimit` as read-only consent readback; empty listings add a fixed next-action note to human output and JSON `notes`. The operating guide's eight-domain workflow table is navigation rather than parity measurement. Handwritten JSON, recipe, recovery and target docs qualify bounded windows, model-only search, partial/default results and human-only/session actions. Generated chapter counts still derive exclusively from capability declarations. + +## ocx command selection + +`src/cli/cli-path-diagnostics.ts` reads the settled Desktop record through `src/lib/desktop-cli-record.mjs` and observes regular executable candidates on this process's PATH without executing them. `pathFirst` follows PATH/PATHEXT order, including cwd-relative and empty entries; Windows's possible cmd current-directory candidate is separate. The expected command is the owned POSIX shim or the recorded Windows bundled executable. Windows package-launcher handoff is disabled before any record read or target check; user `Path` order selects the Desktop executable, while diagnostics still read the record and report PATH order. A stale package candidate first on PATH is reported even if its current launcher would hand off. `src/cli/status.ts` exposes additive `cliCommand` JSON and a human-view line; `src/cli/doctor.ts` prints the separate “ocx command selection” checks. Neither observation supplies runtime ownership or service control authority. Parent-shell aliases, functions and caches remain unobserved. Before JSON interpretation, the POSIX reader requires a real private directory and regular non-symlink record owned by the effective uid, no group/other bits, and matching descriptor dev/ino/uid/mode after nonblocking no-follow open; macOS also refuses extended ACL entries or a failed one-second ACL check. Safety failures report `record-unsafe`. Invalid, unsafe or enabled-pending records and unusable Desktop targets are FAIL; disabled records with `cleanupPending` report `cleanup-pending` as WARN, as do PATH ordering and incomplete scans. + +Internal version observations in `src/service/managing-cli.ts` (`probeVersionOnce`) set `OCX_NO_DESKTOP_HANDOFF=1` in the spawned child's environment so the selected package executable reports its own version. This flag suppresses the package launcher's Desktop handoff only; it does not bypass service ownership or update guards. Normal user `ocx --version` remains eligible for handoff. diff --git a/structure/runtime.md b/structure/runtime.md index 432f7aef7c7..f87e4a5fc0d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -120,7 +120,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX rejection of group/world-writable resolved files or parent directories, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX rejection of group/world-writable resolved files or parent directories, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/cli/cli-path-diagnostics.test.ts b/tests/cli/cli-path-diagnostics.test.ts new file mode 100644 index 00000000000..235b6fc167b --- /dev/null +++ b/tests/cli/cli-path-diagnostics.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test"; +import { chmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { collectCliPathDiagnostics, formatCliCommandLine, formatCliStatusHealthLabel, cliCommandDoctorChecks, type CliPathDiagnosticOptions, type CliPathObservation } from "../../src/cli/cli-path-diagnostics"; +import { readDesktopCliRecord, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES, type DesktopCliRecordDeps, type DesktopCliRecordRead } from "../../src/lib/desktop-cli-record.mjs"; +import { repoPath } from "../helpers/repo-root"; + +// The pending-cleanup case reads a macOS record on every CI host. NTFS reports POSIX +// owner/mode bits the reader rejects, and only macOS ships the `ls -lde` ACL probe, +// so non-mac hosts substitute the safe stats a real macOS record directory would have. +const macRecordOnHost: DesktopCliRecordDeps = { + ...(process.platform === "win32" ? { + euid: 0, + lstat: (path: string) => { const stat = lstatSync(path); stat.uid = 0; stat.mode = stat.isDirectory() ? 0o40700 : 0o100600; return stat; }, + fstat: (fd: number) => { const stat = fstatSync(fd); stat.uid = 0; stat.mode = 0o100600; return stat; }, + } : {}), + ...(process.platform === "darwin" ? {} : { checkAcl: () => true }), +}; + +function record(platform: NodeJS.Platform, target: string): DesktopCliRecordRead { + if (platform !== "darwin" && platform !== "linux" && platform !== "win32") throw new Error("Unsupported fixture platform"); + return { state: "ready", path: "fixture", record: { platform, kind: platform === "win32" ? "windows-install" : platform === "darwin" ? "macos-app" : "linux-deb", cliExecutable: target } }; +} +function fixture(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, files: string[], extra: Partial = {}) { + const windows = platform === "win32"; + const normalize = (path: string) => windows ? path.toLowerCase() : path; + const present = new Set(files.map(normalize)); + let observations = 0; + const observe = (path: string): CliPathObservation => { observations++; return present.has(normalize(path)) ? { kind: "usable", realpath: path } : { kind: "missing" }; }; + const result = collectCliPathDiagnostics({ platform, env, cwd: windows ? "C:\\work" : "/work", home: windows ? "C:\\Users\\example" : "/Users/example", recordRead: record(platform, windows ? "C:\\App\\ocx.exe" : "/Applications/OpenCodex.app/Contents/MacOS/ocx"), observe, ...extra }); + return { result, observations }; +} +const desktopBin = "/Users/example" + "/.opencodex-desktop/bin"; +const shim = desktopBin + "/ocx"; +const target = "/Applications/OpenCodex.app/Contents/MacOS/ocx"; +describe("observed ocx PATH selection", () => { + test("POSIX Desktop shim first, npm later; never executes candidates", () => { + const { result } = fixture("darwin", { PATH: `${desktopBin}:/npm/bin` }, [shim, target, "/npm/bin/ocx"]); + expect(result.pathFirst?.path).toBe(shim); expect(result.expectedExecutable).toBe(shim); + expect(result.desktopFirstOnPath).toBe(true); expect(result.shellResolution).toBe("unobserved"); expect(result.issues).toEqual([]); + const source = readFileSync(repoPath("src", "cli", "cli-path-diagnostics.ts"), "utf8"); + expect(source).not.toMatch(/child_process|Bun\.spawn|execSync|spawnSync/); + }); + test("npm first is an issue even if that launcher can hand off", () => { + const { result } = fixture("linux", { PATH: `/npm/bin:${desktopBin}` }, ["/npm/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe("/npm/bin/ocx"); expect(result.desktopFirstOnPath).toBe(false); expect(result.issues).toContain("path-first-not-desktop"); + expect(cliCommandDoctorChecks(result)[0]!.level).toBe("WARN"); + }); + test("POSIX empty and relative PATH elements resolve against cwd; absent PATH differs", () => { + for (const path of [":/bin", ".:/bin", "tools:/bin"]) { + const { result } = fixture("linux", { PATH: path }, ["/work/ocx", "/work/tools/ocx", "/bin/ocx", shim, target]); + expect(result.pathFirst?.path).toBe(path.startsWith("tools") ? "/work/tools/ocx" : "/work/ocx"); + } + expect(fixture("linux", {}, ["/work/ocx", shim, target]).result.pathFirst).toBeNull(); + expect(fixture("linux", { PATH: "" }, ["/work/ocx", shim, target]).result.pathFirst?.path).toBe("/work/ocx"); + }); + test("Windows PATHEXT order, case-insensitive Path, quotes and bundled expected exe", () => { + const { result } = fixture("win32", { Path: '"C:\\App";C:\\npm', PathExt: ".CMD;.EXE" }, ["C:\\App\\ocx.exe", "C:\\App\\ocx.cmd", "C:\\npm\\ocx.cmd"]); + expect(result.pathFirst?.extension).toBe(".cmd"); expect(result.expectedExecutable).toBe("C:\\App\\ocx.exe"); expect(result.issues).toContain("path-first-not-desktop"); + const selected = fixture("win32", { PATH: "c:\\app", PATHEXT: ".EXE;.CMD" }, ["C:\\App\\ocx.exe"]).result; + expect(selected.desktopFirstOnPath).toBe(true); + expect(selected.packageHandoff).toBe("disabled-on-windows"); + expect(selected.handoffTarget).toBeNull(); + expect(formatCliCommandLine(selected)).toContain("package handoff=disabled on Windows"); + expect(formatCliCommandLine(selected)).toContain("PATH first=c:\\app\\ocx.exe"); + }); + test("Windows empty entry and cmd cwd candidate remain distinct from PATH first", () => { + const row = fixture("win32", { PATH: "C:\\App", PATHEXT: ".EXE" }, ["C:\\App\\ocx.exe", "C:\\work\\ocx.exe"]).result; + expect(row.pathFirst?.path).toBe("C:\\App\\ocx.exe"); expect(row.currentDirectoryCandidate?.path).toBe("C:\\work\\ocx.exe"); expect(row.issues).toContain("windows-current-directory-shadow"); + expect(fixture("win32", { PATH: ";C:\\App", PATHEXT: ".EXE" }, ["C:\\work\\ocx.exe", "C:\\App\\ocx.exe"]).result.pathFirst?.path).toBe("C:\\work\\ocx.exe"); + }); + test("invalid PATHEXT entries ignored; extensionless last; missing PATH and bounded scan", () => { + const row = fixture("win32", { PATH: "C:\\npm", PATHEXT: ".EXE;bad;../evil;.CMD" }, ["C:\\npm\\ocx", "C:\\App\\ocx.exe"]).result; + expect(row.pathFirst?.extension).toBe(""); + expect(fixture("linux", {}, [shim, target]).result.issues).toContain("path-missing"); + const long = fixture("linux", { PATH: Array(257).fill("/missing").join(":") }, [shim, target]); + expect(long.result.issues).toContain("path-scan-truncated"); expect(long.observations).toBe(258); expect(long.result.desktopFirstOnPath).toBeNull(); + const extensions = Array.from({ length: 33 }, (_, i) => `.x${i}`).join(";"); + expect(fixture("win32", { PATH: "C:\\App", PATHEXT: extensions }, ["C:\\App\\ocx.exe"]).result.issues).toContain("path-scan-truncated"); + }); + test("missing or disabled Desktop is informational; bad records and missing target fail", () => { + for (const state of ["missing", "disabled"] as const) { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"], { recordRead: state === "disabled" ? { state, path: "fixture", cleanupPending: false } : { state, path: "fixture" } }).result; + expect(row.desktopFirstOnPath).toBeNull(); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("OK"); + } + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable", "record-unsafe"] as const) { + const row = fixture("linux", {}, [], { recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", path: "fixture", issue } }).result; + expect(row.issues).toContain(issue); expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + } + const missing = fixture("linux", { PATH: "/npm" }, ["/npm/ocx"]).result; + expect(missing.issues).toContain("desktop-target-missing"); expect(missing.issues).toContain("expected-command-missing"); + }); + test("disabled pending cleanup warns without selecting a target; enabled pending fails", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-pending-")); + try { + const path = desktopCliRecordPath({ platform: process.platform, home: root }); + mkdirSync(join(root, ".opencodex-desktop"), { mode: 0o700 }); + for (const [name, issue, level] of [ + ["disabled-with-pending", "cleanup-pending", "WARN"], + ["pending-enabled", "record-pending", "FAIL"], + ["first-pending", "record-pending", "FAIL"], + ] as const) { + writeFileSync(path, readFileSync(repoPath("tests", "fixtures", "desktop-cli-record", `${name}.json`)), { mode: 0o600 }); + const read = readDesktopCliRecord({ platform: "darwin", home: root, recordPath: path }, macRecordOnHost); + if (issue === "cleanup-pending") expect(read).toEqual({ state: "disabled", path, cleanupPending: true }); + else expect(read).toEqual({ state: "invalid", path, issue }); + const row = collectCliPathDiagnostics({ platform: "darwin", home: root, cwd: root, env: { PATH: "" }, observe: () => ({ kind: "missing" }), recordRead: process.platform === "darwin" ? undefined : read }); + expect(row.configured).toBe(false); + expect(row.handoffTarget).toBeNull(); + expect(row.expectedExecutable).toBeNull(); + expect(row.desktopFirstOnPath).toBeNull(); + expect(row.issues).toEqual([issue]); + const check = cliCommandDoctorChecks(row)[0]!; + expect(check.level).toBe(level); + if (level === "FAIL") { + expect(check.message).toContain("Open OpenCodex Desktop to repair the terminal command"); + expect(check.message).toContain("OCX_NO_DESKTOP_HANDOFF=1"); + expect(check.message).not.toMatch(/delet|remov/i); + } else expect(check.message).toContain("finish terminal-command cleanup"); + } + writeFileSync(path, "x".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1), { mode: 0o600 }); + const tooLargeRead = process.platform === "darwin" ? undefined : readDesktopCliRecord({ platform: "darwin", home: root, recordPath: path }, macRecordOnHost); + const tooLarge = collectCliPathDiagnostics({ platform: "darwin", home: root, cwd: root, env: {}, observe: () => ({ kind: "missing" }), recordRead: tooLargeRead }); + expect(tooLarge.issues).toEqual(["record-too-large"]); + expect(cliCommandDoctorChecks(tooLarge)[0]!.message).toContain(`${DESKTOP_CLI_RECORD_MAX_BYTES} bytes`); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("unusable/unreadable observations have named codes and no false OK", () => { + const row = fixture("linux", { PATH: "/bad" }, [], { observe: path => path === target ? { kind: "unusable" } : path === shim ? { kind: "unusable" } : { kind: "unreadable" } }).result; + expect(row.issues).toEqual(expect.arrayContaining(["desktop-target-unusable", "expected-command-unusable", "path-unreadable"])); + expect(row.desktopFirstOnPath).toBeNull(); + expect(cliCommandDoctorChecks(row)[0]!.level).toBe("FAIL"); + }); + test("realpath alias of owned command compares physically and output stays one safe line", () => { + const row = fixture("linux", { PATH: "/alias" }, [], { observe: path => ({ kind: "usable", realpath: path === target ? target : shim }) }).result; + expect(row.desktopFirstOnPath).toBe(true); + const line = formatCliCommandLine(row); expect(line).toContain("shell=unobserved"); expect(line).not.toContain("\n"); + const unsafe = { ...row, pathFirst: { ...row.pathFirst!, path: "/fixture/line\nbreak/ocx" } }; + expect(formatCliCommandLine(unsafe)).not.toContain("\n"); + }); + test("connected status adapter renders one command line and preserves local labels", () => { + const row = fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result; + for (const connected of [false, true]) { + const rendered = `Health: ${formatCliStatusHealthLabel("healthy", row, connected)}${connected ? " (local)" : ""}`; + const lines = rendered.split("\n"); expect(lines).toHaveLength(2); + for (const line of lines) expect(line.includes(" (local)")).toBe(connected); + expect(lines[1]).toContain("ocx command:"); + } + const json = JSON.parse(JSON.stringify({ proxy: { health: { message: "healthy" } }, cliCommand: row })); + expect(json.proxy.health.message).toBe("healthy"); + }); + (process.platform === "win32" ? test.skip : test)("default observer rejects directory/non-executable and observes physical symlink without execution", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-observe-")); + try { + const bin = join(root, ".opencodex-desktop", "bin"); mkdirSync(bin, { recursive: true }); + const target = join(root, "ocx-target"); writeFileSync(target, "fixture; never executed"); chmodSync(target, 0o755); + const shimPath = join(bin, "ocx"); symlinkSync(target, shimPath); + const platform = process.platform; + const options = { platform, home: root, cwd: root, recordRead: record(platform, target), env: { PATH: bin } }; + expect(collectCliPathDiagnostics(options).desktopFirstOnPath).toBe(true); + rmSync(shimPath); mkdirSync(shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + rmSync(shimPath, { recursive: true }); writeFileSync(shimPath, "fixture"); chmodSync(shimPath, 0o600); + expect(collectCliPathDiagnostics(options).issues).toContain("expected-command-unusable"); + expect(collectCliPathDiagnostics({ ...options, env: { PATH: target } }).pathFirst).toBeNull(); + rmSync(shimPath); symlinkSync(shimPath, shimPath); + expect(collectCliPathDiagnostics(options).issues).toContain("path-unreadable"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("status JSON field and human line, doctor independent registration, root dispatcher untouched", () => { + const status = readFileSync(repoPath("src", "cli", "status.ts"), "utf8"); + expect(status).toContain("cliCommand: CliPathDiagnostics;"); expect(status).toContain(" cliCommand,"); + expect(status).toContain("formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected)"); + const doctor = readFileSync(repoPath("src", "cli", "doctor.ts"), "utf8"); + expect(doctor).toContain('console.log("\\nocx command selection")'); expect(doctor).toContain('if (check.level === "FAIL") recordDoctorFailure()'); + expect(doctor.indexOf('console.log("Paths")')).toBeLessThan(doctor.indexOf('console.log("\\nocx command selection")')); + expect(doctor.indexOf('console.log("\\nocx command selection")')).toBeLessThan(doctor.indexOf('console.log("\\nResponse-state temp files")')); + expect(doctor.indexOf('console.log("\\nocx command selection")')).toBeLessThan(doctor.indexOf('console.log("\\nCodex runtime selection")')); + const json = JSON.parse(JSON.stringify(fixture("linux", { PATH: "/npm" }, ["/npm/ocx", shim, target]).result)); + expect(json.pathFirst.path).toBe("/npm/ocx"); expect(json.shellResolution).toBe("unobserved"); + }); + + (process.platform === "win32" ? test.skip : test)("unsafe record is a doctor FAIL with its path and no configured target", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-unsafe-")); + try { + mkdirSync(join(root, ".opencodex-desktop"), { mode: 0o700 }); + const path = desktopCliRecordPath({ home: root }); + writeFileSync(path, '{"version":1,"enabled":false}', { mode: 0o600 }); + chmodSync(path, 0o644); + const row = collectCliPathDiagnostics({ home: root, cwd: root, env: { PATH: "" }, observe: () => ({ kind: "missing" }) }); + expect(row.issues).toEqual(["record-unsafe"]); + expect(row.configured).toBe(false); + expect(row.handoffTarget).toBeNull(); + const check = cliCommandDoctorChecks(row)[0]!; + expect(check.level).toBe("FAIL"); + expect(check.message).toContain(path); + expect(check.message).toContain("OCX_NO_DESKTOP_HANDOFF=1"); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + test("Windows unsafe records still report a PATH first candidate with handoff disabled", () => { + const row = fixture("win32", { PATH: "C:\\npm", PATHEXT: ".CMD" }, ["C:\\npm\\ocx.cmd"], { + recordRead: { state: "invalid", path: "C:\\Desktop\\cli.json", issue: "record-unsafe" }, + }).result; + expect(row.pathFirst?.path).toBe("C:\\npm\\ocx.cmd"); + expect(row.packageHandoff).toBe("disabled-on-windows"); + expect(row.handoffTarget).toBeNull(); + expect(row.issues).toContain("record-unsafe"); + const check = cliCommandDoctorChecks(row)[0]!; + expect(check.level).toBe("FAIL"); + expect(check.message).toContain("package handoff=disabled on Windows"); + expect(check.message).not.toContain("OCX_NO_DESKTOP_HANDOFF=1"); + }); +}); diff --git a/tests/cli/ocx-launcher-desktop-handoff.test.ts b/tests/cli/ocx-launcher-desktop-handoff.test.ts new file mode 100644 index 00000000000..f15b757dec2 --- /dev/null +++ b/tests/cli/ocx-launcher-desktop-handoff.test.ts @@ -0,0 +1,626 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { chmodSync, chownSync, constants, fstatSync, lstatSync, mkdirSync, openSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync as writeFile, type statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readDesktopCliRecord as readRecord, desktopCliRecordPath, DESKTOP_CLI_RECORD_MAX_BYTES, type DesktopCliRecordRead, type DesktopCliRecordOptions, type DesktopCliRecordDeps } from "../../src/lib/desktop-cli-record.mjs"; +import { desktopHandoffExcluded, planDesktopCliHandoff, runDesktopCliHandoff } from "../../src/lib/desktop-cli-handoff.mjs"; +import { initializeNodeLauncherContext } from "../../src/cli/launcher-context"; +import { buildNativeClaudeEnv } from "../../src/cli/claude"; +import { observeManagingClis } from "../../src/service/managing-cli"; +import { repoPath } from "../helpers/repo-root"; + +// Explicit private fixtures; only temp paths are written by this helper. +function writeFileSync(path: string, data: Parameters[1]): void { + writeFile(path, data, { mode: 0o600 }); +} +function readDesktopCliRecord(options: DesktopCliRecordOptions = {}, deps: DesktopCliRecordDeps = {}): DesktopCliRecordRead { + const hostStats = process.platform === "win32" ? { + euid: 0, + lstat: (path: string) => { const stat = lstatSync(path); stat.uid = 0; stat.mode = stat.isDirectory() ? 0o40700 : 0o100600; return stat; }, + fstat: (fd: number) => { const stat = fstatSync(fd); stat.uid = 0; stat.mode = 0o100600; return stat; }, + } : {}; + return readRecord(options, { ...hostStats, ...deps }); +} +const roots: string[] = []; +const hostKind = { darwin: "macos-app", win32: "windows-install", linux: "linux-deb" }[process.platform as "darwin" | "win32" | "linux"]; +const PREFIX = "--ocx-internal-launch-proof="; +const proof = "A".repeat(43); +const context = JSON.stringify({ version: 1, proof, anthropicEnvSlots: ["ANTHROPIC_API_KEY"], codexCliInspectionEnv: null }); +afterEach(() => { + initializeNodeLauncherContext(["bun", "index.ts"], {}); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function box(): string { + const root = mkdtempSync(join(tmpdir(), "ocx-handoff-")); chmodSync(root, 0o700); roots.push(root); return root; +} +function ready(target = "/fixture/desktop/ocx"): DesktopCliRecordRead { + return { state: "ready", path: "/fixture/cli.json", record: { + platform: "linux", kind: "linux-deb", cliExecutable: target, + } }; +} +const stat = (() => ({ isFile: () => true })) as unknown as typeof statSync; +const deps = { stat, access: () => {}, realpath: (path: string) => path }; +const coded = (code: string): NodeJS.ErrnoException => Object.assign(new Error("fixture"), { code }); + +function fakeSpawn(error?: string, exitSignal: NodeJS.Signals | null = null) { + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: (signal: string) => boolean }; + const killed: string[] = []; + child.kill = signal => { killed.push(signal); return true; }; + let observed: { target: string; argv: string[]; options: Record } | undefined; + const run = ((target: string, argv: string[], options: Record) => { + observed = { target, argv, options }; + queueMicrotask(() => { + parent.emit("SIGTERM"); + if (error) { child.emit("error", coded(error)); child.emit("exit", 99, null); } + else child.emit("exit", 17, exitSignal); + }); + return child; + }) as unknown as typeof spawn; + return { run, parent, killed, observed: () => observed }; +} + +describe("Desktop record and handoff policy", () => { + test("fixed record location and injected reader path", () => { + expect(desktopCliRecordPath({ platform: "darwin", home: "/Users/example" })).toBe("/Users/example" + "/.opencodex-desktop/cli.json"); + expect(desktopCliRecordPath({ platform: "win32", env: { USERPROFILE: "C:\\Users\\example" } })).toBe("C:\\Users\\example\\.opencodex-desktop\\cli.json"); + const root = box(); const recordPath = join(root, "cli.json"); + expect(readDesktopCliRecord({ recordPath }).state).toBe("missing"); + writeFileSync(recordPath, '{"version":1,"enabled":false}'); + expect(readDesktopCliRecord({ recordPath }).state).toBe("disabled"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: join(root, "ocx") }, ownerId: "ignored" })); + expect(readDesktopCliRecord({ recordPath }).state).toBe("ready"); + }); + test("reader refuses oversized, malformed, pending and wrong-platform records", () => { + const root = box(); const recordPath = join(root, "cli.json"); + const valid = { version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: join(root, "ocx") } }; + for (const value of ["{", "[]", "null", "42", JSON.stringify({ ...valid, enabled: "yes" }), JSON.stringify({ ...valid, bundle: null }), JSON.stringify({ ...valid, version: 2 }), JSON.stringify({ ...valid, pending: {} }), JSON.stringify({ ...valid, bundle: { platform: "other", cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, kind: hostKind, cliExecutable: "relative" } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, kind: hostKind, cliExecutable: "/" + "x".repeat(4096) } }), JSON.stringify({ ...valid, bundle: { platform: process.platform, kind: hostKind, cliExecutable: "/fixture/line\nbreak" } })]) { + writeFileSync(recordPath, value); expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + } + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-too-large" }); + rmSync(recordPath); mkdirSync(recordPath); + expect(readDesktopCliRecord({ recordPath }).state).toBe("invalid"); + }); + test("descriptor sentinel bounds growth after metadata and strict UTF-8 rejects corrupt bytes", () => { + const root = box(); const recordPath = join(root, "cli.json"); + writeFileSync(recordPath, Buffer.from([0xff])); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ issue: "record-invalid" }); + writeFileSync(recordPath, " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)); + const script = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; +const original = fs.fstatSync; fs.fstatSync = (...args) => { const info = original(...args); info.size = 1; return info; }; syncBuiltinESMExports(); +const mod = await import(${JSON.stringify(pathToFileURL(repoPath("src", "lib", "desktop-cli-record.mjs")).href)}); +console.log(JSON.stringify(mod.readDesktopCliRecord({recordPath:${JSON.stringify(recordPath)}})));`; + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf8", timeout: 5000 }); + expect(result.status).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ issue: "record-too-large" }); + }); + (process.platform === "win32" ? test.skip : test)("reader symlinks are unsafe and disabled intent wins over pending journal", () => { + const root = box(); const recordPath = join(root, "cli.json"); + symlinkSync(recordPath, recordPath); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ state: "invalid", issue: "record-unsafe" }); + rmSync(recordPath); writeFileSync(recordPath, '{"version":1,"enabled":false,"pending":{}}'); + expect(readDesktopCliRecord({ recordPath })).toMatchObject({ state: "disabled", cleanupPending: true }); + }); + test("record absence and disabled intent keep the package path; invalid records error", () => { + for (const state of ["missing", "disabled"] as const) expect(planDesktopCliHandoff({ platform: "linux", argv: ["status"], env: {}, recordRead: state === "disabled" ? { state, path: "fixture", cleanupPending: false } : { state, path: "fixture" } })).toEqual({ kind: "continue", reason: state }); + for (const issue of ["record-invalid", "record-too-large", "record-pending", "record-unreadable", "record-unsafe"] as const) expect(planDesktopCliHandoff({ platform: "linux", argv: ["status"], env: {}, recordRead: { state: issue === "record-unreadable" ? "unreadable" : "invalid", issue, path: "fixture" } })).toEqual({ kind: "error", issue }); + }); + test("update, removal, internal inspection and codex-cli-update are exceptions", () => { + for (const argv of [["update"], ["update", "--help"], ["uninstall"], ["remove"], ["__update-badge"], ["system", "codex-cli-update", "malformed"], [PREFIX + "invalid", "update"]]) { + expect(desktopHandoffExcluded(argv, {})).toBe(true); + expect(planDesktopCliHandoff({ platform: "linux", argv, env: {}, recordRead: ready() }, { stat: (() => { throw new Error("must not read target"); }) as typeof stat })).toMatchObject({ reason: "excluded" }); + } + expect(desktopHandoffExcluded(["--version"], {})).toBe(false); + expect(desktopHandoffExcluded(["inspect", "config"], {})).toBe(false); + }); + test("OCX_NO_DESKTOP_HANDOFF is exact and keeps user version handoff eligible", () => { + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "1" })).toBe(true); + expect(desktopHandoffExcluded(["--version"], { OCX_NO_DESKTOP_HANDOFF: "0" })).toBe(false); + }); + test("absolute regular executable target selected; self realpath never delegated", () => { + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/package/ocx.mjs"] }, deps)).toEqual({ kind: "handoff", target: "/fixture/desktop/ocx" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths: ["/fixture/alias"] }, { ...deps, realpath: () => "/fixture/same" })).toMatchObject({ kind: "error", issue: "target-self" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready("relative") }, deps)).toMatchObject({ issue: "target-invalid" }); + const selfPaths = ["/fixture/missing-self"]; + const missingSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ENOENT"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, missingSelf).kind).toBe("handoff"); + const badSelf = { ...deps, realpath: (path: string) => { if (path === selfPaths[0]) throw coded("ELOOP"); return path; } }; + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready(), selfPaths }, badSelf)).toMatchObject({ issue: "target-unusable" }); + expect(planDesktopCliHandoff({ argv: [], env: {}, platform: "linux", recordRead: ready() }, { ...deps, stat: (() => ({ isFile: () => false })) as unknown as typeof statSync })).toMatchObject({ issue: "target-invalid" }); + }); + test("target ENOENT resumes and EACCES or other errors fail", () => { + for (const code of ["ENOENT", "EACCES", "ENOTDIR", "ENOEXEC", "ELOOP"]) { + const plan = planDesktopCliHandoff({ argv: ["status"], env: {}, platform: "linux", recordRead: ready() }, { ...deps, access: () => { throw coded(code); } }); + expect(plan.kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("Windows returns before reading valid, unreadable or unsafe records and never selects a spawn", () => { + for (const read of [ready("C:\\App\\ocx.exe"), { state: "unreadable", path: "fixture", issue: "record-unreadable" }, { state: "invalid", path: "fixture", issue: "record-unsafe" }] as DesktopCliRecordRead[]) { + let readAttempted = false; + let targetChecked = false; + let spawned = false; + const input = { platform: "win32" as const, argv: ["--version"], env: {} }; + Object.defineProperty(input, "recordRead", { get() { readAttempted = true; return read; } }); + const plan = planDesktopCliHandoff(input, { stat: (() => { targetChecked = true; throw new Error("must not check target"); }) as typeof stat }); + if (plan.kind === "handoff") spawned = true; + expect(plan).toEqual({ kind: "continue", reason: "windows-path-only" }); + expect(readAttempted).toBe(false); + expect(targetChecked).toBe(false); + expect(spawned).toBe(false); + } + }); +}); + +describe("async handoff transport", () => { + test("argv, inherited stdin, fresh proof and exit code; signal handlers cleaned", async () => { + const fake = fakeSpawn(); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { + argv: [PREFIX + "obsolete", "claude", "a b", "--literal=$x"], proof, context, + env: { PATH: "fixture", OCX_BUN_RUNTIME_PATH: "stale", OCX_BUN_RUNTIME_SOURCE: "bundled" }, + }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "linux" }); + expect(result).toEqual({ kind: "exit", code: 17, signal: null }); + const called = fake.observed()!; + expect(called.argv).toEqual([PREFIX + proof, "claude", "a b", "--literal=$x"]); + expect(called.options).toMatchObject({ stdio: "inherit", shell: false, windowsHide: true }); + expect(called.options.env).toEqual({ PATH: "fixture", OCX_NO_DESKTOP_HANDOFF: "1", OCX_NODE_LAUNCH_CONTEXT: context }); + expect(fake.killed).toEqual(["SIGTERM"]); expect(fake.parent.listenerCount("SIGTERM")).toBe(0); + }); + test("child signal is retained and confirmed asynchronous target ENOENT resumes", async () => { + for (const error of [undefined, "ENOENT", "EACCES", "ENOEXEC"]) { + const fake = fakeSpawn(error, "SIGTERM"); + const result = await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, lstat: (() => { throw coded("ENOENT"); }) as typeof lstatSync }); + expect(result.kind).toBe(!error ? "exit" : error === "ENOENT" ? "continue" : "error"); + if (result.kind === "exit") expect(result.signal).toBe("SIGTERM"); + expect(fake.parent.eventNames()).toHaveLength(0); + } + }); + test("Windows forwards INT/TERM only and a kill race still settles from child exit", async () => { + const fake = fakeSpawn(); + const pending = runDesktopCliHandoff({ kind: "handoff", target: "C:\\App\\ocx.exe" }, { argv: [], env: {}, proof, context }, { spawn: fake.run, parent: fake.parent as unknown as NodeJS.Process, platform: "win32" }); + expect(fake.parent.listenerCount("SIGHUP")).toBe(0); expect(fake.parent.listenerCount("SIGINT")).toBe(1); + expect((await pending).kind).toBe("exit"); + const parent = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { kill: () => never }; + child.kill = () => { throw new Error("already exited"); }; + const run = (() => { queueMicrotask(() => { parent.emit("SIGTERM"); child.emit("exit", null, null); }); return child; }) as unknown as typeof spawn; + expect(await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: run, parent: parent as unknown as NodeJS.Process })).toEqual({ kind: "exit", code: 1, signal: null }); + }); + test("synchronous spawn errors have the same confirmed-target ENOENT policy", async () => { + for (const code of ["ENOENT", "EACCES"]) { + const fail = (() => { throw coded(code); }) as unknown as typeof spawn; + expect((await runDesktopCliHandoff({ kind: "handoff", target: "/fixture/ocx" }, { argv: [], env: {}, proof, context }, { spawn: fail, lstat: (() => { throw coded("ENOENT"); }) as typeof lstatSync })).kind).toBe(code === "ENOENT" ? "continue" : "error"); + } + }); + test("proof reaches the real Claude native-env consumer and keeps exported slots", () => { + const env = { OCX_NODE_LAUNCH_CONTEXT: context, ANTHROPIC_API_KEY: "fixture-user-key", ANTHROPIC_BASE_URL: "https://fixture.invalid", OCX_PRE_BUN_ANTHROPIC_ENV: "obsolete" }; + const argv = ["bun", "index.ts", PREFIX + proof, "claude"]; + expect(initializeNodeLauncherContext(argv, env)?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY"]); + expect(argv).toEqual(["bun", "index.ts", "claude"]); + const config = { port: 10100, providers: {} } as Parameters[0]; + const child = buildNativeClaudeEnv(config, env); + expect(child.ANTHROPIC_API_KEY).toBe("fixture-user-key"); + expect(child.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(child.OCX_NODE_LAUNCH_CONTEXT).toBeUndefined(); + }); + test("internal managing CLI probes set the bypass in the actual spawn environment", () => { + let actual: NodeJS.ProcessEnv | undefined; + const probe = ((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => { actual = options.env; return { status: 0, stdout: "2.82.0", stderr: "" }; }) as unknown as typeof spawnSync; + const result = observeManagingClis(null, { platform: "linux", env: { PATH: "/fixture", KEEP: "yes" }, execPath: "/fixture/self", exists: path => path === "/fixture/ocx", isFile: () => true, spawn: probe }); + expect(result.path.status).toBe("observed"); expect(actual).toEqual({ PATH: "/fixture", KEEP: "yes", OCX_NO_DESKTOP_HANDOFF: "1" }); + }); +}); + +function packageFixture() { + const home = box(); const directory = join(home, ".opencodex-desktop"); mkdirSync(directory, { mode: 0o700 }); + for (const name of ["opencodex", "codex", "grok"]) mkdirSync(join(home, name)); + const target = join(home, "desktop-cli"); + const recordPath = join(directory, "cli.json"); + writeFileSync(recordPath, JSON.stringify({ version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: target } })); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, USERPROFILE: home, OPENCODEX_HOME: join(home, "opencodex"), CODEX_HOME: join(home, "codex"), GROK_HOME: join(home, "grok"), ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_BASE_URL: "", OCX_NO_DESKTOP_HANDOFF: "0", OCX_NODE_LAUNCH_CONTEXT: "forged", ANTHROPIC_API_KEY: "fixture-user-key", OPENCODEX_BUN_PATH: join(home, "missing-bun") }; + return { home, target, env }; +} +const posixTest = process.platform === "win32" ? test.skip : test; +const launcher = repoPath("bin", "ocx.mjs"); +posixTest("real Node launcher regenerates proof, forwards argv/stdin, mirrors exit and bypasses Bun lookup", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, `#!/usr/bin/env node\nconst fs = require('node:fs');\nconsole.log(JSON.stringify({ argv: process.argv.slice(2), context: JSON.parse(process.env.OCX_NODE_LAUNCH_CONTEXT), stdin: fs.readFileSync(0, 'utf8'), bypass: process.env.OCX_NO_DESKTOP_HANDOFF }));\nprocess.exit(23);\n`); + chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "claude", "a b", PREFIX + "old"], { env: fixture.env, input: "fixture stdin\n", encoding: "utf8", timeout: 5000 }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(23); + const row = JSON.parse(result.stdout.trim()); + expect(row.stdin).toBe("fixture stdin\n"); expect(row.bypass).toBe("1"); + expect(row.argv.slice(1)).toEqual(["claude", "a b"]); + expect(row.argv[0]).toBe(PREFIX + row.context.proof); + expect(row.context.proof).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(row.context.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); + const argv = ["bun", "index.ts", ...row.argv]; + const capturedEnv = { OCX_NODE_LAUNCH_CONTEXT: JSON.stringify(row.context), ANTHROPIC_API_KEY: "fixture-user-key", ANTHROPIC_BASE_URL: "https://dotenv.invalid" }; + const captured = initializeNodeLauncherContext(argv, capturedEnv); + expect(captured?.anthropicEnvSlots).toContain("ANTHROPIC_API_KEY"); + const config = { port: 10100, providers: {} } as Parameters[0]; + const native = buildNativeClaudeEnv(config, capturedEnv); + expect(native.ANTHROPIC_API_KEY).toBe("fixture-user-key"); + expect(native.ANTHROPIC_BASE_URL).toBeUndefined(); +}); +posixTest("real target disappearance resumes Bun; EACCES never resumes Bun", () => { + const fixture = packageFixture(); + const absent = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(absent.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(fixture.target, "not executable"); chmodSync(fixture.target, 0o600); + const denied = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(denied.status).toBe(1); expect(denied.stderr).toContain("target-unusable"); expect(denied.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real update-help is untouched; opt-out and bad record activate distinct paths", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nconsole.log('desktop-picked'); process.exit(0);\n"); chmodSync(fixture.target, 0o755); + const help = spawnSync("node", [launcher, "update", "--help"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(help.status).toBe(0); expect(help.stdout).toContain("Usage: ocx update"); expect(help.stdout).not.toContain("desktop-picked"); + const skipped = spawnSync("node", [launcher, "--version"], { env: { ...fixture.env, OCX_NO_DESKTOP_HANDOFF: "1" }, encoding: "utf8", timeout: 5000 }); + expect(skipped.stdout).not.toContain("desktop-picked"); expect(skipped.stderr).toContain("OPENCODEX_BUN_PATH"); + writeFileSync(join(fixture.home, ".opencodex-desktop", "cli.json"), "{"); + const invalid = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(invalid.status).toBe(1); expect(invalid.stderr).toContain("record-invalid"); expect(invalid.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); +posixTest("real child terminating signal is mirrored by Node parent", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.kill(process.pid, 'SIGTERM');\n"); chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "status"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.signal).toBe("SIGTERM"); +}); +posixTest("signal sent only to real launcher reaches its child and waits for child exit", async () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(29));\nconsole.log(process.pid); setInterval(() => {}, 1000);\n"); chmodSync(fixture.target, 0o755); + const child: ChildProcess = spawn("node", [launcher, "status"], { env: fixture.env, stdio: ["ignore", "pipe", "pipe"] }); + let timer: ReturnType | undefined; + let targetPid: number | undefined; + try { + const code = await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("fixture timeout")), 5000); + child.once("error", reject); child.once("exit", resolve); + child.stdout!.once("data", bytes => { targetPid = Number(String(bytes).trim()); child.kill("SIGTERM"); }); + }); + expect(code).toBe(29); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + if (targetPid && Number.isSafeInteger(targetPid)) { try { process.kill(targetPid, "SIGKILL"); } catch { /* exited */ } } + child.kill("SIGKILL"); + } + } +}); +test("launcher source anchors preserve exceptions before handoff and handoff before repair/resolver", () => { + const source = readFileSync(launcher, "utf8"); + const hook = source.indexOf("const desktopPlan ="); + expect(hook).toBeGreaterThan(source.indexOf("const codexCliUpdateInspection =")); + expect(hook).toBeLessThan(source.indexOf('if (process.argv[2] === "update" && installMethod === "mise")')); + expect(hook).toBeLessThan(source.indexOf("const probe = bootRestoreProbe(")); + expect(hook).toBeLessThan(source.indexOf("const bunRuntime = resolveBun(")); + expect(source).toContain("const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext();"); + expect(source.match(/randomBytes\(32\)/g)).toHaveLength(1); +}); + +describe("shared Rust record fixtures and lexical path boundaries", () => { + test("the whole Rust fixture directory maps to consumption state on every supported host", () => { + const fixtureDir = repoPath("tests", "fixtures", "desktop-cli-record"); + const expected = { + "valid-darwin.json": { state: "ready", host: "darwin" }, + "valid-win32.json": { state: "ready", host: "win32" }, + "notify-pending.json": { state: "ready", host: "win32" }, + "valid-linux.json": { state: "ready", host: "linux" }, + "disabled-tombstone.json": { state: "disabled", cleanupPending: false }, + "disabled-with-pending.json": { state: "disabled", cleanupPending: true }, + "pending-enabled.json": { state: "invalid", issue: "record-pending" }, + "first-pending.json": { state: "invalid", issue: "record-pending" }, + "appimage-kind.json": { state: "invalid", issue: "record-invalid" }, + "relative-target.json": { state: "invalid", issue: "record-invalid" }, + "dotdot-target.json": { state: "invalid", issue: "record-invalid" }, + } as const; + const names = readdirSync(fixtureDir).sort(); + expect(names).toEqual(Object.keys(expected).sort()); + const recordPath = join(box(), "cli.json"); + for (const platform of ["darwin", "win32", "linux"] as const) { + for (const name of names) { + const want = expected[name as keyof typeof expected]; + writeFileSync(recordPath, readFileSync(join(fixtureDir, name))); + const actual = readDesktopCliRecord({ recordPath, platform }, { checkAcl: () => true }); + if (want.state === "ready") { + if (want.host === platform) { + const fixture = JSON.parse(readFileSync(join(fixtureDir, name), "utf8")); + expect(actual).toMatchObject({ state: "ready", record: { + platform, kind: fixture.bundle.kind, cliExecutable: fixture.bundle.cliExecutable, + } }); + if (actual.state === "ready") expect(Object.keys(actual.record).sort()).toEqual(["cliExecutable", "kind", "platform"]); + } else { + expect(actual).toMatchObject({ state: "invalid", issue: "record-invalid" }); + } + } else { + expect(actual).toMatchObject(want); + } + } + } + }); + + test("path length counts Unicode code points and paths are not normalized before validation", () => { + const recordPath = join(box(), "cli.json"); + const cases: [string, boolean][] = [ + ["/" + "x".repeat(4095), true], + ["/" + "x".repeat(4096), false], + ["/" + "😀".repeat(4095), true], + ["/" + "😀".repeat(4096), false], + ["/fixture/./ocx", false], + ["/fixture/../ocx", false], + ["/fixture/ocx/..", false], + ["/fixture/ocx/.", false], + ["/fixture/ocx\n", false], + ["/fixture/ocx\r", false], + ["/fixture/ocx\0", false], + ["relative/ocx", false], + ["/fixture/.../ocx", true], + ["/fixture\\..\\ocx", true], + ["//fixture//ocx", true], + ]; + for (const [cliExecutable, accepted] of cases) { + writeFileSync(recordPath, JSON.stringify({ + version: 1, enabled: true, bundle: { platform: "darwin", kind: "macos-app", cliExecutable }, + })); + expect(readDesktopCliRecord({ recordPath, platform: "darwin" }, { checkAcl: () => true }).state).toBe(accepted ? "ready" : "invalid"); + } + }); + + test("Windows absolute paths match Rust drive and UNC rules exactly", () => { + const recordPath = join(box(), "cli.json"); + const cases: [string, boolean][] = [ + ["C:\\App\\ocx.exe", true], ["C:/App/ocx.exe", true], + ["\\\\server\\share\\ocx.exe", true], ["//server/share/ocx.exe", true], + ["\\\\server", true], ["\\\\", true], ["//", true], + ["\\ocx.exe", false], ["/ocx.exe", false], ["C:ocx.exe", false], + ["1:/ocx.exe", false], ["C:\\App\\..\\ocx.exe", false], ["C:/App/./ocx.exe", false], + ["C:\\App/../ocx.exe", false], + ]; + for (const [cliExecutable, accepted] of cases) { + writeFileSync(recordPath, JSON.stringify({ + version: 1, enabled: true, bundle: { platform: "win32", kind: "windows-install", cliExecutable }, + })); + expect(readDesktopCliRecord({ recordPath, platform: "win32" }).state).toBe(accepted ? "ready" : "invalid"); + } + }); + + test("platform-kind pairs are closed and the 64 KiB bound accepts a padded valid record", () => { + const recordPath = join(box(), "cli.json"); + const valid = { version: 1, enabled: true, bundle: { platform: "linux", kind: "linux-deb", cliExecutable: "/fixture/ocx" } }; + for (const kind of ["macos-app", "windows-install", "linux-appimage", "", null]) { + writeFileSync(recordPath, JSON.stringify({ ...valid, bundle: { ...valid.bundle, kind } })); + expect(readDesktopCliRecord({ recordPath, platform: "linux" })).toMatchObject({ issue: "record-invalid" }); + } + writeFileSync(recordPath, JSON.stringify({ ...valid, bundle: { ...valid.bundle, platform: "freebsd" } })); + expect(readDesktopCliRecord({ recordPath, platform: "freebsd" })).toMatchObject({ issue: "record-invalid" }); + const json = JSON.stringify(valid); + writeFileSync(recordPath, json + " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES - Buffer.byteLength(json))); + expect(readDesktopCliRecord({ recordPath, platform: "linux" }).state).toBe("ready"); + writeFileSync(recordPath, json + " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES - Buffer.byteLength(json) + 1)); + expect(readDesktopCliRecord({ recordPath, platform: "linux" })).toMatchObject({ issue: "record-too-large" }); + }); +}); + +test("AM-8 spawn ENOENT resumes only after lstat also reports ENOENT", async () => { + for (const synchronous of [false, true]) { + for (const check of ["exists", "ENOENT", "EACCES", "ELOOP", "ENOTDIR"]) { + let checked: string | undefined; + const fake = fakeSpawn("ENOENT"); + const spawnChild = synchronous ? (() => { throw coded("ENOENT"); }) as typeof spawn : fake.run; + const lstat = ((target: string) => { + checked = target; + if (check !== "exists") throw coded(check); + return { isFile: () => true }; + }) as unknown as typeof lstatSync; + const result = await runDesktopCliHandoff( + { kind: "handoff", target: "/fixture/desktop/ocx" }, + { argv: [], env: {}, proof, context }, + { spawn: spawnChild, parent: fake.parent as unknown as NodeJS.Process, lstat }, + ); + expect(checked).toBe("/fixture/desktop/ocx"); + expect(result).toEqual(check === "ENOENT" ? { kind: "continue" } : { kind: "error", issue: "spawn-failed" }); + expect(fake.parent.eventNames()).toHaveLength(0); + } + } +}); + +posixTest("AM-8 an executable with a missing interpreter exits 127 without Bun fallback", () => { + const fixture = packageFixture(); + writeFileSync(fixture.target, "#!" + join(fixture.home, "missing-interpreter") + "\nexit 0\n"); + chmodSync(fixture.target, 0o755); + const result = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(127); + expect(result.stderr).toContain("Desktop CLI handoff failed (spawn-failed)"); + expect(result.stderr).not.toContain("OPENCODEX_BUN_PATH"); +}); + +posixTest("AM-2 record refusal names the record, repair and exact bypass for every issue", () => { + const fixture = packageFixture(); + const recordPath = join(fixture.home, ".opencodex-desktop", "cli.json"); + const cases = [ + ["record-invalid", "{"], + ["record-too-large", " ".repeat(DESKTOP_CLI_RECORD_MAX_BYTES + 1)], + ["record-pending", JSON.stringify({ version: 1, enabled: true, pending: {} })], + ["record-unsafe", null], + ] as const; + for (const [issue, content] of cases) { + rmSync(recordPath, { force: true }); + if (content === null) symlinkSync(recordPath, recordPath); + else writeFileSync(recordPath, content); + const result = spawnSync("node", [launcher, "--version"], { env: fixture.env, encoding: "utf8", timeout: 5000 }); + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe( + "opencodex: OpenCodex Desktop's terminal-command record at " + recordPath + + " could not be used (" + issue + "). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1.", + ); + expect(result.stderr).not.toContain("OPENCODEX_BUN_PATH"); + } +}); + +posixTest("real --version opts out to the package version even with a broken record", () => { + const fixture = packageFixture(); + writeFileSync(join(fixture.home, ".opencodex-desktop", "cli.json"), "{"); + const result = spawnSync("node", [launcher, "--version"], { + env: { ...fixture.env, OPENCODEX_BUN_PATH: process.execPath, OCX_NO_DESKTOP_HANDOFF: "1" }, + encoding: "utf8", timeout: 10000, + }); + const version = JSON.parse(readFileSync(repoPath("package.json"), "utf8")).version; + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(`opencodex ${version}`); +}); + +function safetyFixture() { + const home = box(); + const directory = join(home, ".opencodex-desktop"); + mkdirSync(directory, { mode: 0o700 }); + chmodSync(directory, 0o700); + const recordPath = join(directory, "cli.json"); + writeFileSync(recordPath, JSON.stringify({ + version: 1, enabled: true, bundle: { platform: process.platform, kind: hostKind, cliExecutable: join(home, "ocx") }, + })); + chmodSync(recordPath, 0o600); + return { home, directory, recordPath }; +} +function expectUnsafe(read: DesktopCliRecordRead): void { + expect(read).toMatchObject({ state: "invalid", issue: "record-unsafe" }); + expect(planDesktopCliHandoff({ platform: "linux", argv: ["--version"], env: {}, recordRead: read }, { + stat: (() => { throw new Error("unsafe record must not inspect or select a target"); }) as typeof stat, + })).toEqual({ kind: "error", issue: "record-unsafe" }); +} + +posixTest("POSIX safety precedes JSON/disabled interpretation and accepts explicit 0700/0600", () => { + const fixture = safetyFixture(); + expect(readDesktopCliRecord({ home: fixture.home }).state).toBe("ready"); + for (const value of ['{"version":1,"enabled":false}', "{"]) { + writeFileSync(fixture.recordPath, value); + chmodSync(fixture.recordPath, 0o666); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + chmodSync(fixture.recordPath, 0o600); + } + chmodSync(fixture.directory, 0o777); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); +}); + +posixTest("directory and record symlinks are refused without handoff", () => { + for (const linked of ["directory", "record"]) { + const fixture = safetyFixture(); + if (linked === "directory") { + const moved = join(fixture.home, "moved-directory"); + renameSync(fixture.directory, moved); + symlinkSync(moved, fixture.directory, "dir"); + } else { + const moved = join(fixture.directory, "moved-record"); + renameSync(fixture.recordPath, moved); + symlinkSync(moved, fixture.recordPath); + } + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + } +}); + +posixTest("stat seam refuses foreign ownership for either path without requiring root", () => { + for (const foreign of ["directory", "record"]) { + const fixture = safetyFixture(); + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + lstat: path => { + const info = lstatSync(path); + if (path === (foreign === "directory" ? fixture.directory : fixture.recordPath)) info.uid += 1; + return info; + }, + })); + } +}); +const rootTest = process.platform !== "win32" && process.geteuid?.() === 0 ? test : test.skip; +if (process.platform === "win32" || process.geteuid?.() !== 0) console.log("SKIP real foreign-owner fixture: effective uid is not root; injected-owner cases still run."); +rootTest("root-only real foreign-owner directory and record are refused", () => { + for (const foreign of ["directory", "record"]) { + const fixture = safetyFixture(); + chownSync(foreign === "directory" ? fixture.directory : fixture.recordPath, 10001, -1); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + } +}); + +posixTest("lstat to open replacement and every descriptor identity field fail closed", () => { + const fixture = safetyFixture(); + let actualFlags = 0; + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + open: (path, flags) => { + actualFlags = flags; + renameSync(path, join(fixture.directory, "old-record")); + writeFileSync(path, '{"version":1,"enabled":false}'); + return openSync(path, flags); + }, + })); + expect(actualFlags & constants.O_NOFOLLOW).toBe(constants.O_NOFOLLOW); + expect(actualFlags & constants.O_NONBLOCK).toBe(constants.O_NONBLOCK); + for (const field of ["dev", "ino", "uid", "mode"] as const) { + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + fstat: fd => { const info = fstatSync(fd); info[field] += 1; return info; }, + })); + } + expectUnsafe(readDesktopCliRecord({ home: fixture.home }, { + open: () => { throw coded("ENOENT"); }, + })); +}); + +posixTest("a FIFO record and a FIFO replacement return refusals before the 2-second deadline", () => { + for (const replaceAtOpen of [false, true]) { + const fixture = safetyFixture(); + if (!replaceAtOpen) { + rmSync(fixture.recordPath); + const created = spawnSync("mkfifo", ["-m", "600", fixture.recordPath], { encoding: "utf8", timeout: 1000 }); + expect(created.error).toBeUndefined(); + expect(created.status).toBe(0); + } + const script = [ + 'import { readDesktopCliRecord } from ' + JSON.stringify(pathToFileURL(repoPath("src", "lib", "desktop-cli-record.mjs")).href) + ';', + 'import { openSync, renameSync } from "node:fs";', + 'import { spawnSync } from "node:child_process";', + 'const path = ' + JSON.stringify(fixture.recordPath) + ';', + 'const deps = ' + replaceAtOpen + ' ? { open: (path, flags) => {', + 'renameSync(path, path + ".old");', + 'const made = spawnSync("mkfifo", ["-m", "600", path], { timeout: 1000 });', + 'if (made.error || made.status !== 0) throw new Error("mkfifo failed");', + 'return openSync(path, flags); } } : {};', + 'console.log(JSON.stringify(readDesktopCliRecord({ recordPath: path }, deps)));', + ].join("\n"); + const result = spawnSync("node", ["--input-type=module", "-e", script], { encoding: "utf8", timeout: 2000 }); + expect(result.error).toBeUndefined(); // Timeout termination is a failure, never refusal evidence. + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ state: "invalid", issue: "record-unsafe" }); + } +}); + +posixTest("macOS ACL seam rejects entries and failed checks before JSON interpretation", () => { + const fixture = safetyFixture(); + writeFileSync(fixture.recordPath, '{"version":1,"enabled":false}'); + for (const checkAcl of [() => false, () => { throw coded("ETIMEDOUT"); }, () => { throw coded("ENOENT"); }]) { + expectUnsafe(readDesktopCliRecord({ recordPath: fixture.recordPath, platform: "darwin" }, { checkAcl })); + } + let checked: string[] = []; + expect(readDesktopCliRecord({ recordPath: fixture.recordPath, platform: "darwin" }, { + checkAcl: (directory, path) => { checked = [directory, path]; return true; }, + }).state).toBe("disabled"); + expect(checked).toEqual([fixture.directory, fixture.recordPath]); +}); +const macTest = process.platform === "darwin" ? test : test.skip; +if (process.platform !== "darwin") console.log("SKIP real macOS extended ACL fixture: chmod +a is only available on macOS."); +macTest("macOS real extended ACLs on a 0700 directory or 0600 record are refused", () => { + for (const aclTarget of ["directory", "record"]) { + const fixture = safetyFixture(); + const path = aclTarget === "directory" ? fixture.directory : fixture.recordPath; + const added = spawnSync("/bin/chmod", ["+a", "everyone allow read", path], { encoding: "utf8", timeout: 1000 }); + expect(added.error).toBeUndefined(); + expect(added.status).toBe(0); + expect(lstatSync(path).mode & 0o077).toBe(0); + expectUnsafe(readDesktopCliRecord({ home: fixture.home })); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0c1fc226221..d3875bf280b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -789,6 +789,7 @@ "oauth-status-privacy.test.ts": "oauth", "oauth-store-multi.test.ts": "oauth", "oauth-tos-warning.test.ts": "gui", "oauth-upsert-preserves-api-key.test.ts": "oauth", "ocx-launcher-runtime.test.ts": "cli", "ocx-launcher-source.test.ts": "cli", + "ocx-launcher-desktop-handoff.test.ts": "cli", "cli-path-diagnostics.test.ts": "cli", "ocx-run.test.ts": "cli", "ollama-native-parser.test.ts": "providers/ollama", "ollama-native-reasoning-wire.test.ts": "providers/ollama", "ollama-native-structured-output.test.ts": "providers/ollama", "ollama-native-v4.test.ts": "providers/ollama",