diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 4cd3380ae18..ded5539a34a 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -34,6 +34,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; +import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; @@ -95,6 +96,15 @@ function currentPackageVersion() { } } +function pinnedBunVersion() { + try { + const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")); + return typeof pkg.dependencies?.bun === "string" ? pkg.dependencies.bun : ""; + } catch { + return ""; + } +} + function updateTag(currentVersion) { // Allowlist the tag: the value is argv-controlled and (on Windows) flows into a // shell-joined spawnSync — never forward arbitrary strings. @@ -884,6 +894,8 @@ const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; // imported; tests/cli/ocx-launcher-source.test.ts pins the two together. const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; +/** Total budget for validating a PATH Bun fallback (both probes together). */ +const PATH_BUN_PROBE_BUDGET_MS = 5_000; function findBunBinary(bunDir) { // The bundled `bun` package ships the binary as bin/bun.exe on every platform; @@ -899,6 +911,7 @@ function fail(msg) { const reinstall = installMethod === "pnpm" ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; + const desktopCli = findDesktopCli(); console.error( `opencodex: ${msg}\n` + "The bundled Bun runtime could not be prepared. This usually means the\n" + @@ -906,7 +919,8 @@ function fail(msg) { "or pnpm did not approve bun's build) or optional dependencies. Reinstall with:\n" + ` ${reinstall}\n` + "(use sudo if the original install used sudo; without --ignore-scripts\n" + - "and without --omit=optional / optional=false)" + "and without --omit=optional / optional=false)" + + (desktopCli ? `\nAn installed Desktop CLI is available: "${desktopCli}"` : "") ); process.exit(1); } @@ -923,25 +937,32 @@ function resolveBun({ allowInstall = true } = {}) { ); } - let bunDir; + let bunDir = null; try { bunDir = bunBinDir(); - } catch { - fail("the `bun` dependency is not installed."); - } + } catch { /* Missing dependency can still fall back to a validated PATH Bun. */ } - let bin = findBunBinary(bunDir); + let bin = bunDir ? findBunBinary(bunDir) : null; if (bin) return { path: bin, source: "bundled" }; // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. - const installJs = join(bunDir, "install.js"); - if (allowInstall && existsSync(installJs)) { + const installJs = bunDir ? join(bunDir, "install.js") : null; + if (allowInstall && installJs && existsSync(installJs)) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } - if (!bin) fail("Bun binary missing after install attempt."); - return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled" }; + + // Reached only when the bundled runtime is unusable. The two probes start the candidate Bun + // cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for + // well over a second, so the total budget is generous rather than interactive-tight. + const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); + if (pathBun) { + console.error(`opencodex: using PATH Bun ${pathBun.version}.`); + return { path: pathBun.path, source: "process" }; + } + fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } // `ocx update --help` prints usage and exits WITHOUT side effects. The Node launcher diff --git a/devlog/_plan/261009_L1-desktop-cli/019_wp2_done.md b/devlog/_plan/261009_L1-desktop-cli/019_wp2_done.md new file mode 100644 index 00000000000..236008ae6f7 --- /dev/null +++ b/devlog/_plan/261009_L1-desktop-cli/019_wp2_done.md @@ -0,0 +1,15 @@ +# 019 — wp2 done (PR A #6802) + +**Conclusion.** The CLI now recognizes a Desktop-supervised runtime without an ownership claim and stops recommending a +competing service. PR #6802 (head 1718fcad8b, rebased on dev fd2032050a) carries evidence, projection, GUI and docs. + +**Evidence.** 332 focused tests pass (receipt), typecheck/lint/GUI tsc/build/ratchet/structure/privacy/docs build pass; +live read-only check on the reporting Mac matches the expected output; independent review PASS after two fix rounds +(found: live wording without liveness, stale supervision across login probe, override pid binding, untranslated +guidance, ownership race after the final probe, custom-local precedence). + +**What did not improve / risks.** Windows still reports `unsupported`. The fix reaches users whose CLI is updated; the +2.81.0 runtime's own startup probe still lacks the field until the Desktop app updates (the override covers updated CLIs). +Hosted CI was pending at D. + +**Next.** wp5 command guards stacked on this branch (020 + 003); then wp3 launcher from dev. diff --git a/devlog/_plan/261009_L1-desktop-cli/021_wp5_execution_packets.md b/devlog/_plan/261009_L1-desktop-cli/021_wp5_execution_packets.md new file mode 100644 index 00000000000..6624ef2d2c1 --- /dev/null +++ b/devlog/_plan/261009_L1-desktop-cli/021_wp5_execution_packets.md @@ -0,0 +1,47 @@ +# 021 — wp5 execution packets (cycle plan, PR B stacked on #6802) + +**Continuity.** wp2 D (019): "evidence + projection shipped as #6802; next wp5 command guards stacked on that branch, +then wp3 launcher from dev." Kept. Re-verification: `git diff c15037b324 fd2032050a` touches none of bin/, src/service, +src/update, src/cli/{index,dispatch,update-restart}.ts, so 020 anchors still hold. Branch +`codex/desktop-sidecar-command-guards` cut from #6802's head in the same lane worktree (the session's source binding). + +Contract: 003 + 020 including r2–r5 (later wins). Blocking rule everywhere: `createSupervisionLatch()` from +`src/service/desktop-supervision.mjs` — blocked on `desktop` or `unknown && desktopSeen`, cleared only by `none`. +`desktopServiceRefusal` (020) therefore takes the latch verdict, not just `kind === "desktop"`. + +| Worker | Write scope | Notes | +|---|---|---| +| G1 service | NEW `src/service/desktop-command-guard.ts`; MODIFY `src/service/cli.ts`, `src/service/orchestration.ts`, `src/service/repair.ts`; tests `tests/service/service-ownership-handover.test.ts` | refuse install/repair/start/restart before any mutation incl. Windows staging; one latch per command | +| G2 updater | MODIFY `src/update/runtime-ownership.mjs` + `.d.mts`, `bin/ocx.mjs`, `src/update/index.ts`, `src/update/restart-ownership.ts`; tests `tests/update/update-desktop-owner.test.ts`, `tests/update/update-restart-lease.test.ts`, `tests/cli/cli-update-restart.test.ts`, `tests/cli/ocx-launcher-source.test.ts`, `tests/cli/ocx-launcher-runtime.test.ts` | planner `supervision` input; initial + **pre-stop** (index.ts stop block, bin/ocx.mjs stop) + recovery + refresh through one latch; Node proof per 020 r5 | +| G3 CLI wording | NEW `src/cli/desktop-runtime-guidance.ts`; MODIFY `src/cli/index.ts` (net ≤ 0 lines preferred, max +5), `src/cli/dispatch.ts`, `src/cli/update-restart.ts`; tests `tests/cli/cli-stop-json.test.ts`, `tests/cli/cli-restart-health.test.ts` | type-only import from G1's module; stop notice suppressed under --json, wording "may" | +| G4 docs | `structure/desktop-shell.md`, `structure/ops/service-and-sidecars.md`, docs-site desktop guide en + ko | guard paragraph + bypass ledger summary (020 r4 F7), user section from 020 (~:766/:782) | + +Verifier (main, after integration): the union of the worker test files plus wp2's nine files (regression), +`bun run typecheck`, `bun scripts/file-size-ratchet.ts`, `bun run structure:check`, `bun run privacy:scan`, +docs-site build, Node import proof `node bin/ocx.mjs update --help`, and a read-only live check: lane CLI +`service install` must refuse on the reporting Mac **without mutating anything** — run it only with +`--dry-run` if the command supports one; otherwise do not run it live (unit tests carry the proof). + + +Reflection (Kant): MISALIGNED → folded: G4 also owns `structure/runtime.md` (cap 600, replace only) and +`structure/cli-management.md` (020:684, :737); main's verifier adds `tests/cli/cli-dispatch.test.ts`, +`tests/cli/system-restart-client.test.ts`, `tests/cli/system-restart-client-package-tree.test.ts` (020:850). + +Audit (reviewer 01a11e4b): GO-WITH-FIXES (blockers=2), folded: +1. `SupervisionInspector = (deps?: Parameters[0]) => DesktopSupervision` (G1). + Restart fallback (G3) keeps the restart target pid and calls `inspect({ targetPid })`; an unrelated Desktop pid never + blocks the target's recovery. Tests: unrelated pid → allowed; target-bound `unknown`+`desktopSeen:true` → blocked. +2. Pre-stop targets (G2): Bun updater uses `targetPid: runtimeTrusted ? livePid : undefined` (src/update/index.ts:522-530); + the Node launcher passes no `targetPid` and relies on the inspector's own pid-file/runtime-port correlation + (bin/ocx.mjs:409). Missing target → inspector correlation only; mismatch → `unknown` (latch keeps a prior block). +3. Every `unknown` fixture carries `desktopSeen` explicitly (false for pass-through, true for refusal cases). +Live check: `service` has no `--dry-run` (src/service/cli.ts:169-186) → no live service command is run. + + +Combined-tree check (030 r4 / 031): scratch branch `codex/l1-combined-check` = PR B head 6653faf382 merged with PR C +head (launcher). Conflict only in `tests/cli/ocx-launcher-runtime.test.ts` (both PRs append a describe block at the end +of the file; resolution keeps both blocks) and in this plan unit (now synced identically to both branches). Result: +typecheck pass; 326 pass / 2 skip / 0 fail across launcher runtime/source, bun-runtime, update-desktop-owner, +version-skew, dispatch, help-recovery; ratchet and structure pass; `node bin/ocx.mjs update --help` exit 0. Whichever of +PR B / PR C merges second needs that one test-file conflict resolved the same way. + diff --git a/devlog/_plan/261009_L1-desktop-cli/030_launcher_bun_fallback.md b/devlog/_plan/261009_L1-desktop-cli/030_launcher_bun_fallback.md index f904371a6a1..23f72a4307e 100644 --- a/devlog/_plan/261009_L1-desktop-cli/030_launcher_bun_fallback.md +++ b/devlog/_plan/261009_L1-desktop-cli/030_launcher_bun_fallback.md @@ -1127,3 +1127,12 @@ Open product/design question: none within accepted wp3 scope. Recorded implement Verify on the combined tree before the last push of the later PR. - **F6:** add `tests/ci-workflows/bun-runtime.test.ts` to the focused verifier command. + + +## r6 amendment (hosted CI, 2026-10-09) + +Production budget raised from 750 ms to `PATH_BUN_PROBE_BUDGET_MS = 5_000` in `bin/ocx.mjs`. Windows CI run +37894746375 (`windows 9/9`) rejected a valid PATH Bun: the two cold probes of a freshly copied `bun.exe` exceeded 750 ms +under on-access scanning. The fallback runs only when the bundled runtime is unusable, so a larger bound costs nothing +on the normal path; it stays a total bound shared by both probes. + diff --git a/devlog/_plan/261009_L1-desktop-cli/031_wp3_execution_packets.md b/devlog/_plan/261009_L1-desktop-cli/031_wp3_execution_packets.md new file mode 100644 index 00000000000..4559972c9b0 --- /dev/null +++ b/devlog/_plan/261009_L1-desktop-cli/031_wp3_execution_packets.md @@ -0,0 +1,32 @@ +# 031 — wp3 execution packets (cycle plan, PR C → dev) + +**Continuity.** wp2 D (019) said "wp5 guards next, then wp3". The goalplan cursor selected wp3 first (registration +order; both are ready), and the two are independent, so wp3 runs now on `codex/ocx-launcher-bun-fallback` from dev +fd2032050a; wp5's packets (021) and branch stay ready. 030 anchors were written against c15037b324; dev's move to +fd2032050a did not touch bin/, src/lib, src/cli/root.ts or src/cli/version-skew.ts, so they hold. + +Contract: 030 incl. r2/r4 (later wins) + 003. PR A (#6802) already added the "Using the ocx CLI with the desktop app" +section to the docs-site desktop guide, so this PR does not touch that page (040's doc item is satisfied there). + +| Worker | Write scope | Notes | +|---|---|---| +| K1 launcher | NEW `src/lib/bun-path-runtime.mjs` + `.d.mts`; MODIFY `bin/ocx.mjs`; tests `tests/cli/ocx-launcher-runtime.test.ts`, `tests/cli/ocx-launcher-source.test.ts`, `tests/ci-workflows/bun-runtime.test.ts` | order override → bundled → installer recovery → validated PATH; identity `-e` probe; POSIX writable rejection; same major, minor ≥ pinned; macOS Desktop CLI pointer in `fail`; source assertion scoped to `resolveBun`/`fail` | +| K2 skew notice | NEW `src/cli/version-skew-notice.ts`; MODIFY `src/cli/root.ts`; tests `tests/cli/cli-version-skew.test.ts` | lifecycle commands only, 200 ms, neutral text, never changes exit code, skip --json/--help | +| K3 docs | `structure/runtime.md` (net 0, cap 600), `structure/ops/docs-and-release.md`, `structure/ops/service-and-sidecars.md` (launcher paragraph ~:139 only), docs-site install troubleshooting page(s) named in 030 §"MODIFY public installation troubleshooting" (en + ko; other locales must not contradict) | PR A edits other paragraphs of service-and-sidecars.md/runtime.md; touch only the launcher text | + +Verifier (main): `bun test tests/cli/ocx-launcher-runtime.test.ts tests/cli/ocx-launcher-source.test.ts tests/ci-workflows/bun-runtime.test.ts tests/cli/cli-version-skew.test.ts tests/cli/cli-head.test.ts` (isolated +`OPENCODEX_HOME`), `bun run typecheck`, ratchet, structure:check, privacy:scan, docs-site build, and a real Node +reproduction of the reported failure: a temp copy of the package without `node_modules/bun` launched with +`node bin/ocx.mjs --version` and a PATH containing the user's Bun → succeeds with the fallback notice (read-only; no +proxy start). + + +Reflection (Pascal): MISALIGNED → folded: main's verifier also runs `tests/cli/cli-help-recovery.test.ts`, +`tests/cli/cli-ready.test.ts`, `tests/cli/cli-ready-subprocess.test.ts`, `tests/cli/cli-resolve.test.ts`, +`tests/cli/cli-resolve-subprocess.test.ts` (030:1076). Combined-tree check (030 r4): main merges PR B's and PR C's +branches into a scratch branch before the later PR's last push and runs ocx-launcher-source + ocx-launcher-runtime there. + +Audit (reviewer 01a11e4b): GO-WITH-FIXES (blockers=1), folded: the Node reproduction runs with a child-only +environment — `BUN_RUNTIME_TRANSPILER_CACHE_PATH=0`, `OPENCODEX_HOME`/`CODEX_HOME`/`HOME`-derived caches pointed at the +temp fixture, `OPENCODEX_BUN_PATH` unset, PATH = the user's Bun directory plus system dirs (child only), and the temp +package copy keeps every installed dependency except `node_modules/bun` so nothing installs during the run. diff --git a/devlog/_plan/261009_L1-desktop-cli/039_wp3_done.md b/devlog/_plan/261009_L1-desktop-cli/039_wp3_done.md new file mode 100644 index 00000000000..d129f3ff291 --- /dev/null +++ b/devlog/_plan/261009_L1-desktop-cli/039_wp3_done.md @@ -0,0 +1,13 @@ +# 039 — wp3 done (PR C #6807) + +**Conclusion.** The package launcher no longer hard-fails when the bundled Bun is missing and a valid Bun is on PATH; +lifecycle commands warn once on CLI/proxy version skew. PR #6807 (head 73f166f08a, from dev fd2032050a). + +**Evidence.** 318 focused tests pass (receipt); real Node reproduction of the report succeeds on PATH Bun 1.4.0; review +PASS first round. + +**Did not improve / risks.** Node 18 and native Windows are left to CI. The PATH check is policy, not authentication. +The npm/pnpm/mise update path (handled in Node before Bun) has no skew notice. The plan unit is duplicated in #6802 and +#6807 with identical content (merges cleanly in either order). + +**Next.** wp5 command guards on `codex/desktop-sidecar-command-guards` (stacked on #6802), then wp4 closeout. diff --git a/devlog/_plan/261009_L1-desktop-cli/040_path_cli_decision_closeout.md b/devlog/_plan/261009_L1-desktop-cli/040_path_cli_decision_closeout.md index 30f1802f48b..c5d11da44a7 100644 --- a/devlog/_plan/261009_L1-desktop-cli/040_path_cli_decision_closeout.md +++ b/devlog/_plan/261009_L1-desktop-cli/040_path_cli_decision_closeout.md @@ -38,3 +38,17 @@ updater. 3. Final report: PR links, head SHAs, CI runs, open decisions (Windows evidence, GUI wording for unowned supervision, PATH installer follow-up), user-environment suggestions (remove stale `~/.opencodex/service-state.json` via `ocx service uninstall` from a working CLI, re-enable Start at Login, repoint `~/.bun/bin/ocx`) — suggestions only. + +## Outcome (wp4, 2026-10-09) + +Decision unchanged: no Desktop PATH installer in this lane. What users get instead: + +- #6802: `ocx status`/`doctor`/`resolve` from an updated CLI name OpenCodex Desktop as the live supervisor and stop + recommending `ocx service install`; the docs-site desktop guide (en/ko) gained "Using the ocx CLI with the desktop + app" with the bundled CLI path per platform. +- #6807: when the package launcher cannot run, its failure text names `/Applications/OpenCodex.app/Contents/MacOS/ocx` + (or the `~/Applications` copy) when installed; a usable PATH Bun now runs the CLI instead of failing. +- PR B (wp5): command guards so an updated CLI refuses to compete with the Desktop runtime. + +Follow-up (separate unit, not opened by this lane): the native "Install ocx command" design above. The coordinator +thread took it on as `codex/desktop-owned-path-cli`; this lane does not touch that scope. diff --git a/devlog/_plan/261009_L1-desktop-cli/041_wp4_cycle_plan.md b/devlog/_plan/261009_L1-desktop-cli/041_wp4_cycle_plan.md new file mode 100644 index 00000000000..54917997753 --- /dev/null +++ b/devlog/_plan/261009_L1-desktop-cli/041_wp4_cycle_plan.md @@ -0,0 +1,32 @@ +# 041 — wp4 cycle plan (decision record + CI closeout for PR A and PR C) + +**Continuity.** wp3 D (039): "next wp5 guards, then wp4 closeout". The goalplan cursor selects wp4 before wp5 +(registration order; wp4 depends only on wp2/wp3). This cycle therefore covers the parts of wp4 that do not need +PR B: the Desktop PATH CLI decision record and exact-head CI for #6802 and #6807. PR B's CI and the final lane report +belong to the wp5 cycle that follows; criterion c-5 is met only after all three PRs are green. + +Deliverables (B): +1. Finalize `040_path_cli_decision_closeout.md` with the outcome: decision unchanged (no Desktop PATH installer in this + lane), evidence that PR A covers the CLI section of the desktop guide and PR C names the Desktop CLI in launcher + failures; follow-up design kept as written. +2. CI: rerun only failed jobs of #6802 run 37873621613 (`macos 1/2` timed out in a `tests/ci-workflows` batch whose + 12 files all pass alone; no changed module is in that batch). If the rerun fails again in the same place, compare + with dev's run; a real regression goes back to a fix in wp5's branch base (PR A). +3. Watch #6807 CI at its current head. + +Check (C): both PRs' required checks green at their exact heads (`gh pr checks`), recorded with run ids. +Out of scope: merging, other lanes, PR B. + + +Audit (reviewer 01a11e4b): GO-WITH-FIXES (blockers=1), folded: +- Review evidence is recorded here for A and C: #6802 independent review PASS (round 3, reviewer 01a11e61, head + e866bf0815 → rebased 1718fcad8b, content-identical); #6807 independent review PASS (reviewer 01a11e61, head 73f166f08a). + PR B's review and the three-PR reconciliation transfer to wp5's C/D. +- 039 now exists on this branch too (copied from the launcher branch; identical content). +- The macos 1/2 failure is retried as a whole failed job after the run completes; a repeat in the same batch is + treated as real and investigated, not retried again. + +Check amendment (at B): run 37873621613 still had 10 queued/running jobs (Windows runner backlog), so its failed job +cannot be retried yet and #6807's CI is pending. This cycle's C therefore checks the decision record (privacy and +structure gates over the changed docs) and records the CI snapshot; the green-at-exact-head requirement for all three +PRs is verified in wp5's C/D before c-5 is met. Nothing about c-5 is weakened. diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index cd334936180..17cba0a6b3b 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -30,9 +30,22 @@ pnpm add -g --allow-build=bun @bitkyc08/opencodex :::note[npm blocked the bun postinstall?] Recent npm versions may block bun's postinstall script (`npm warn install-scripts ... blocked because they are not covered by allowScripts`), -which leaves the bundled Bun runtime unprepared. Reinstall allowing bun's -script — and always include the package name (npm's abbreviated suggestion -omits it, which would reinstall the current directory instead): +which leaves the bundled Bun runtime unprepared. After trying permitted bundled-runtime +recovery, the launcher can use an executable Bun from an absolute PATH directory if its +stable version has the pinned major and a minor at least the pinned minor. With the current +1.4.2 dependency, that means 1.4.0 or newer within major 1. Bounded version-policy and identity +checks require `bun --version` to match `bun -e`'s `Bun.version`; on POSIX, the resolved file +and its parent directory must not be group- or world-writable. A one-line stderr notice names +the selected Bun version. Bun is still bundled; installing it yourself is optional. + +If no runtime works, the error still includes “the `bun` dependency is not installed” when +the dependency is missing. On macOS, it also names an executable Desktop CLI found at +`/Applications/OpenCodex.app/Contents/MacOS/ocx` or +`~/Applications/OpenCodex.app/Contents/MacOS/ocx`. You can invoke that path explicitly; +the package launcher does not delegate automatically or install a PATH shim. + +To repair the bundled runtime, reinstall allowing bun's script. Always include the package +name: npm's abbreviated suggestion omits it and would reinstall the current directory: ```bash npm install -g --allow-scripts=bun @bitkyc08/opencodex @@ -49,6 +62,12 @@ ocx --version opencodex --version ``` +Before start, stop, restart, service, or an update handled by the Bun CLI, a brief stderr +notice can identify a CLI/proxy version mismatch. It is advisory and preserves the command's +exit code; JSON and help output do not trigger it. npm/pnpm updates handled by the Node launcher +before the Bun CLI starts do not emit this notice. `ocx status`, `ocx doctor`, and `ocx resolve` +already report version skew through their diagnostics. + If you install the background service from a shell using fnm, nvm, mise, asdf, or volta, OpenCodex leaves shell-local multishell directories out of the service PATH. On Linux it selects a durable `ocx` launcher when available; otherwise it uses the package's Bun diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 146f25bb01c..ab58fbdd03c 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -24,9 +24,22 @@ npm install -g @bitkyc08/opencodex :::note[npm이 bun postinstall을 차단했다면?] 최신 npm은 bun의 postinstall 스크립트를 차단할 수 있습니다(`npm warn install-scripts ... blocked because they are not covered by allowScripts`). -이 경우 번들 Bun 런타임이 준비되지 않으므로 bun 스크립트를 허용해서 -재설치하세요. npm 경고의 축약 명령에는 패키지 이름이 빠져 있어 현재 -디렉터리를 재설치하게 되니, 항상 패키지 이름을 명시해야 합니다: +이 경우 번들 Bun 런타임이 준비되지 않을 수 있습니다. 런처는 허용된 번들 복구를 +시도한 다음, 절대 경로 PATH 디렉터리의 실행 가능한 Bun을 검증합니다. 정식 버전의 +major가 패키지 pin과 같고 minor가 pin 이상이면 사용할 수 있습니다. 현재 의존성 +1.4.2에서는 1.4.0 이상, 2.0.0 미만입니다. 제한 시간 안에 버전 정책과 identity를 확인하며, +`bun --version` 출력이 `bun -e`로 확인한 `Bun.version`과 같아야 합니다. POSIX에서는 +해석된 실행 파일이나 그 부모 디렉터리에 그룹 또는 다른 사용자의 쓰기 권한이 있으면 거부합니다. +선택한 Bun 버전을 stderr 한 줄로 알립니다. Bun은 계속 패키지에 포함되므로 별도 설치는 선택 사항입니다. + +사용할 런타임이 없고 의존성이 빠져 있으면 오류에 “the `bun` dependency is not installed”가 +표시됩니다. macOS에서는 실행 가능한 Desktop CLI가 있으면 +`/Applications/OpenCodex.app/Contents/MacOS/ocx` 또는 +`~/Applications/OpenCodex.app/Contents/MacOS/ocx` 경로도 안내합니다. 이 경로를 직접 실행할 +수 있지만 패키지 런처가 자동 위임하거나 PATH shim을 설치하지는 않습니다. + +번들 런타임을 복구하려면 bun 스크립트를 허용해서 재설치하세요. npm 경고의 축약 명령에는 +패키지 이름이 빠져 있어 현재 디렉터리를 재설치하게 되니, 항상 패키지 이름을 명시해야 합니다: ```bash npm install -g --allow-scripts=bun @bitkyc08/opencodex @@ -43,6 +56,12 @@ ocx --version opencodex --version ``` +start, stop, restart, service와 Bun CLI가 처리하는 update에서는 CLI와 실행 중인 프록시의 +버전이 다르면 짧은 stderr 안내가 나올 수 있습니다. 안내는 명령 종료 코드를 바꾸지 않으며 +JSON·도움말 모드에서는 실행하지 않습니다. Bun CLI 시작 전에 Node 런처가 처리하는 +npm/pnpm update에는 이 안내가 적용되지 않습니다. `ocx status`, `ocx doctor`, `ocx resolve`는 +기존 진단에서 버전 차이를 표시합니다. + ## 독립 실행형 바이너리(npm 없음) 릴리스에는 지원되는 macOS, Linux, Windows용 독립 실행형 `ocx` 바이너리도 포함됩니다. diff --git a/src/cli/root.ts b/src/cli/root.ts index 5f36caf2d58..d40ac9e873b 100644 --- a/src/cli/root.ts +++ b/src/cli/root.ts @@ -1,6 +1,6 @@ /** - * CLI head: version/help early exits, `ocx ready` pre-parse, and the bounded - * Codex-shim auto-restore preflight, in that order (Phase 1 of the CLI + * CLI head: version/help early exits, ready/resolve pre-parse, lifecycle version + * notice, then bounded Codex-shim auto-restore preflight (Phase 1 of the CLI * deepening — moved out of src/cli/index.ts). * * `parseCliHead` is pure (no I/O, no process access) so the ordering and the @@ -16,6 +16,7 @@ import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; import { findCommand } from "./registry"; import { printUnknownCommand } from "./help-recovery"; import { noteCredentialArgv, redactSecretArgs } from "./secret-args"; +import { maybeNoticeVersionSkew } from "./version-skew-notice"; export interface CliHead { kind: "version" | "help" | "ready" | "resolve" | "command"; @@ -141,6 +142,7 @@ export async function runCli(argv: string[]): Promise { console.error(uninstallError); process.exit(2); } + await maybeNoticeVersionSkew(head.command, head.args); maybeAutoRestoreCodexShim(head.command, head.args); return head; } diff --git a/src/cli/version-skew-notice.ts b/src/cli/version-skew-notice.ts new file mode 100644 index 00000000000..c033c585bde --- /dev/null +++ b/src/cli/version-skew-notice.ts @@ -0,0 +1,77 @@ +import { readConfigDiagnostics } from "../config/diagnostics"; +import { readRuntimePort } from "../config/process-state"; +import { packageVersion } from "./help"; +import { proxyIdentityAt } from "../server/proxy-liveness"; +import { computeVersionSkew } from "./version-skew"; + +const LIFECYCLE = new Set(["start", "stop", "restart", "update", "service"]); +const NOTICE_DEADLINE_MS = 200; +let printed = false; + +export function shouldNoticeVersionSkew(command: string | undefined, args: string[]): boolean { + const boundary = args.indexOf("--"); + const prefix = boundary < 0 ? args : args.slice(0, boundary); + return LIFECYCLE.has(command ?? "") + && !prefix.some(arg => arg === "--json" || arg === "--help" || arg === "-h") + && prefix[1] !== "help"; +} + +export interface VersionSkewNoticeIo { + readRuntime?: typeof readRuntimePort; + readConfig?: typeof readConfigDiagnostics; + probe?: typeof proxyIdentityAt; + cliVersion?: () => string; + now?: () => number; + warn?: (message: string) => void; +} + +/** + * One proxyIdentityAt (src/server/proxy-liveness.ts) call validates /healthz identity, + * expected PID and terminal-safe version within the total diagnostic budget. + */ +export async function maybeNoticeVersionSkew( + command: string | undefined, args: string[], io: VersionSkewNoticeIo = {}, +): Promise { + if (printed || !shouldNoticeVersionSkew(command, args)) return; + const now = io.now ?? Date.now; + const deadlineAt = now() + NOTICE_DEADLINE_MS; + let timer: ReturnType | undefined; + try { + const runtime = (io.readRuntime ?? readRuntimePort)(); + let port: number; + let hostname: string | undefined; + let expectedPid: number | undefined; + if (runtime) { + ({ port, hostname } = runtime); + expectedPid = runtime.pid; + } else { + const diagnostics = (io.readConfig ?? readConfigDiagnostics)(); + if (diagnostics.error) return; + port = diagnostics.config.port ?? 10100; + hostname = diagnostics.config.hostname; + } + const remaining = Math.floor(deadlineAt - now()); + if (remaining <= 0) return; + const cliVersion = (io.cliVersion ?? packageVersion)(); + if (now() >= deadlineAt) return; + const identity = await Promise.race([ + (io.probe ?? proxyIdentityAt)(port, { hostname, expectedPid }, { + timeoutMs: Math.max(1, Math.floor(deadlineAt - now())), + attempts: 1, deadlineAt, nowFn: now, + }), + new Promise(resolve => { + timer = setTimeout(() => resolve(null), Math.max(1, deadlineAt - now())); + }), + ]); + if (printed || !identity || now() >= deadlineAt) return; + const skew = computeVersionSkew(cliVersion, identity.version); + if (!skew.skewed) return; + printed = true; + (io.warn ?? console.error)(`ocx ${cliVersion} does not match the running proxy ${identity.version}. ` + + "Check which opencodex installation you meant to use (`ocx status` shows both)."); + } catch { + // Best-effort diagnostics must not fail a lifecycle command or change its exit code. + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/src/lib/bun-path-runtime.d.mts b/src/lib/bun-path-runtime.d.mts new file mode 100644 index 00000000000..88d3aa94d75 --- /dev/null +++ b/src/lib/bun-path-runtime.d.mts @@ -0,0 +1,32 @@ +export interface DesktopCliIo { + stat?: (path: string) => { isFile(): boolean }; + access?: (path: string, mode: number) => void; +} +export interface PathBunIo extends DesktopCliIo { + now?: () => number; + realpath?: (path: string) => string; + stat?: (path: string) => { isFile(): boolean; mode: number }; + isRealBunBinary?: (path: string) => boolean; + spawnSync?: (path: string, args: string[], options: { + env: NodeJS.ProcessEnv; + encoding: "utf8"; + timeout: number; + maxBuffer: number; + killSignal: "SIGKILL"; + shell: false; + windowsHide: true; + stdio: ["ignore", "pipe", "pipe"]; + }) => { status: number | null; stdout?: string; signal?: string | null; error?: unknown }; +} +export declare function findPathBun(options: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + pinnedVersion: string; + deadlineMs?: number; + io?: PathBunIo; +}): { path: string; version: string } | null; +export declare function findDesktopCli(options?: { + platform?: NodeJS.Platform; + home?: string; + io?: DesktopCliIo; +}): string | null; diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs new file mode 100644 index 00000000000..729e86901cd --- /dev/null +++ b/src/lib/bun-path-runtime.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from "node:child_process"; +import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; +import { performance } from "node:perf_hooks"; +import { isRealBunBinary } from "./bun-binary-validator.mjs"; + +const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const MAX_VERSION_BYTES = 4096; +const IDENTITY_PROBE = "process.stdout.write(String(typeof Bun==='object'&&Bun.version))"; + +function stableVersion(value) { + if (typeof value !== "string" || value.length > 64) return null; + const match = STABLE.exec(value); + if (!match) return null; + const numbers = match.slice(1, 4).map(Number); + return numbers.every(Number.isSafeInteger) ? numbers : null; +} + +/** Discover the first absolute PATH Bun passing version-policy and identity checks. */ +export function findPathBun({ + env = process.env, platform = process.platform, pinnedVersion, + deadlineMs = 750, io = {}, +}) { + const pin = stableVersion(pinnedVersion); + if (!pin || !Number.isFinite(deadlineMs) || deadlineMs <= 0) return null; + const paths = platform === "win32" ? win32 : posix; + const absolute = value => paths.isAbsolute(value) + && (platform !== "win32" || /^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)/.test(value)); + const now = io.now ?? (() => performance.now()); + const end = now() + deadlineMs; + const realpath = io.realpath ?? realpathSync.native; + const stat = io.stat ?? statSync; + const access = io.access ?? accessSync; + const validate = io.isRealBunBinary ?? isRealBunBinary; + const spawn = io.spawnSync ?? spawnSync; + const key = platform === "win32" + ? Object.keys(env).find(name => name.toLowerCase() === "path") : "PATH"; + const directories = (env[key ?? "PATH"] ?? "").split(platform === "win32" ? ";" : ":"); + const seen = new Set(); + const probe = (path, args) => { + const remaining = Math.floor(end - now()); + if (remaining <= 0) return null; + const result = spawn(path, args, { + env, encoding: "utf8", timeout: remaining, maxBuffer: MAX_VERSION_BYTES, + killSignal: "SIGKILL", shell: false, windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + if (now() >= end || result.error || result.signal || result.status !== 0 + || typeof result.stdout !== "string" || result.stdout.length > MAX_VERSION_BYTES) return null; + return result.stdout.trim(); + }; + for (const directory of directories) { + if (now() >= end) break; + if (!directory || !absolute(directory)) continue; + try { + const path = realpath(paths.join(directory, platform === "win32" ? "bun.exe" : "bun")); + if (!absolute(path)) continue; + const identity = platform === "win32" ? path.toLowerCase() : path; + if (seen.has(identity)) continue; + seen.add(identity); + const file = stat(path); + if (!file.isFile()) continue; + if (platform !== "win32" && ((file.mode & 0o022) || (stat(paths.dirname(path)).mode & 0o022))) continue; + access(path, constants.X_OK); + if (!validate(path)) continue; + const version = probe(path, ["--version"]); + const candidate = stableVersion(version); + if (!candidate || candidate[0] !== pin[0] || candidate[1] < pin[1]) continue; + if (probe(path, ["-e", IDENTITY_PROBE]) !== version) continue; + return { path, version }; + } catch { + // Unreadable, unexecutable, vanished or invalid: try the next absolute candidate. + } + } + return null; +} + +/** Failure-text pointer only. Never execute or delegate to this binary. */ +export function findDesktopCli({ platform = process.platform, home = homedir(), io = {} } = {}) { + if (platform !== "darwin") return null; + const stat = io.stat ?? statSync; + const access = io.access ?? accessSync; + for (const path of [ + "/Applications/OpenCodex.app/Contents/MacOS/ocx", + posix.join(home, "Applications/OpenCodex.app/Contents/MacOS/ocx"), + ]) { + try { + if (!stat(path).isFile()) continue; + access(path, constants.X_OK); + return path; + } catch { /* Missing or not executable: no pointer for this candidate. */ } + } + return null; +} diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 38c2748830f..ab0a85afd79 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -343,11 +343,11 @@ npm dependency pinned to `1.4.2` (esbuild-style: a tiny main package plus platfo Invariants: -- `bin/ocx.mjs` resolves the bundled binary via `require.resolve("bun/package.json")` and a size gate - (`>= 1 MB`) that rejects the ~450-byte placeholder stub left by `--ignore-scripts`/pnpm; it then - lazy-runs `install.js` and execs `src/cli/index.ts` under Bun, propagating exit code and signal. - The Windows service wrapper applies the same gate before each launch and waits on a placeholder - instead of executing it ([Windows service wrapper](#windows-service-wrapper-and-incomplete-updates)). +- `bin/ocx.mjs` selects explicit override, bundled Bun, allowed installer recovery, then validated PATH Bun. + `src/lib/bun-path-runtime.mjs` requires absolute entries, canonical regular/executable files, the >=1 MB gate, and rejects group/world-writable resolved files or parent directories on POSIX. + Bounded version-policy and identity checks require `--version` to match `-e`'s `Bun.version`, a stable version with the pinned major and minor ≥ pinned minor (currently 1.4.0 ≤ version < 2.0.0). + PATH selection stamps `process`; failure may name an installed Desktop CLI without executing it. + The [Windows service wrapper](#windows-service-wrapper-and-incomplete-updates) keeps its own placeholder wait gate; updater inspection never runs installer recovery. - `package.json` carries `"trustedDependencies": ["bun"]` so `bun install` runs the dependency's postinstall, and `"engines": { "node": ">=18" }` (Bun is no longer a user prerequisite). - The plain-Node launcher owns `OPENCODEX_BUN_PATH` selection before Bun can load project dotenv and diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 3691558cb74..b24c6ad2669 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -140,9 +140,9 @@ repair compares the full plist after normalizing its previous PATH: a PATH clean definition change reloads the live job through the guarded eviction and bootstrap path. Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that -versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound -`OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are -rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by +versioned tree. Only a pre-Bun, proof-bound `OPENCODEX_BUN_PATH` with durable source `override` is +carried through; `bin/ocx.mjs` rediscovers bundled or validated PATH Bun, stamping PATH selection as +`process` via `src/lib/bun-path-runtime.mjs`. The API-auth token remains file-backed and is loaded only by the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job against `expectedLaunchdCommand`, which still follows a `launcherPath` recorded by a pre-pinning install rather than re-walking PATH, so such a job is never misreported as an older plist (#3464). diff --git a/structure/runtime.md b/structure/runtime.md index d4d436003e4..47e22da1a6e 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). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | +| `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. | | `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/ci-workflows/bun-runtime.test.ts b/tests/ci-workflows/bun-runtime.test.ts index 0bbb22d8e46..ae84eba59d9 100644 --- a/tests/ci-workflows/bun-runtime.test.ts +++ b/tests/ci-workflows/bun-runtime.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, afterAll, afterEach } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../../src/lib/bun-runtime"; +import { findPathBun } from "../../src/lib/bun-path-runtime.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -50,6 +52,45 @@ describe("isRealBunBinary (size gate vs placeholder stub)", () => { }); describe("bundledBunPath / durableBunPath", () => { + it("PATH-selected process provenance survives durable selection and project dotenv", () => { + const root = join(tmp, "path-dotenv"); + const pathDir = join(root, "path"); + mkdirSync(pathDir, { recursive: true }); + const bun = join(pathDir, process.platform === "win32" ? "bun.exe" : "bun"); + copyFileSync(process.execPath, bun); + chmodSync(bun, 0o755); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: root, USERPROFILE: root, + OPENCODEX_HOME: join(root, "opencodex"), CODEX_HOME: join(root, "codex"), + GROK_HOME: join(root, "grok"), XDG_CACHE_HOME: join(root, "cache"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0", PATH: pathDir, + }; + for (const key of Object.keys(env)) { + if (key.toLowerCase() === "path" && key !== "PATH") delete env[key]; + } + delete env.OPENCODEX_BUN_PATH; + delete env.OCX_PATH_DOTENV_LOADED; + const pkg = JSON.parse(readFileSync(repoPath("package.json"), "utf8")); + const selected = findPathBun({ env, pinnedVersion: pkg.dependencies.bun, deadlineMs: 2000 }); + expect(selected).not.toBeNull(); + const lateOverride = join(root, "dotenv-bun.exe"); + writeFileSync(lateOverride, Buffer.alloc(1_000_000)); + // Bun dotenv preserves backslashes in single quotes; JSON quoting doubles Windows separators. + writeFileSync(join(root, ".env"), `OPENCODEX_BUN_PATH='${lateOverride}'\nOCX_BUN_RUNTIME_SOURCE=override\nOCX_BUN_RUNTIME_PATH='${lateOverride}'\nOCX_PATH_DOTENV_LOADED=yes\n`); + const script = join(root, "consumer.ts"); + writeFileSync(script, `import {durableBunRuntime,reportedBunRuntimeSource} from ${JSON.stringify(repoPath("src/lib/bun-runtime.ts"))};\nconsole.log(JSON.stringify({runtime:durableBunRuntime(),reported:reportedBunRuntimeSource(),dotenv:process.env.OCX_PATH_DOTENV_LOADED,override:process.env.OPENCODEX_BUN_PATH}));`); + const result = spawnSync(selected!.path, [script], { + cwd: root, encoding: "utf8", timeout: 30_000, windowsHide: true, + env: { ...env, [BUN_RUNTIME_SOURCE_ENV]: "process", [BUN_RUNTIME_PATH_ENV]: selected!.path }, + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + runtime: { path: selected!.path, source: "process", overrideEnv: "OPENCODEX_BUN_PATH" }, + reported: "process", dotenv: "yes", override: lateOverride, + }); + }); + it("does not reselect a dotenv Bun override after the runtime has started", () => { const real = join(tmp, "override-bun.exe"); const stub = join(tmp, "override-stub.exe"); diff --git a/tests/cli/cli-help-recovery.test.ts b/tests/cli/cli-help-recovery.test.ts index afec23f46e7..63d0f9d758a 100644 --- a/tests/cli/cli-help-recovery.test.ts +++ b/tests/cli/cli-help-recovery.test.ts @@ -193,6 +193,12 @@ describe("CLI help recovery", () => { mock.module(${JSON.stringify(repoPath("src", "cli", "codex-shim-autorestore.ts"))}, () => ({ maybeAutoRestoreCodexShim: command => { calls.push(command); if (reportCalls) console.log("preflight called"); }, })); + // The lifecycle skew notice probes the configured (here: default) port. A developer machine + // with a live proxy there would answer, so keep this admission test independent of the host. + mock.module(${JSON.stringify(repoPath("src", "cli", "version-skew-notice.ts"))}, () => ({ + shouldNoticeVersionSkew: () => false, + maybeNoticeVersionSkew: async () => {}, + })); const { runCli } = await import(${JSON.stringify(repoPath("src", "cli", "root.ts"))});`; const rejected = isolated(`${mockSetup} reportCalls = true; await runCli(["modles"]);`); diff --git a/tests/cli/cli-version-skew.test.ts b/tests/cli/cli-version-skew.test.ts index f32511a9daf..a0e2e7a0032 100644 --- a/tests/cli/cli-version-skew.test.ts +++ b/tests/cli/cli-version-skew.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; import { computeVersionSkew, isConfirmedVersionMatch } from "../../src/cli/version-skew"; import { packageVersion } from "../../src/cli/help"; +import { shouldNoticeVersionSkew } from "../../src/cli/version-skew-notice"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repoPath } from "../helpers/repo-root"; /** * #2701: an older `ocx` earlier on PATH than the running proxy described a different @@ -129,3 +135,246 @@ describe("version skew detection", () => { expect(version).not.toBe("unknown"); }); }); + +type NoticeScenario = { + command?: string; args?: string[]; cliVersion?: string; proxyVersion?: string | null; + runtime?: boolean; configError?: boolean; configDefault?: boolean; + foreign?: boolean; wrongPid?: boolean; + outcome?: "reject" | "hang" | "late" | "expired-read" | "network-abort"; + repeat?: boolean; root?: boolean; exitCode?: number; + endpointMs?: number; versionMs?: number; quietFirst?: boolean; +}; +function freshNotice(scenario: NoticeScenario = {}) { + const root = mkdtempSync(join(tmpdir(), "ocx-notice-test-")); + try { + const ocxHome = join(root, "ocx"), codexHome = join(root, "codex"); + mkdirSync(ocxHome); mkdirSync(codexHome); + const script = ` + const scenario = ${JSON.stringify(scenario)}; + const notice = await import(${JSON.stringify(repoPath("src", "cli", "version-skew-notice.ts"))}); + const { proxyIdentityAt } = await import(${JSON.stringify(repoPath("src", "server", "proxy-liveness.ts"))}); + const warnings = [], calls = [], probes = [], events = []; + let clock = 0, aborted = false; + const started = performance.now(); + let acknowledgeAbort; + const abortObserved = new Promise(resolve => { acknowledgeAbort = resolve; }); + const io = { + now: () => scenario.outcome === "hang" || scenario.outcome === "network-abort" ? Date.now() : clock, + cliVersion: () => { clock += scenario.versionMs ?? 0; return scenario.cliVersion ?? "2.80.0"; }, + warn: line => { warnings.push(line); events.push("warn"); console.error(line); }, + readRuntime: () => { + calls.push("runtime"); + clock += scenario.endpointMs ?? 0; + if (scenario.outcome === "expired-read") clock = 201; + return scenario.runtime === false ? null : { pid:123, port:23456, hostname:"127.0.0.1" }; + }, + readConfig: () => { + calls.push("config"); + return { config: scenario.configDefault ? {} : {port:34567,hostname:"127.0.0.1"}, + source: scenario.configDefault ? "default" : "file", error:scenario.configError ? "invalid" : null }; + }, + probe: async (port, opts, budget) => { + probes.push({port,opts,budget:{attempts:budget.attempts,timeoutMs:budget.timeoutMs,deadlineAt:budget.deadlineAt}}); + if (scenario.outcome === "reject") throw new Error("transport"); + if (scenario.outcome === "hang") return new Promise(() => {}); + if (scenario.outcome === "late") clock = 201; + return proxyIdentityAt(port, opts, { ...budget, fetchFn: async (_url, init) => { + if (scenario.outcome === "network-abort") return new Promise((_resolve,reject) => { + const abort = () => { aborted = true; acknowledgeAbort(); reject(new Error("aborted")); }; + init.signal.addEventListener("abort",abort,{once:true}); + if (init.signal.aborted) abort(); + }); + return Response.json({service:scenario.foreign ? "foreign" : "opencodex",status:"ok", + pid:scenario.wrongPid ? 124 : 123, + ...(scenario.proxyVersion === null ? {} : {version:scenario.quietFirst && probes.length === 1 ? "2.80.0" : scenario.proxyVersion ?? "2.81.0"})}); + } }); + }, + }; + const command = scenario.command ?? "start", args = scenario.args ?? [command]; + if (scenario.root) { + const { mock } = await import("bun:test"); + const actual = notice.maybeNoticeVersionSkew; + mock.module(${JSON.stringify(repoPath("src", "cli", "version-skew-notice.ts"))}, () => ({ + shouldNoticeVersionSkew: notice.shouldNoticeVersionSkew, + maybeNoticeVersionSkew: (cmd,argv) => { events.push("hook"); return actual(cmd,argv,io); }, + })); + mock.module(${JSON.stringify(repoPath("src", "cli", "codex-shim-autorestore.ts"))}, () => ({ + maybeAutoRestoreCodexShim: () => events.push("shim"), + })); + const { runCli } = await import(${JSON.stringify(repoPath("src", "cli", "root.ts"))}); + await runCli(args); events.push("dispatch"); + } else { + await notice.maybeNoticeVersionSkew(command,args,io); + if (scenario.repeat || scenario.quietFirst) await notice.maybeNoticeVersionSkew("restart",["restart"],io); + } + if (scenario.outcome === "network-abort") await abortObserved; + console.log(JSON.stringify({warnings,calls,probes,events,aborted,elapsedMs:performance.now()-started})); + process.exitCode = scenario.exitCode ?? 0; + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: root, encoding: "utf8", timeout: 5000, windowsHide: true, + env: { ...process.env, HOME: root, USERPROFILE: root, OPENCODEX_HOME: ocxHome, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0", CODEX_HOME: codexHome, GROK_HOME: join(root,"grok"), OCX_OWNER_REGISTRY_DIR: join(root,"owners") }, + }); + const report = result.stdout.trim().startsWith("{") ? JSON.parse(result.stdout) as { + warnings: string[]; calls: string[]; + probes: Array<{port:number;opts:{expectedPid?:number};budget:{attempts:number;timeoutMs:number;deadlineAt:number}}>; + events: string[]; aborted: boolean; elapsedMs: number; + } : null; + return { status:result.status, stdout:result.stdout, stderr:result.stderr, report }; + } finally { rmSync(root,{recursive:true,force:true}); } +} + +describe("lifecycle version notice activation", () => { + test("lifecycle hook reports an older CLI once before dispatch", () => { + const result = freshNotice({root:true}); + expect(result.status).toBe(0); + expect(result.stderr).toBe("ocx 2.80.0 does not match the running proxy 2.81.0. Check which opencodex installation you meant to use (`ocx status` shows both).\n"); + expect(result.report?.events).toEqual(["hook","warn","shim","dispatch"]); + expect(result.report?.probes).toHaveLength(1); + const repeat = freshNotice({repeat:true}); + expect(repeat.report?.warnings).toHaveLength(1); + expect(repeat.report?.probes).toHaveLength(1); + }); + test("lifecycle hook reports a newer CLI without changing dispatch exit", () => { + const result = freshNotice({root:true,cliVersion:"2.82.0",exitCode:7}); + expect(result.status).toBe(7); + expect(result.stderr).toBe("ocx 2.82.0 does not match the running proxy 2.81.0. Check which opencodex installation you meant to use (`ocx status` shows both).\n"); + expect(result.report?.events.at(-1)).toBe("dispatch"); + }); + test.each(["start","stop","restart","update","service"])("lifecycle %s JSON and help perform no notice reads", command => { + for (const option of ["--json","--help","-h","help"]) { + const result = freshNotice({command,args:[command,option]}); + expect(result.status).toBe(0); expect(result.stderr).toBe(""); + expect(result.report?.calls).toEqual([]); expect(result.report?.probes).toEqual([]); + } + }); + test.each(["status","doctor","resolve","ready","internal","ensure","version","setup"])("non-lifecycle %s performs no notice reads", command => { + const result = freshNotice({command}); + expect(result.report?.calls).toEqual([]); expect(result.report?.probes).toEqual([]); + expect(result.stderr).toBe(""); + }); + test("runtime target wins over configured port for the notice", () => { + const result = freshNotice(); + expect(result.report?.calls).toEqual(["runtime"]); + expect(result.report?.probes[0]?.port).toBe(23456); + expect(result.report?.probes[0]?.opts.expectedPid).toBe(123); + expect(result.report?.probes[0]?.budget).toEqual({attempts:1,timeoutMs:200,deadlineAt:200}); + }); + test("missing runtime record selects valid configured endpoint once", () => { + const result = freshNotice({runtime:false}); + expect(result.report?.calls).toEqual(["runtime","config"]); + expect(result.report?.probes).toHaveLength(1); + expect(result.report?.probes[0]?.port).toBe(34567); + expect(result.report?.probes[0]?.opts.expectedPid).toBeUndefined(); + expect(result.report?.warnings).toHaveLength(1); + }); + test("missing config uses default port and malformed config stays quiet", () => { + expect(freshNotice({runtime:false,configDefault:true}).report?.probes[0]?.port).toBe(10100); + const invalid = freshNotice({runtime:false,configError:true}); + expect(invalid.report?.probes).toEqual([]); expect(invalid.stderr).toBe(""); + }); + test("stale runtime record does not probe a second endpoint", () => { + const result = freshNotice({outcome:"reject"}); + expect(result.status).toBe(0); expect(result.stderr).toBe(""); + expect(result.report?.calls).toEqual(["runtime"]); expect(result.report?.probes).toHaveLength(1); + }); + test.each([{foreign:true},{wrongPid:true}])("foreign or mismatched health identity never emits skew %j", scenario => { + const result = freshNotice(scenario); + expect(result.stderr).toBe(""); expect(result.report?.probes).toHaveLength(1); + }); + test.each(["2.81.0\u001b[31m",null,"unknown","0.0.0","2.80.0"])("unsafe absent placeholder or matching version %j stays quiet", proxyVersion => { + const result = freshNotice({proxyVersion}); expect(result.stderr).toBe(""); + expect(result.report?.warnings).toEqual([]); expect(result.report?.probes).toHaveLength(1); + }); + test("expired endpoint reads never start a probe", () => { + const result = freshNotice({outcome:"expired-read"}); + expect(result.report?.probes).toEqual([]); expect(result.stderr).toBe(""); + }); + test.each(["reject","hang"] as const)("notice %s preserves the command", outcome => { + const result = freshNotice({outcome}); + expect(result.status).toBe(0); expect(result.stderr).toBe(""); + expect(result.report?.probes).toHaveLength(1); + if (outcome === "hang") { + expect(result.report!.elapsedMs).toBeGreaterThanOrEqual(150); + expect(result.report!.elapsedMs).toBeLessThan(1500); + } + }); + test("late identity never prints after the diagnostic deadline", () => { + expect(freshNotice({outcome:"late"}).stderr).toBe(""); + }); + test("production identity request receives cancellation at the notice deadline", () => { + const result = freshNotice({outcome:"network-abort"}); + expect(result.status).toBe(0); expect(result.stderr).toBe(""); + expect(result.report?.aborted).toBe(true); expect(result.report?.probes).toHaveLength(1); + }); + test.each([ + [["stop","--expect-pid","broken"],64], [["unknown-root"],1], + [["--help"],0], [["ready","--timeout","bad"],64], [["resolve","--invalid"],64], + ] as const)("root early-exit %j retains exit %s before notice", (args,status) => { + const result = freshNotice({root:true,args:[...args]}); + expect(result.status).toBe(status); expect(result.report).toBeNull(); + expect(result.stderr).not.toContain("does not match the running proxy"); + }); +}); + +describe("lifecycle version notice gate", () => { + test.each(["start", "stop", "restart", "update", "service"])("activates for %s", command => { + expect(shouldNoticeVersionSkew(command, [command])).toBe(true); + }); + test.each(["status", "doctor", "resolve", "ready", "internal", "ensure", "version", "setup"])("skips %s", command => { + expect(shouldNoticeVersionSkew(command, [command])).toBe(false); + }); + test.each(["--json", "--help", "-h", "help"])("skips option %s", option => { + expect(shouldNoticeVersionSkew("service", ["service", option])).toBe(false); + }); + test("operand options after double dash do not change global diagnostic mode", () => { + expect(shouldNoticeVersionSkew("start", ["start", "--", "--json"])).toBe(true); + const result = freshNotice({ args: ["start", "--", "--json"] }); + expect(result.status).toBe(0); + expect(result.report?.warnings).toHaveLength(1); + }); + test.each(["unknown", "0.0.0"])("unknown CLI %s stays quiet", cliVersion => { + const result = freshNotice({ cliVersion }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.report?.warnings).toEqual([]); + }); +}); + +describe("version notice total deadline and quiet-call recovery", () => { + test("endpoint and version reads share the 200 ms probe budget", () => { + const result = freshNotice({ endpointMs: 75, versionMs: 25 }); + expect(result.status).toBe(0); + expect(result.report?.probes).toHaveLength(1); + expect(result.report?.probes[0]?.budget).toEqual({ attempts: 1, timeoutMs: 100, deadlineAt: 200 }); + expect(result.report?.warnings).toHaveLength(1); + }); + test("an expired version lookup never starts a probe", () => { + const result = freshNotice({ versionMs: 200 }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.report?.probes).toEqual([]); + }); + test("a matching quiet call permits a later mismatch notice", () => { + const result = freshNotice({ quietFirst: true }); + expect(result.status).toBe(0); + expect(result.report?.probes).toHaveLength(2); + expect(result.report?.warnings).toHaveLength(1); + expect(result.stderr).toBe("ocx 2.80.0 does not match the running proxy 2.81.0. Check which opencodex installation you meant to use (`ocx status` shows both).\n"); + }); + test.each(["reject", "hang"] as const)("root dispatch retains exit 7 on notice %s", outcome => { + const result = freshNotice({ root: true, outcome, exitCode: 7 }); + expect(result.status).toBe(7); + expect(result.stderr).toBe(""); + expect(result.report?.events).toEqual(["hook", "shim", "dispatch"]); + }); + test.each(["start", "stop", "restart", "update", "service"])("root %s JSON skips notice reads", command => { + const result = freshNotice({ root: true, command, args: [command, "--json"] }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.report?.calls).toEqual([]); + expect(result.report?.probes).toEqual([]); + expect(result.report?.events).toEqual(["hook", "shim", "dispatch"]); + }); +}); diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index b6edb5b4126..858744fef91 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { bundledBunPath } from "../../src/lib/bun-runtime"; +import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; +import { REAL_BUN_MIN_BYTES } from "../../src/lib/bun-binary-validator.mjs"; import { killProxy } from "../../src/lib/process-control"; import { repoPath } from "../helpers/repo-root"; @@ -307,6 +309,8 @@ function isolatedLauncherEnv(root: string, override: string): NodeJS.ProcessEnv OPENCODEX_HOME: opencodexHome, CODEX_HOME: codexHome, GROK_HOME: grokHome, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0", + XDG_CACHE_HOME: join(root, "cache"), OPENCODEX_BUN_PATH: override, }; } @@ -383,3 +387,316 @@ describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { } }, EFFECTIVE_RUNTIME_TEST_TIMEOUT_MS); }); + +function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n") { + const calls: Array<{ path: string; args: string[]; timeout: number; shell: boolean; maxBuffer: number }> = []; + let clock = 0; + const io: PathBunIo = { + now: () => clock, + realpath: path => path, + stat: () => ({ isFile: () => true, mode: 0o755 }), + access: () => {}, + isRealBunBinary: () => true, + spawnSync: (path, args, options) => { + calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + return { status: 0, stdout }; + }, + }; + return { calls, io, advance: (ms: number) => { clock += ms; }, + find: (env: NodeJS.ProcessEnv, pinnedVersion = "1.4.2", deadlineMs = 750) => + findPathBun({ env, platform, pinnedVersion, deadlineMs, io }) }; +} + +describe("validated PATH Bun policy", () => { + test.each(["1.4.0", "1.4.2", "1.5.0", "1.99.999", "1.4.0+build.1"])("accepts stable %s on the pinned major", version => { + const fixture = pathProbeFixture("linux", version + "\n"); + expect(fixture.find({ PATH: "/trusted/bin" })).toEqual({ path: "/trusted/bin/bun", version }); + expect(fixture.calls).toEqual([ + { path: "/trusted/bin/bun", args: ["--version"], timeout: 750, shell: false, maxBuffer: 4096 }, + { path: "/trusted/bin/bun", args: ["-e", "process.stdout.write(String(typeof Bun==='object'&&Bun.version))"], timeout: 750, shell: false, maxBuffer: 4096 }, + ]); + }); + test.each(["1.3.99", "2.0.0", "0.99.0", "1.4.0-canary", "v1.4.0", "1.4", "01.4.0", "1.4.0\nnoise", "1.4.0\u001b[31m"])("rejects %s", version => { + expect(pathProbeFixture("linux", version).find({ PATH: "/trusted/bin" })).toBeNull(); + }); + test("skips empty relative and tilde PATH entries before filesystem access", () => { + const fixture = pathProbeFixture(); + expect(fixture.find({ PATH: ":.:relative:~/bin:/trusted/bin" })?.path).toBe("/trusted/bin/bun"); + expect(fixture.calls.map(call => call.path)).toEqual(["/trusted/bin/bun", "/trusted/bin/bun"]); + }); + test("Windows uses case-insensitive Path and only a rooted bun.exe", () => { + const fixture = pathProbeFixture("win32"); + expect(fixture.find({ Path: ";C:relative;\\relative;C:\\trusted\\bin", PATHEXT: ".CMD;.BAT;.EXE" })?.path).toBe("C:\\trusted\\bin\\bun.exe"); + expect(fixture.calls.map(call => call.path)).toEqual(["C:\\trusted\\bin\\bun.exe", "C:\\trusted\\bin\\bun.exe"]); + }); + test.each(["directory", "unexecutable", "stub", "realpath-error", "stat-error"])("does not spawn a %s candidate", kind => { + const fixture = pathProbeFixture(); + if (kind === "directory") fixture.io.stat = () => ({ isFile: () => false, mode: 0o755 }); + if (kind === "unexecutable") fixture.io.access = () => { throw new Error("EACCES"); }; + if (kind === "stub") fixture.io.isRealBunBinary = () => false; + if (kind === "realpath-error") fixture.io.realpath = () => { throw new Error("ENOENT"); }; + if (kind === "stat-error") fixture.io.stat = () => { throw new Error("EIO"); }; + expect(fixture.find({ PATH: "/bad" })).toBeNull(); + expect(fixture.calls).toEqual([]); + }); + test.each(["exit", "signal", "error", "overflow"])("rejects a %s version probe", kind => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = () => kind === "exit" ? { status: 1, stdout: "1.4.0" } + : kind === "signal" ? { status: null, signal: "SIGKILL", stdout: "1.4.0" } + : { status: null, error: new Error(kind) }; + expect(fixture.find({ PATH: "/bad" })).toBeNull(); + }); + test("shares one deadline across candidates and stops after timeout", () => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = (path, args, options) => { + fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + fixture.advance(750); + return { status: null, error: new Error("ETIMEDOUT") }; + }; + expect(fixture.find({ PATH: "/first:/second" })).toBeNull(); + expect(fixture.calls).toHaveLength(1); + }); + test("canonical aliases are probed only once before the next candidate", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path.includes("alias") ? "/first/bun" : path; + fixture.io.spawnSync = (path, args, options) => { + fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + fixture.advance(100); + return { status: 0, stdout: path === "/last/bun" ? "1.4.0" : "1.3.0" }; + }; + expect(fixture.find({ PATH: "/first:/alias:/last" })?.path).toBe("/last/bun"); + expect(fixture.calls.map(call => call.timeout)).toEqual([750, 650, 550]); + }); + test.each(["", "^1.4.2", "1.4.2-canary", "unknown"])("invalid package pin %s does not probe PATH", pin => { + const fixture = pathProbeFixture(); + expect(fixture.find({ PATH: "/trusted/bin" }, pin)).toBeNull(); + expect(fixture.calls).toEqual([]); + }); + test("threshold changes derive from the package pin", () => { + expect(pathProbeFixture("linux", "1.4.0").find({ PATH: "/bin" }, "1.5.2")).toBeNull(); + expect(pathProbeFixture("linux", "2.0.0").find({ PATH: "/bin" }, "2.0.9")?.version).toBe("2.0.0"); + }); +}); + +describe("failure-only Desktop CLI pointer", () => { + test.each(["linux", "win32"] as const)("%s performs no app filesystem lookup", platform => { + expect(findDesktopCli({ platform, io: { stat: () => { throw new Error("must not read"); } } })).toBeNull(); + }); + test("macOS checks executable system app before executable user app", () => { + const visited: string[] = []; + const result = findDesktopCli({ platform: "darwin", home: "/user", io: { + stat: path => { visited.push(path); return { isFile: () => true }; }, + access: path => { if (path.startsWith("/Applications/")) throw new Error("EACCES"); }, + } }); + expect(result).toBe("/user/Applications/OpenCodex.app/Contents/MacOS/ocx"); + expect(visited).toEqual(["/Applications/OpenCodex.app/Contents/MacOS/ocx", result!]); + }); + test("macOS omits a missing or directory app CLI", () => { + expect(findDesktopCli({ platform: "darwin", io: { stat: () => { throw new Error("ENOENT"); } } })).toBeNull(); + expect(findDesktopCli({ platform: "darwin", io: { stat: () => ({ isFile: () => false }) } })).toBeNull(); + }); +}); + +describe("real PATH Bun validation", () => { + test.each(["file-group", "file-world", "parent-group", "parent-world"])("rejects POSIX %s write access before spawning", kind => { + const fixture = pathProbeFixture(); + fixture.io.stat = path => ({ + isFile: () => true, + mode: 0o755 | ((kind.startsWith("file") === path.endsWith("/bun")) + ? (kind.endsWith("group") ? 0o020 : 0o002) : 0), + }); + expect(fixture.find({ PATH: "/unsafe" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + + test("Windows does not interpret POSIX write mode bits", () => { + const fixture = pathProbeFixture("win32"); + fixture.io.stat = () => ({ isFile: () => true, mode: 0o777 }); + expect(fixture.find({ Path: "C:\\trusted" })?.version).toBe("1.4.0"); + }); + + test.each(["false", "1.4.1", "1.4.0\nnoise", ""])("rejects a mismatched identity response %j", identity => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = (_path, args) => ({ status: 0, stdout: args[0] === "--version" ? "1.4.0" : identity }); + expect(fixture.find({ PATH: "/candidate" })).toBeNull(); + }); + + test.each(["exit", "signal", "error", "overflow", "throw"])("rejects a failed %s identity probe", kind => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = (_path, args) => { + if (args[0] === "--version") return { status: 0, stdout: "1.4.0" }; + if (kind === "throw") throw new Error("spawn failure"); + if (kind === "exit") return { status: 1, stdout: "1.4.0" }; + if (kind === "signal") return { status: null, signal: "SIGKILL", stdout: "1.4.0" }; + if (kind === "overflow") return { status: 0, stdout: "1.4.0" + " ".repeat(4096) }; + return { status: null, error: new Error("probe failure") }; + }; + expect(fixture.find({ PATH: "/candidate" })).toBeNull(); + }); + + test("both probes and later candidates consume one shared deadline", () => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = (path, args, options) => { + fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + fixture.advance(200); + return { status: 0, stdout: args[0] === "--version" || path === "/last/bun" ? "1.4.0" : "false" }; + }; + expect(fixture.find({ PATH: "/first:/last" })).toBeNull(); + expect(fixture.calls.map(call => call.timeout)).toEqual([750, 550, 350, 150]); + }); + + test("identity receives the remaining budget and succeeds before the deadline", () => { + const fixture = pathProbeFixture(); + fixture.io.spawnSync = (path, args, options) => { + fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + fixture.advance(100); + return { status: 0, stdout: "1.4.0" }; + }; + expect(fixture.find({ PATH: "/trusted" })?.version).toBe("1.4.0"); + expect(fixture.calls.map(call => call.timeout)).toEqual([750, 650]); + }); + + test("skips non-absolute PATH entries before even resolving them", () => { + const fixture = pathProbeFixture(); + const visited: string[] = []; + fixture.io.realpath = path => { visited.push(path); return path; }; + expect(fixture.find({ PATH: ":.:relative:~/bin:/trusted" })?.path).toBe("/trusted/bun"); + expect(visited).toEqual(["/trusted/bun"]); + }); + + test("Windows accepts UNC bun.exe and deduplicates case aliases", () => { + const fixture = pathProbeFixture("win32"); + fixture.io.spawnSync = (path, args, options) => { + fixture.calls.push({ path, args, timeout: options.timeout, shell: options.shell, maxBuffer: options.maxBuffer }); + return { status: 0, stdout: path.startsWith("\\\\") ? "1.4.0" : "1.3.0" }; + }; + expect(fixture.find({ Path: "C:\\first;c:\\FIRST;\\\\host\\share" })?.path).toBe("\\\\host\\share\\bun.exe"); + expect(fixture.calls.map(call => call.path)).toEqual(["C:\\first\\bun.exe", "\\\\host\\share\\bun.exe", "\\\\host\\share\\bun.exe"]); + }); + + test.skipIf(process.platform === "win32")("real padded version-only script fails the identity check", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-identity-")); + try { + const path = join(root, "bun"); + writeFileSync(path, '#!/bin/sh\nif [ "$1" = "--version" ]; then printf "1.4.0\\n"; else exit 1; fi\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + chmodSync(path, 0o755); + expect(findPathBun({ env: { PATH: root }, pinnedVersion: "1.4.2", deadlineMs: 2000 })).toBeNull(); + } finally { removeTree(root); } + }); + + test.skipIf(process.platform === "win32")("real writable file and resolved parent are rejected", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-mode-")); + try { + const path = join(root, "bun"); + writeFileSync(path, '#!/bin/sh\nprintf "1.4.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + chmodSync(path, 0o777); + const find = () => findPathBun({ env: { PATH: root }, pinnedVersion: "1.4.2", deadlineMs: 2000 }); + expect(find()).toBeNull(); + chmodSync(path, 0o755); + chmodSync(root, 0o777); + expect(find()).toBeNull(); + } finally { removeTree(root); } + }); + + test("real PATH validation rejects the small stub and accepts the complete executable", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-bun-")); + try { + const first = join(root, "first"), last = join(root, "last"); + mkdirSync(first); mkdirSync(last); + const name = process.platform === "win32" ? "bun.exe" : "bun"; + writeFileSync(join(first, name), "not bun"); + if (process.platform === "win32") copyFileSync(process.execPath, join(last, name)); + else writeFileSync(join(last, name), '#!/bin/sh\ncase "$1" in --version|-e) printf "1.4.0\\n";; *) exit 1;; esac\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + chmodSync(join(first, name), 0o755); chmodSync(join(last, name), 0o755); + const selected = findPathBun({ env: { ...process.env, PATH: [first, last].join(process.platform === "win32" ? ";" : ":") }, pinnedVersion: "1.4.2", deadlineMs: 2000 }); + expect(selected?.path).toBe(realpathSync.native(join(last, name))); + expect(selected?.version).toMatch(/^1\.(?:[4-9]|[1-9]\d+)\./); + } finally { removeTree(root); } + }); +}); + +function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broken" | "override" | "none" | "inspection" | "rejected") { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-path-launcher-"))); + try { + const node = spawnSync("node", ["-p", "process.execPath"], { encoding: "utf8", windowsHide: true }).stdout.trim(); + const version = spawnSync(process.execPath, ["--version"], { encoding: "utf8", windowsHide: true }).stdout.trim(); + expect(version).toMatch(/^1\.(?:[4-9]|[1-9]\d+)\./); + cpSync(repoPath("src"), join(root, "src"), { recursive: true }); + mkdirSync(join(root, "bin")); + copyFileSync(BIN_OCX, join(root, "bin", "ocx.mjs")); + writeFileSync(join(root, "package.json"), JSON.stringify({ version: "2.81.0", dependencies: { bun: "1.4.2" } })); + writeFileSync(join(root, "src", "cli", "index.ts"), 'console.log(JSON.stringify({source:process.env.OCX_BUN_RUNTIME_SOURCE,path:process.env.OCX_BUN_RUNTIME_PATH}));'); + const pathDir = join(root, "path"); mkdirSync(pathDir); + const pathBun = join(pathDir, process.platform === "win32" ? "bun.exe" : "bun"); + if (mode === "rejected") { + writeFileSync(pathBun, '#!/bin/sh\nprintf "2.0.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + chmodSync(pathBun, 0o755); + } else if (mode !== "none") { copyFileSync(process.execPath, pathBun); chmodSync(pathBun, 0o755); } + const bundleDir = join(root, "node_modules", "bun"); + if (["bundled", "repair", "broken", "inspection"].includes(mode)) { + mkdirSync(join(bundleDir, "bin"), { recursive: true }); + writeFileSync(join(bundleDir, "package.json"), '{"name":"bun","version":"1.4.2"}'); + const bundle = join(bundleDir, "bin", "bun.exe"); + if (mode === "bundled") { copyFileSync(process.execPath, bundle); chmodSync(bundle, 0o755); } + else writeFileSync(bundle, "stub"); + const installer = mode === "repair" + ? `const fs=require("node:fs");fs.copyFileSync(${JSON.stringify(process.execPath)},${JSON.stringify(bundle)});fs.chmodSync(${JSON.stringify(bundle)},0o755);` + : 'process.exit(1);'; + writeFileSync(join(bundleDir, "install.js"), `require("node:fs").writeFileSync(${JSON.stringify(join(root, "installer-ran"))},"yes");` + installer); + } + const env = isolatedLauncherEnv(root, mode === "override" ? pathBun : ""); + env.PATH = pathDir; + for (const key of Object.keys(env)) if (key.toLowerCase() === "path" && key !== "PATH") delete env[key]; + const args = mode === "inspection" ? ["system", "codex-cli-update", "inspect", "--json"] : ["--version"]; + const result = spawnSync(node, [join(root, "bin", "ocx.mjs"), ...args], { cwd: root, env, encoding: "utf8", timeout: 30_000, windowsHide: true }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr, + installerRan: existsSync(join(root, "installer-ran")), + version, pathBun, canonicalPathBun: mode === "none" ? null : realpathSync.native(pathBun), + bundle: join(bundleDir, "bin", "bun.exe") }; + } finally { removeTree(root); } +} + +describe.skipIf(!nodeAvailable)("Node launcher PATH fallback activation", () => { + test.each(["missing", "broken", "inspection"] as const)("%s bundle reaches validated PATH Bun", mode => { + const result = launcherFallbackFixture(mode); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ source: "process", path: result.canonicalPathBun }); + expect(result.stderr).toBe(`opencodex: using PATH Bun ${result.version}.\n`); + expect(result.installerRan).toBe(mode === "broken"); + }); + test.each(["bundled", "repair", "override"] as const)("%s runtime wins over PATH", mode => { + const result = launcherFallbackFixture(mode); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ source: mode === "override" ? "override" : "bundled", path: mode === "override" ? result.pathBun : result.bundle }); + expect(result.stderr).not.toContain("using PATH Bun"); + expect(result.installerRan).toBe(mode === "repair"); + }); + test("terminal missing-bundle error is reached only when PATH has no usable Bun", () => { + const result = launcherFallbackFixture("none"); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("the `bun` dependency is not installed"); + expect(result.stderr).not.toContain("using PATH Bun"); + }); +}); + +describe.skipIf(!nodeAvailable)("PATH fallback negative activation", () => { + test.skipIf(process.platform === "win32")("rejected PATH versions never expose candidate paths", () => { + const result = launcherFallbackFixture("rejected"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("the `bun` dependency is not installed"); + expect(result.stderr).not.toContain(result.pathBun); + expect(result.stderr).not.toContain("using PATH Bun"); + }); + test.skipIf(process.platform === "win32")("real version probe returns null within the deadline", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-path-timeout-")); + try { + const path = join(root, "bun"); + writeFileSync(path, '#!/bin/sh\nexec /bin/sleep 5\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); + chmodSync(path, 0o755); + const started = performance.now(); + expect(findPathBun({ env: { PATH: root }, pinnedVersion: "1.4.2", deadlineMs: 50 })).toBeNull(); + expect(performance.now() - started).toBeLessThan(1500); + } finally { removeTree(root); } + }); +}); diff --git a/tests/cli/ocx-launcher-source.test.ts b/tests/cli/ocx-launcher-source.test.ts index f15293f48ae..2de34bf1a4f 100644 --- a/tests/cli/ocx-launcher-source.test.ts +++ b/tests/cli/ocx-launcher-source.test.ts @@ -15,6 +15,42 @@ const validatorSource = readFileSync( ); describe("ocx.mjs package launcher (source invariants)", () => { + test("PATH fallback follows bundled installer recovery and returns process provenance", () => { + const start = source.indexOf("function resolveBun("); + const end = source.indexOf("// `ocx update --help`", start); + const resolver = source.slice(start, end); + expect(resolver.indexOf("bunDir = bunBinDir()")).toBeGreaterThan(resolver.indexOf('source: "override"')); + expect(resolver.indexOf("findPathBun(")).toBeGreaterThan(resolver.indexOf("spawnSync(process.execPath, [installJs]")); + expect(resolver.indexOf("fail(bunDir ?")).toBeGreaterThan(resolver.indexOf("findPathBun(")); + expect(resolver).toContain('source: "process"'); + expect(resolver).not.toContain('catch {\n fail("the `bun` dependency is not installed.");'); + }); + + test("fallback derives its compatibility floor from the package dependency", () => { + expect(source).toContain('typeof pkg.dependencies?.bun === "string" ? pkg.dependencies.bun : ""'); + expect(source).toContain("pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS"); + // Bounded, but sized for a cold first run of a scanned bun.exe on Windows. + expect(source).toContain("const PATH_BUN_PROBE_BUDGET_MS = 5_000;"); + expect(source).not.toContain('pinnedVersion: "1.4.2"'); + }); + + test("terminal failure names Desktop only as a pointer; resolver and failure do not import supervision", () => { + const failStart = source.indexOf("function fail("); + const resolveStart = source.indexOf("function resolveBun(", failStart); + const resolveEnd = source.indexOf("// `ocx update --help`", resolveStart); + expect(failStart).toBeGreaterThanOrEqual(0); + expect(resolveStart).toBeGreaterThan(failStart); + expect(resolveEnd).toBeGreaterThan(resolveStart); + const failure = source.slice(failStart, resolveStart); + const resolver = source.slice(resolveStart, resolveEnd); + expect(failure).toContain("const desktopCli = findDesktopCli();"); + expect(failure).toContain("An installed Desktop CLI is available:"); + expect(failure).not.toMatch(/spawn(?:Sync)?\(/); + for (const body of [failure, resolver]) expect(body).not.toContain("desktop-supervision.mjs"); + const pathRuntime = readFileSync(repoPath("src", "lib", "bun-path-runtime.mjs"), "utf8"); + expect(pathRuntime).not.toContain("desktop-supervision.mjs"); + }); + test("the Bun child receives the runtime provenance the launcher actually selected (#848)", () => { // The launcher is a plain-Node bin script executing at import time, so this is // asserted at the source level: the marker must reach the spawn env, and it must diff --git a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts index 63364745571..dd16c762cc2 100644 --- a/tests/codex-integration/codex-cli-update-launcher-policy.test.ts +++ b/tests/codex-integration/codex-cli-update-launcher-policy.test.ts @@ -29,6 +29,9 @@ describe("Codex CLI updater launcher policy", () => { expect(guard).toContain("!codexCliUpdateInspection"); expect(guard).toContain("isNodeModulesInstall()"); expect(source).toContain("resolveBun({ allowInstall: !codexCliUpdateInspection })"); - expect(source).toContain("if (allowInstall && existsSync(installJs))"); + // A missing bundle makes installJs nullable; allowInstall still gates its execution. + expect(source).toContain( + 'if (allowInstall && installJs && existsSync(installJs)) {\n const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });', + ); }); });