From 1aa8c52149b1a10e5f8a71382999a0d6bc2b9a5f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 2 Aug 2026 19:26:47 +0900 Subject: [PATCH 1/5] fix(windows): let post-create scheduler verification settle before rollback An elevated schtasks /create can finish before the non-elevated view catches up. finalize verified exactly once, so a task that was merely not visible yet read as 'not installed' and got rolled back milliseconds before Task Scheduler would have listed it. Verification now re-checks on a bounded 1.1s backoff, but only while the failure looks like a lagging view: the task is not visible, or it is visible without a fully published registration. Every other verdict keeps its meaning and spends no delay at all. Proven WinSW presence is rejected independently of conflict, because conflict only becomes true once the task itself is visible -- while it is still invisible the pair is conflict:false with nativeServiceAbsent:false, and retrying that would wait for a service that is already proven present. Rollback deletes a real task, so it now takes the same ownership fence the state write already had; a stale attempt can no longer delete a task a newer attempt owns. Eight regressions cover the settle path and each fail-closed class; four of them fail without this change. --- src/service.ts | 68 ++++++- tests/windows-elevation-spawn.test.ts | 245 ++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 1 deletion(-) diff --git a/src/service.ts b/src/service.ts index 8c353298b..246843115 100644 --- a/src/service.ts +++ b/src/service.ts @@ -909,6 +909,8 @@ type FinalizeHooks = { /** Defense-in-depth: late reconciliation must still own this attempt. */ stillOwnsAttempt?: (attemptId: string) => boolean; requestTimeoutMs?: number; + /** Test-only seam for the post-create settle backoff; real installs use a timer. */ + settleDelay?: (ms: number) => Promise; }; let finalizeHooks: FinalizeHooks | null = null; @@ -973,6 +975,65 @@ function attemptStillOwned(options: ApplyElevatedOptions): boolean { return !check || check(options.attemptId); } +/** + * Bounded post-create backoff, 1.1s total. Task Scheduler's non-elevated view can + * lag an elevated `/create` by a few hundred milliseconds, so a single verification + * would roll back a task that is merely not visible yet. + */ +const SCHEDULER_SETTLE_DELAYS_MS = [50, 150, 300, 600] as const; + +/** + * Whether a failed verification is still worth re-checking after a short delay. + * + * Retrying is confined to states that a lagging scheduler view actually produces: + * the task is not visible yet, or it is visible but its registration has not been + * published in full. Everything else keeps its existing fail-closed meaning and is + * rejected here so no delay can turn it into a pass: + * + * - a proven conflict (both backends present) is a real dual-backend install; + * - missing assets are missing on disk, which no amount of waiting creates; + * - a WinSW service that is proven present (`started`/`stopped`) is never absent + * later. This is checked independently of `conflict`, which only becomes true + * once the task itself is visible — while the task is still invisible the pair + * is `conflict: false` with `nativeServiceAbsent: false`, and that must not retry; + * - unknown SCM status is unproven rather than transient, and has its own + * task-preserving branch below. + */ +function schedulerVerificationMaySettle(v: WindowsSchedulerInstallVerification): boolean { + if (v.ok) return false; + if (v.conflict) return false; + if (!v.assetsHealthy) return false; + if (!v.nativeServiceAbsent) return false; + return !v.taskInstalled || !v.registrationHealthy; +} + +function settleDelay(ms: number): Promise { + const hook = finalizeHooks?.settleDelay; + if (hook) return hook(ms); + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Verify the elevated install, re-checking only while the failure looks like a + * scheduler view that has not caught up yet. Returns `null` when this attempt lost + * ownership mid-settle: a newer attempt owns the task, so this one must neither + * write install state nor roll anything back. + */ +async function verifyWindowsSchedulerInstallAfterSettle( + options: ApplyElevatedOptions, +): Promise { + const verify = finalizeHooks?.verify ?? verifyWindowsSchedulerInstall; + let verification = verify(); + for (const delayMs of SCHEDULER_SETTLE_DELAYS_MS) { + if (!schedulerVerificationMaySettle(verification)) break; + if (!attemptStillOwned(options)) return null; + await settleDelay(delayMs); + if (!attemptStillOwned(options)) return null; + verification = verify(); + } + return verification; +} + async function applyElevatedSchedulerResult( result: ElevatedSchtasksCreateAndRunResult, options: ApplyElevatedOptions, @@ -1002,7 +1063,9 @@ async function applyElevatedSchedulerResult( await reconcileUnknownElevatedOutcome(result.exitCode); } - const verification = (finalizeHooks?.verify ?? verifyWindowsSchedulerInstall)(); + const verification = await verifyWindowsSchedulerInstallAfterSettle(options); + // Ownership moved to a newer attempt while settling; that attempt owns the outcome. + if (!verification) return; if (!verification.ok) { // Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status). // Unknown is not a confirmed dual-backend conflict; install state is still withheld. @@ -1019,6 +1082,9 @@ async function applyElevatedSchedulerResult( "Installation state was not written.", ]); } + // Rollback deletes a real task, so it needs the same ownership fence as the + // state write below: a stale attempt must never delete a newer attempt's task. + if (!attemptStillOwned(options)) return; const rollbackError = await rollbackElevatedSchedulerTask(); const parts = [ "Elevated Task Scheduler registration did not produce a conflict-free install.", diff --git a/tests/windows-elevation-spawn.test.ts b/tests/windows-elevation-spawn.test.ts index 411a3e151..048909a64 100644 --- a/tests/windows-elevation-spawn.test.ts +++ b/tests/windows-elevation-spawn.test.ts @@ -24,6 +24,7 @@ import { finalizeWindowsSchedulerServiceRegistration, setFinalizeWindowsSchedulerHooksForTests, } from "../src/service"; +import type { WindowsSchedulerInstallVerification } from "../src/service"; /** Linux CI fakes win32 without a real System32; keep elevation paths production-shaped. */ const FAKE_TRUSTED_ELEVATION_EXES = { @@ -740,6 +741,250 @@ describe("finalizeWindowsSchedulerServiceRegistration", () => { expect(parentRollbackLaunches).toBe(0); }); + // --- Post-create settle (#868) ------------------------------------------------- + // + // Task Scheduler's non-elevated view can lag an elevated /create, so a one-shot + // verification rolls back a task that is merely not visible yet. These cases pin + // both halves: the lagging view must settle, and every fail-closed state must + // still fail closed without spending a single delay. + + function absentVerify(): WindowsSchedulerInstallVerification { + return { + taskInstalled: false, + registrationHealthy: false, + assetsHealthy: true, + nativeServiceAbsent: true, + nativeStatusUnknown: false, + conflict: false, + ok: false, + detail: "Task Scheduler task is not installed.", + }; + } + + function unhealthyVerify(): WindowsSchedulerInstallVerification { + return { + taskInstalled: true, + registrationHealthy: false, + assetsHealthy: true, + nativeServiceAbsent: true, + nativeStatusUnknown: false, + conflict: false, + ok: false, + detail: "Task Scheduler registration is present but unhealthy.", + }; + } + + function succeedingElevation() { + return async () => { + elevateLaunches += 1; + return { outcome: "success" as const, exitCode: OCX_ELEVATED_SUCCESS, stdout: "", stderr: "" }; + }; + } + + test("a lagging scheduler view settles into a healthy install instead of rolling back", async () => { + mockParentRollbackSpawn(); + const delays: number[] = []; + const sequence = [absentVerify(), unhealthyVerify(), okVerify()]; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => sequence[probes++] ?? okVerify(), + settleDelay: async ms => { delays.push(ms); }, + writeInstallState: () => { writeCount += 1; }, + }); + + const result = await finalizeWindowsSchedulerServiceRegistration(); + expect(result).toEqual({ kind: "done" }); + expect(probes).toBe(3); + expect(delays).toEqual([50, 150]); + expect(writeCount).toBe(1); + expect(parentRollbackLaunches).toBe(0); + }); + + test("a persistently unhealthy registration exhausts the bounded budget and then rolls back", async () => { + mockParentRollbackSpawn(); + const delays: number[] = []; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { probes += 1; return unhealthyVerify(); }, + settleDelay: async ms => { delays.push(ms); }, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).rejects.toThrow(/present but unhealthy/); + expect(probes).toBe(5); + expect(delays).toEqual([50, 150, 300, 600]); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(1); + }); + + test("a proven conflict is never retried into success", async () => { + mockParentRollbackSpawn(); + const delays: number[] = []; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { + probes += 1; + return { + taskInstalled: true, + registrationHealthy: true, + assetsHealthy: true, + nativeServiceAbsent: false, + nativeStatusUnknown: false, + conflict: true, + ok: false, + detail: "CONFLICT: Task Scheduler and native WinSW are both present.", + }; + }, + settleDelay: async ms => { delays.push(ms); }, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).rejects.toThrow(/CONFLICT/); + expect(probes).toBe(1); + expect(delays).toEqual([]); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(1); + }); + + test("missing assets fail immediately — waiting does not create files", async () => { + mockParentRollbackSpawn(); + const delays: number[] = []; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { + probes += 1; + return { + taskInstalled: true, + registrationHealthy: true, + assetsHealthy: false, + nativeServiceAbsent: true, + nativeStatusUnknown: false, + conflict: false, + ok: false, + detail: "Required scheduler service assets are missing.", + }; + }, + settleDelay: async ms => { delays.push(ms); }, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).rejects.toThrow(/assets are missing/); + expect(probes).toBe(1); + expect(delays).toEqual([]); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(1); + }); + + test("a proven-present WinSW service blocks retry even before the task becomes visible", async () => { + // conflict only turns true once the task itself is visible, so an invisible task + // beside a running WinSW is `conflict: false, nativeServiceAbsent: false`. A + // predicate that only checked `!conflict` would happily retry this. + mockParentRollbackSpawn(); + const delays: number[] = []; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { + probes += 1; + return { + taskInstalled: false, + registrationHealthy: false, + assetsHealthy: true, + nativeServiceAbsent: false, + nativeStatusUnknown: false, + conflict: false, + ok: false, + detail: "Task Scheduler task is not installed.", + }; + }, + settleDelay: async ms => { delays.push(ms); }, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).rejects.toThrow(/not installed/); + expect(probes).toBe(1); + expect(delays).toEqual([]); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(1); + }); + + test("unknown WinSW status is unproven, not transient, and still preserves the task", async () => { + mockParentRollbackSpawn(); + const delays: number[] = []; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { + probes += 1; + return { + taskInstalled: true, + registrationHealthy: true, + assetsHealthy: true, + nativeServiceAbsent: false, + nativeStatusUnknown: true, + conflict: false, + ok: false, + detail: "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent.", + }; + }, + settleDelay: async ms => { delays.push(ms); }, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).rejects.toThrow(/could not verify/); + expect(probes).toBe(1); + expect(delays).toEqual([]); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(0); + }); + + test("ownership lost during a settle delay stops without rollback or state write", async () => { + mockParentRollbackSpawn(); + let owned = true; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { probes += 1; return absentVerify(); }, + settleDelay: async () => { owned = false; }, + stillOwnsAttempt: () => owned, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).resolves.toEqual({ kind: "done" }); + expect(probes).toBe(1); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(0); + }); + + test("ownership lost around a non-retryable failure skips rollback too", async () => { + // The settle loop never awaits for a non-retryable verdict, so this is the only + // path that reaches the pre-rollback ownership fence: a stale attempt must not + // delete a task that a newer attempt now owns. + mockParentRollbackSpawn(); + let owned = true; + let probes = 0; + setFinalizeWindowsSchedulerHooksForTests({ + elevateCreateAndRun: succeedingElevation(), + verify: () => { + probes += 1; + owned = false; + return unhealthyVerify(); + }, + settleDelay: async () => { throw new Error("must not settle a non-retryable verdict"); }, + stillOwnsAttempt: () => owned, + writeInstallState: () => { writeCount += 1; }, + }); + + await expect(finalizeWindowsSchedulerServiceRegistration()).resolves.toEqual({ kind: "done" }); + expect(probes).toBe(1); + expect(writeCount).toBe(0); + expect(parentRollbackLaunches).toBe(0); + }); + test("runElevatedSchtasksCreateAndRun launches PowerShell once and classifies protocol exit", async () => { let launches = 0; setWindowsElevationSpawnForTests((() => { From 3ac3fb2954b016c6489e92d734b80b1ea0826f8e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 2 Aug 2026 19:41:58 +0900 Subject: [PATCH 2/5] fix(doctor): report how the service actually got its Bun runtime doctor kept telling Windows users to set OPENCODEX_BUN_PATH even when the override was already active. The payload it reads had a Bun version but no runtime origin, so the auto-known-bad branch could not tell an override from the bundled binary and printed the same remedy either way. Origin cannot be recovered after the fact: resolving it at report time answers 'what would this shell pick now', which is a different question from 'what was the service started with', and the two diverge exactly when it matters. So the selecting launcher now stamps it. Every real launch path carries the marker -- npm launcher, scheduler wrapper, WinSW, launchd, systemd, plus the Codex autostart shim and the tray host, which relaunch the proxy themselves and would otherwise erase it. Path and provenance come from one resolution at each site, so the marker cannot describe a binary other than the one baked. Read-back allowlists the three values and never falls back to resolving locally. A service installed before the marker existed reports nothing, and doctor says the origin is unknown instead of guessing -- an absent marker is an answer, not a gap to fill. bunRevision stays informational and the conservative auto-known-bad result for canaries is untouched; bun-stream-caps.ts and responses/core.ts are absent from this diff. Fixes #848. --- bin/ocx.mjs | 19 +- .../000_plan.md | 34 ++++ .../010_implementation.md | 162 ++++++++++++++++++ src/cli/doctor.ts | 25 ++- src/codex/shim.ts | 29 ++-- src/lib/bun-runtime.ts | 34 +++- src/lib/winsw.ts | 9 +- src/server/management/system-routes.ts | 4 + src/service.ts | 25 ++- src/tray/windows-tray.ps1 | 4 + src/tray/windows.ts | 11 +- structure/05_gui-and-management-api.md | 30 +++- tests/bun-runtime.test.ts | 39 ++++- tests/codex-shim.test.ts | 18 +- tests/doctor.test.ts | 31 +++- tests/memory-watchdog.test.ts | 27 +++ tests/ocx-launcher-source.test.ts | 2 +- tests/service.test.ts | 44 ++++- tests/windows-tray.test.ts | 15 ++ tests/winsw.test.ts | 11 +- 20 files changed, 530 insertions(+), 43 deletions(-) create mode 100644 devlog/_plan/260802_wt5_windows_service_doctor/000_plan.md create mode 100644 devlog/_plan/260802_wt5_windows_service_doctor/010_implementation.md diff --git a/bin/ocx.mjs b/bin/ocx.mjs index fd1cc9194..6e7f2b448 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -317,6 +317,10 @@ function bunBinDir() { } const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; +// Mirrors BUN_RUNTIME_SOURCE_ENV in src/lib/bun-runtime.ts. This launcher is plain +// Node and runs before any TypeScript is loaded, so the name is repeated rather than +// imported; tests/ocx-launcher-source.test.ts pins the two together. +const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; function findBunBinary(bunDir) { // The npm `bun` package ships the binary as bin/bun.exe on every platform; @@ -347,7 +351,7 @@ function resolveBun() { const override = process.env[BUN_OVERRIDE_ENV]?.trim(); if (override) { const overridePath = resolve(override); - if (isRealBunBinary(overridePath)) return overridePath; + if (isRealBunBinary(overridePath)) return { path: overridePath, source: "override" }; console.error( `opencodex: ${BUN_OVERRIDE_ENV} is missing, unreadable, or not a complete Bun binary; falling back to the bundled runtime.`, ); @@ -361,7 +365,7 @@ function resolveBun() { } let bin = findBunBinary(bunDir); - if (bin) return bin; + 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. @@ -371,7 +375,7 @@ function resolveBun() { if (r.status === 0) bin = findBunBinary(bunDir); } if (!bin) fail("Bun binary missing after install attempt."); - return bin; + return { path: bin, source: "bundled" }; } // `ocx update --help` prints usage and exits WITHOUT side effects. The npm launcher @@ -389,7 +393,8 @@ if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstal runNpmSelfUpdate(); } -const bun = resolveBun(); +const bunRuntime = resolveBun(); +const bun = bunRuntime.path; // Run the Bun child asynchronously and FORWARD termination signals to it, then wait // for its graceful shutdown before this launcher exits. The previous blocking @@ -414,7 +419,11 @@ const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] .filter(name => typeof process.env[name] === "string" && process.env[name] !== ""); const child = spawn(bun, [cliPath, ...process.argv.slice(2)], { stdio: "inherit", - env: { ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(",") }, + env: { + ...process.env, + OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(","), + [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, + }, }); // Windows has no real POSIX signals (no SIGHUP); forwarding is best-effort there. diff --git a/devlog/_plan/260802_wt5_windows_service_doctor/000_plan.md b/devlog/_plan/260802_wt5_windows_service_doctor/000_plan.md new file mode 100644 index 000000000..b1f24ae07 --- /dev/null +++ b/devlog/_plan/260802_wt5_windows_service_doctor/000_plan.md @@ -0,0 +1,34 @@ +# wt5 — Windows scheduler settle + Bun provenance diagnostics (research) + +Worktree: `/Users/jun/.codex/worktrees/bdbb/opencodex` (branch `codex/wt5-windows-service`, off `dev`). +The originally scaffolded `260802-wt5-windows-service` checkout was empty and has been removed; this +checkout owns the branch. +Two must-fix bugs in service install/diagnostics. + +## Scope + +### Bug A — PR #868: scheduler registration verification never settles + +- Root cause (from PR body, re-verify): post-create Task Scheduler verification retried non-transient states, so registration verification could fail to settle. Fix retries only transient post-create visibility/XML health states, preserves fail-closed behavior for conflicts/missing assets/unknown SCM status, and stops late reconciliation when attempt ownership changes. +- Grounding: `src/service.ts`, `src/lib/winsw.ts`, `src/lib/windows-elevation.ts`; verification contracts in scheduler/startup/install tests (PR claims 136 focused tests). +- Severity: medium-high — Windows service install reliability; fail-closed semantics must be preserved exactly. + +### Bug B — PR #861 / issue #848: doctor repeats OPENCODEX_BUN_PATH guidance when override already active + +- Root cause (owner-confirmed on `dev@fa5780d5`): the service/doctor path has the Bun version but no trustworthy runtime-origin field, so Windows `auto-known-bad` repeats the `OPENCODEX_BUN_PATH` instruction even when the override is already active. +- Fix shape (owner-directed, from issue #848 comment): propagate one allowlisted `override | bundled | process` provenance marker through every actual launcher (npm Node launcher, Windows scheduler, native WinSW, launchd, systemd); report unknown/absent for legacy payloads instead of inferring from the current shell (`durableBunRuntime()` at reporting time can mislabel the running process); keep `bunRevision` informational; preserve the conservative `auto-known-bad` result for canaries; document the provenance trust rule in `structure/05_gui-and-management-api.md`. +- Grounding: `src/lib/bun-runtime.ts`, `src/service.ts`, `src/cli/status.ts`, `src/server/management/system-routes.ts`. +- Severity: medium — diagnostics-only, but it sends Windows users down a wrong remediation path. + +## Claim ledger + +| # | Claim | Source | Status | +|---|-------|--------|--------| +| 1 | Windows scheduler verification can fail to settle on transient states | PR #868 body | code-verified (PR author claims live Windows validation) | +| 2 | Doctor mislabels runtime origin; repeats override guidance | issue #848, owner comment | verified by owner on dev@fa5780d5 | +| 3 | Conservative eager-relay policy must stay unchanged for canary Bun | issue #848, owner comment | verified directive | + +## Out of scope + +- Changing the conservative eager-relay capability policy for canary/unknown Bun builds. +- macOS launchd/systemd behavior changes beyond adding the provenance marker. diff --git a/devlog/_plan/260802_wt5_windows_service_doctor/010_implementation.md b/devlog/_plan/260802_wt5_windows_service_doctor/010_implementation.md new file mode 100644 index 000000000..e76574cb4 --- /dev/null +++ b/devlog/_plan/260802_wt5_windows_service_doctor/010_implementation.md @@ -0,0 +1,162 @@ +# wt5 — Implementation roadmap + +Branch `codex/wt5-windows-service`. Working checkout: `/Users/jun/.codex/worktrees/bdbb/opencodex`. + +**P-phase stale check (2026-08-02, tree at `478354ee8`).** Both bugs were re-verified against the +current tree by independent sol-medium explorers. Neither is a NOOP, and the line anchors below are +measured rather than copied from the PR bodies. Baseline before any change: `bun run typecheck` +exit 0; `bun run test` 6918 pass / 8 skip / 5 fail, where all five failures were +`Cannot find package 'react'` from an uninstalled `gui/node_modules` — resolved by running +`bun install` inside `gui/`, so the real pre-change baseline is a green suite. + +## Bug A — #868: scheduler verification settle + +**Measured root cause.** `applyElevatedSchedulerResult()` verifies exactly once and rolls back +immediately (`src/service.ts:1005-1022`). After a successful elevated `/create` + `/run`, the +non-elevated `/query /tn` and the CSV fallback can both miss the just-created task, so +`probeWindowsSchedulerTask()` returns `absent` (`src/service.ts:763-774`), +`windowsSchedulerTaskInstalled()` collapses every non-`present` result to `false` +(`src/service.ts:783-785`), and the evaluator reports `ok: false` / +"Task Scheduler task is not installed." (`src/service.ts:817-820`). A healthy registration is +rolled back milliseconds before Task Scheduler would have exposed it. + +Precision note (audit): "rolls back immediately" is the failure path only. A healthy registration +with unknown SCM status is already preserved without writing state (`src/service.ts:1007-1021`), and +create/run/protocol failures exit before verification entirely (`:985-1003`). + +Already present on this tree (do NOT re-add): tri-state probe (`src/service.ts:662`, `:759-780`), +`resolveWindowsSchedulerTaskProbe()` (`:916`), the pure evaluator (`:800`), the on-disk XML fallback +for an empty `/query /xml` view (`:844-856`), and ownership fencing — `attemptStillOwned()` is +defined at `:971`, the entry guard is `:980-982`, and the pre-write guard is `:1036-1041`. + +Genuinely missing: bounded post-create retries, a retry-eligibility classifier, an injectable settle +delay for tests, and an ownership guard before rollback. + +File map: + +- MODIFY `src/service.ts` — add a retry-eligibility predicate that permits a delayed recheck ONLY + when assets are healthy, WinSW is proven absent, there is no conflict, and task presence or + registration health is still incomplete. Add a bounded settle loop (initial verification plus at + most four delayed rechecks; delays `[50, 150, 300, 600]` ms, 1.1 s total) with an ownership check + before and after every await, plus an ownership guard before rollback (`:1022`). +- MODIFY the `FinalizeHooks` seam (`src/service.ts:894`) — add a test-only injectable `settleDelay`. +- Tests: `tests/windows-elevation-spawn.test.ts` for the settle cases, alongside the existing + contracts in `tests/windows-scheduler-install-verification.test.ts` and + `tests/startup-action-control-elevation.test.ts`. + +Scope note: an empty `/query /xml` view is already mitigated by the disk fallback, and permanently +malformed XML is not transient — retrying only delays the same rollback there. The load-bearing case +is task-visibility lag, plus a non-empty but temporarily unhealthy XML view. + +Acceptance + activation: + +1. Transient post-create invisibility settles: scripted `absent` → `present but unhealthy` → + healthy, then install state is written. Activation: fault-injection test with an injected + `settleDelay`. +2. Confirmed conflict receives ZERO retries and rolls back. Activation: adversarial test counting + probe invocations. +3. Missing assets fail immediately with no retry. Activation: adversarial test. +4. Unknown SCM status still fails closed without claiming conflict and without rollback — the + existing contract at `tests/windows-elevation-spawn.test.ts:718` must stay green. +5. Persistent unhealthy registration exhausts the budget (five probes, four delays) and then rolls + back. Activation: counting test. +6. Ownership lost DURING a settle delay stops with no rollback and no state write. Activation: + interleaving test. +7. Ownership lost AFTER the final verification but before the caller resumes from `await` also + stops. Activation: a test flipping ownership from the final `verify` hook. This is the edge the + upstream PR's own tests miss, and it is why this lane re-implements rather than cherry-picks. +8. **(audit blocker 2)** Ownership lost around a NON-RETRYABLE final failure must skip rollback too. + Acceptance 7 alone only re-proves the existing pre-write guard at `:1036-1041`, because a + successful final verify has no await after it. Activation: a dedicated test where `verify` returns + a non-retryable failure AND revokes ownership, asserting zero rollback launches and zero state + writes. This is the only test that actually fires the new pre-rollback guard. +9. **(audit blocker 3)** "WinSW proven absent" must be proven as an INDEPENDENT retry-rejection + condition. Because `conflict` requires `taskInstalled` (`src/service.ts:812-817`), the conflict + case in acceptance 2 activates `conflict:true` and `nativeServiceAbsent:false` together and cannot + distinguish a predicate that only checks `!conflict`. Activation: an isolated case with + `taskInstalled:false`, healthy assets, and `nativeStatus:"started"` — assert exactly one + verification, zero delays, and immediate fail-closed handling. + +Live Windows validation is not available in this session, so the PR body's startup-protection smoke +claim is covered by the fault-injection contracts above. Anything that genuinely requires a real +Windows host is reported as such rather than claimed. + +## Bug B — #861/#848: Bun runtime provenance + +**Measured root cause.** The repeated instruction comes from one unguarded branch in doctor: +`if (d.platform === "win32" && d.eagerRelay?.reason === "auto-known-bad")` +(`src/cli/doctor.ts:657`), which always emits "…or set `OPENCODEX_BUN_PATH` to a runtime you trust" +(`:658-660`). The payload it reads carries no runtime-origin field at all — +`src/server/management/system-routes.ts:77-82` goes straight from `bunRevision` to `platform` — so +the branch cannot distinguish an active override from bundled or process execution. + +`durableBunRuntime()` already returns the three-value source (`src/lib/bun-runtime.ts:55-60`), but it +resolves in the CALLING process: `src/cli/status.ts:170` calls it inside the status process, which +says nothing about how the running service was launched. That is exactly why the marker has to be +stamped at launch instead of inferred at report time. + +File map: + +- MODIFY `src/lib/bun-runtime.ts` — export one marker env-var name, the shared source type, and an + allowlisted `reportedBunRuntimeSource(env)` returning `undefined` for missing or invalid values. + It must never call `durableBunRuntime()`. +- MODIFY the launcher entry (`cliEntry()` at `src/service.ts:46-50`) so the executable path and its + source come from ONE `durableBunRuntime()` call, then stamp that paired source into all five + launchers: npm child env (`bin/ocx.mjs:415-418`), scheduler batch env (`src/service.ts:1265-1278` + — today the source is only logged at `:1283-1286`), WinSW `` (`src/lib/winsw.ts:95-103`, entry + shape `:65-68`), launchd `EnvironmentVariables` (`src/service.ts:276-281`), and systemd + `Environment=` (`src/service.ts:1860-1867`). +- **(audit blocker 1)** Two further REAL launch paths must be covered or the marker is erased on the + most common Windows start: + - Codex autostart shim — it selects its own runtime at `src/codex/shim.ts:123-127` (called at + `:604`) and reaches the daemon through `ocx ensure` (`src/codex/shim.ts:409`, `:465`, `:500`), + which spawns with inherited env at `src/cli/index.ts:383-389`. Tests: `tests/codex-shim.test.ts`. + - Windows tray — `src/tray/windows.ts:83-90` only builds an entry; the child environment is built + at `:491-510` and autostart arguments at `:129-177`, while tray proxy actions actually spawn Bun + from `src/tray/windows-tray.ps1:84-95` (today setting only `CODEX_HOME` and `OPENCODEX_HOME`). + Pass the paired source through the tray arguments and stamp it into + `ProcessStartInfo.EnvironmentVariables`. Tests: `tests/windows-tray.test.ts`. +- MODIFY `src/server/management/system-routes.ts:77-82` — serialize only the allowlisted marker + beside `bunRevision`; `undefined` omits the field entirely for legacy payloads. +- MODIFY `src/cli/doctor.ts` — carry the scalar through the client type (`:523-537`) and payload + normalization (`:573-606`), then branch under the existing guard: `override` states the override is + already active and never re-emits the setup instruction; `undefined` states legacy/unknown without + guessing; `bundled` keeps today's remediation; `process` names process provenance instead of + implying bundled. +- DOCS: `structure/05_gui-and-management-api.md:81` — provenance trust + backward-compat rule. +- DO NOT TOUCH: `src/lib/bun-stream-caps.ts` (`MIN_FIXED_BUN_VERSION` `:20-24`, canary conservatism + `:52-67`, the `auto-known-bad` decision `:79-88`, the `config-eager` opt-in `:84-85`, + `selectEagerPath()` `:98-111`) or `src/server/responses/core.ts:1769-1778`. + +Acceptance + activation: + +1. Override marker present → doctor never emits "set `OPENCODEX_BUN_PATH`". Activation: doctor test + with a payload carrying `override`. +2. Legacy payload with no marker → unknown wording, no shell guess. Activation: fixture with the old + payload shape. +3. `bundled` keeps today's remediation and `process` gets its own wording. Activation: two doctor + tests. +4. Each launcher stamps the source PAIRED with the Bun path it actually selected, not merely "some + source is present". Activation: per-launcher artifact assertions covering all seven paths (five + named launchers plus the Codex shim and the tray). +5. Endpoint serialization matrix: `override`, `bundled`, and `process` serialize; invalid and unset + both omit the field. Activation: five input states; invalid and unset may share one test provided + both are asserted. +6. Canary `auto-known-bad` and the eager-relay policy stay unchanged. Activation: + `tests/bun-stream-caps.test.ts` stays green untouched AND — **(audit blocker 4)** — a diff receipt + showing `src/lib/bun-stream-caps.ts` and `src/server/responses/core.ts` do not appear in the + implementation diff at all. A green test alone does not prove the source is unchanged. + +Known-fixture impact (audit, plan for it rather than discovering it at B): making the paired `source` +required breaks fixture literals at `tests/service.test.ts:79`, `:496-499`, `:508`, `:522-525`, +`:546-549`, `:1336` and the shared WinSW entry at `tests/winsw.test.ts:10`. Artifact assertions that +should gain marker checks: `tests/service.test.ts:538-560`, `:575-589`, `tests/winsw.test.ts:38-48`. +The currently green doctor test at `tests/doctor.test.ts:404-409` WILL fail unless its `baseData` +fixture gains `bundled` (legacy/unknown must stop printing the override instruction) — add a separate +legacy fixture rather than weakening that test; `tests/doctor.test.ts:411-426` stays unchanged. + +## Verification gate + +`bun run typecheck` exit 0, focused doctor/runtime/service/watchdog/winsw/elevation tests shown +red-then-green, and a full `bun run test` compared against the green baseline recorded at the top of +this document. Commit per bug locally; pushing stays gated on explicit user approval. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 901491761..fb849efbb 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -13,6 +13,8 @@ import { dirname, join } from "node:path"; import { getConfigDir, getConfigPath, readConfigDiagnostics, readPid, readRuntimePort, resolveEnvValue } from "../config"; import { findLiveProxy } from "../server/proxy-liveness"; import { gracefulStopHost } from "../lib/process-control"; +import { BUN_RUNTIME_SOURCES } from "../lib/bun-runtime"; +import type { BunRuntimeSource } from "../lib/bun-runtime"; import { maskAccountId } from "../lib/privacy"; import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env"; import { configuredAdminToken } from "../lib/admin-secrets"; @@ -523,6 +525,8 @@ export async function probeWham(fetchImpl: typeof fetch = fetch): Promise source === body.bunRuntimeSource), platform: typeof body.platform === "string" ? body.platform : "unknown", rss: body.rss, heapUsed: typeof body.heapUsed === "number" ? body.heapUsed : 0, @@ -652,12 +659,22 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] } else { lines.push(" !! high RSS, indeterminate split — capture two doctor runs over time to see the trend"); } - // Version-claiming (never binary-claiming): the endpoint cannot distinguish - // the bundled binary from an OPENCODEX_BUN_PATH override of the same version. if (d.platform === "win32" && d.eagerRelay?.reason === "auto-known-bad") { lines.push(` service is running Bun ${d.bunVersion} on Windows — a version affected by the upstream Bun memory issue.`); - lines.push(" Options: wait for a bundled runtime update, or set OPENCODEX_BUN_PATH to a runtime you trust (unvalidated — own risk),"); - lines.push(" or opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs)."); + // The remediation depends on how the SERVICE was launched, which only the + // launch-time marker can answer. Telling someone to set OPENCODEX_BUN_PATH + // when it is already set is the bug this branch exists to avoid (#848). + if (d.bunRuntimeSource === "override") { + lines.push(` OPENCODEX_BUN_PATH is already active for this service — the override runtime is itself an affected version (unvalidated — own risk).`); + lines.push(" Options: point the override at a different runtime, or opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs)."); + } else if (d.bunRuntimeSource === undefined) { + lines.push(" this service records no runtime origin (installed before provenance tracking), so OpenCodex cannot tell whether an override is already active."); + lines.push(" Reinstall the service to record it, or opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs)."); + } else { + const origin = d.bunRuntimeSource === "process" ? "the runtime that launched it" : "the bundled runtime"; + lines.push(` the service is using ${origin}. Options: wait for a bundled runtime update, or set OPENCODEX_BUN_PATH to a runtime you trust (unvalidated — own risk),`); + lines.push(" or opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs)."); + } } return lines; } diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 97bf424e1..4eb251f57 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -19,7 +19,8 @@ import { writeFileSync, } from "node:fs"; import { getConfigDir } from "../config"; -import { durableBunPath } from "../lib/bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "../lib/bun-runtime"; +import type { BunRuntimeSource } from "../lib/bun-runtime"; import { isProcessAlive } from "../lib/process-control"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -120,11 +121,13 @@ export type CodexShimAutoRestoreResult = | { status: "ineligible" | "deferred"; message?: string } | { status: "restored"; message: string }; -function cliEntry(): { bun: string; cli: string } { +function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { // Bundled Bun path (survives `ocx update`); all three shim builders // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. // This module lives in src/codex/, the CLI entry in src/cli/index.ts. - return { bun: durableBunPath(), cli: join(import.meta.dir, "..", "cli", "index.ts") }; + // Path and provenance resolve together so the marker always describes this binary. + const runtime = durableBunRuntime(); + return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") }; } function commandNames(name: string): string[] { @@ -366,11 +369,13 @@ function shQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } -export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, tokenFile = serviceApiTokenFilePath()): string { +export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, tokenFile = serviceApiTokenFilePath(), bunRuntimeSource: BunRuntimeSource = "bundled"): string { const internalCommands = CODEX_INTERNAL_COMMANDS.join("|"); const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|"); return `#!/usr/bin/env sh # ${SHIM_MARKER} +${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} +export ${BUN_RUNTIME_SOURCE_ENV} if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})" export OPENCODEX_API_AUTH_TOKEN @@ -431,13 +436,14 @@ function windowsBatchSet(name: string, value: string): string { return `set "${name}=${windowsEnvIndirectBatchValue(value, windowsBatchValue)}"`; } -export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string { +export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource = "bundled"): string { const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n"); const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n"); return `@echo off\r rem ${SHIM_MARKER}\r ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r +${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r ${windowsBatchSet("OCX_CLI", cliPath)}\r ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r @@ -472,12 +478,13 @@ function psString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string { +export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource = "bundled"): string { const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", "); const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", "); const tokenFile = serviceApiTokenFilePath(); return `#!/usr/bin/env pwsh # ${SHIM_MARKER} +$env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)} if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) { $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim() } @@ -601,25 +608,25 @@ function gitBashPath(path: string): string { } function writeShim(wrapperPath: string, realCodexPath: string): void { - const { bun, cli } = cliEntry(); + const { bun, bunRuntimeSource, cli } = cliEntry(); if (process.platform === "win32") { const lower = wrapperPath.toLowerCase(); if (lower.endsWith(".ps1")) { // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI // codepage, which mangles non-ASCII paths embedded in the shim. - writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli)}`, "utf8"); + writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8"); } else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) { - writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8"); + writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8"); } else { // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. writeFileSync( wrapperPath, - buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath())), + buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath()), bunRuntimeSource), "utf8", ); } } else { - writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli), "utf8"); + writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli, serviceApiTokenFilePath(), bunRuntimeSource), "utf8"); chmodSync(wrapperPath, 0o755); } } diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index b352d84ac..b4396fe29 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -20,12 +20,44 @@ const require = createRequire(import.meta.url); const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; +/** + * Env marker stamped by whichever launcher selected the Bun binary, then read back + * inside the launched process. + * + * Provenance has to travel with the launch because it cannot be recovered afterwards: + * resolving it at report time answers "what would this shell pick now", not "what was + * this service started with", and those differ exactly when the answer matters. + */ +export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; + +export type BunRuntimeSource = "override" | "bundled" | "process"; + +/** The only provenance values any surface may accept off the wire or out of the env. */ +export const BUN_RUNTIME_SOURCES: readonly BunRuntimeSource[] = ["override", "bundled", "process"]; + export type DurableBunRuntime = { path: string; - source: "override" | "bundled" | "process"; + source: BunRuntimeSource; overrideEnv: typeof BUN_OVERRIDE_ENV; }; +/** + * The provenance this process was launched with, or `undefined` when nothing + * trustworthy is recorded. + * + * Deliberately never falls back to `durableBunRuntime()`. A service installed before + * the marker existed has no provenance, and guessing one from the current environment + * would report a confident wrong answer — "unknown" is the honest result and callers + * are expected to say so. Values outside the allowlist are treated as absent rather + * than passed through. + */ +export function reportedBunRuntimeSource( + env: NodeJS.ProcessEnv = process.env, +): BunRuntimeSource | undefined { + const raw = env[BUN_RUNTIME_SOURCE_ENV]?.trim(); + return BUN_RUNTIME_SOURCES.find(source => source === raw); +} + /** * Absolute path to the bundled Bun binary, or null if the `bun` dependency is * not installed/resolvable (or only the un-downloaded placeholder is present). diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 5222e1228..21d04d3e4 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -20,7 +20,8 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandUserPath, getConfigDir, loadConfig } from "../config"; import { recordOwnedConfigPath } from "./config-ownership"; -import { durableBunPath } from "./bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime"; +import type { BunRuntimeSource } from "./bun-runtime"; import { serviceApiTokenFilePath } from "./service-secrets"; export const WINSW_VERSION = "2.12.0"; @@ -64,6 +65,8 @@ function currentCodexHomeAbsolute(): string { export interface WinswEntry { bun: string; + /** Provenance of `bun`, resolved together with it so the two can never disagree. */ + bunRuntimeSource: BunRuntimeSource; cli: string; } @@ -95,6 +98,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim(); const envLines = [ ` `, + ` `, ` `, ` `, env.CODEX_HOME?.trim() ? ` ` : null, @@ -371,5 +375,6 @@ export function winswStatusSummary(): string { /** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */ export function defaultWinswEntry(cliDir: string): WinswEntry { - return { bun: durableBunPath(), cli: join(cliDir, "cli", "index.ts") }; + const runtime = durableBunRuntime(); + return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") }; } diff --git a/src/server/management/system-routes.ts b/src/server/management/system-routes.ts index cb7dba89e..6defbbc5a 100644 --- a/src/server/management/system-routes.ts +++ b/src/server/management/system-routes.ts @@ -22,6 +22,7 @@ * dashboard drain-and-restart confirm UX — never request bodies or IDs. */ import { selectEagerPath } from "../../lib/bun-stream-caps"; +import { reportedBunRuntimeSource } from "../../lib/bun-runtime"; import { getActiveTurnCount, isDraining } from "../lifecycle"; import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog"; import { responseStateMetrics } from "../../responses/state"; @@ -78,6 +79,9 @@ export async function handleSystemRoutes(ctx: ManagementContext): PromiseOCX_SERVICE1`, + ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, @@ -1325,8 +1331,9 @@ function taskXmlRunLevelAcceptable(principal: string): boolean { } export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string { - const { bun, cli } = entry; - const bunRuntime = durableBunRuntime(); + // Provenance rides along with the entry: a second durableBunRuntime() call here could + // resolve differently from the binary the caller actually baked. + const { bun, bunRuntimeSource, cli } = entry; const path = process.env.PATH ?? ""; const lines = [ "@echo off", @@ -1335,6 +1342,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants. "chcp 65001 >nul", windowsBatchSet("OCX_SERVICE", "1"), + windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), windowsBatchSet("PATH", path, "pathList"), windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), @@ -1348,7 +1356,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ ":loop", '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start', '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"', - `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntime.source}"`, + `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntimeSource}"`, '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"', '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"', '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"', @@ -1920,13 +1928,14 @@ function unitPath(): string { } export function buildUnit(): string { - const { bun, cli } = cliEntry(); + const { bun, bunRuntimeSource, cli } = cliEntry(); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim()); const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()); const envLines = [ systemdEnvironmentAssignment("OCX_SERVICE", "1"), + systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), systemdEnvironmentAssignment("PATH", path), codexHome, opencodexHome, diff --git a/src/tray/windows-tray.ps1 b/src/tray/windows-tray.ps1 index c7dfa58f7..cedcbb42b 100644 --- a/src/tray/windows-tray.ps1 +++ b/src/tray/windows-tray.ps1 @@ -3,6 +3,9 @@ param( [Parameter(Mandatory = $true)][string]$CliPath, [Parameter(Mandatory = $true)][string]$CodexHome, [Parameter(Mandatory = $true)][string]$OpenCodexHome, + # Provenance of $BunPath, chosen when the tray entry was built. Optional so an + # already-installed launcher command from an older version still starts. + [ValidateSet("", "override", "bundled", "process")][string]$BunRuntimeSource = "", [ValidateSet("Run", "Stop")][string]$Mode = "Run", [int]$HostPid = 0 ) @@ -92,6 +95,7 @@ function Start-OcxCommand([string[]]$CommandArgs) { $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden $psi.EnvironmentVariables["CODEX_HOME"] = $CodexHome $psi.EnvironmentVariables["OPENCODEX_HOME"] = $OpenCodexHome + if ($BunRuntimeSource) { $psi.EnvironmentVariables["OCX_BUN_RUNTIME_SOURCE"] = $BunRuntimeSource } $process = [System.Diagnostics.Process]::Start($psi) if ($null -ne $process) { $process.Dispose() } Write-ActionLog "dispatched $($CommandArgs -join ' ')" diff --git a/src/tray/windows.ts b/src/tray/windows.ts index 78c2e681b..781329be7 100644 --- a/src/tray/windows.ts +++ b/src/tray/windows.ts @@ -4,7 +4,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; -import { durableBunPath } from "../lib/bun-runtime"; +import { durableBunRuntime } from "../lib/bun-runtime"; +import type { BunRuntimeSource } from "../lib/bun-runtime"; import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -20,6 +21,8 @@ const TRAY_ICON_FILES = [ export interface WindowsTrayEntry { bun: string; + /** Provenance of `bun`, resolved together with it. */ + bunRuntimeSource: BunRuntimeSource; cli: string; script: string; codexHome: string; @@ -81,8 +84,10 @@ function currentCodexHome(): string { } function currentEntry(): WindowsTrayEntry { + const runtime = durableBunRuntime(); return { - bun: durableBunPath(), + bun: runtime.path, + bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts"), script: installedTrayScriptPath(), codexHome: currentCodexHome(), @@ -136,6 +141,7 @@ export function windowsTrayProcessArgs(entry: WindowsTrayEntry, mode: "Run" | "S "-WindowStyle", "Hidden", "-File", safePath(entry.script), "-BunPath", safePath(entry.bun), + "-BunRuntimeSource", entry.bunRuntimeSource, "-CliPath", safePath(entry.cli), "-CodexHome", safePath(entry.codexHome), "-OpenCodexHome", safePath(entry.opencodexHome), @@ -170,6 +176,7 @@ export function buildWindowsTrayPowerShellCommand(entry: WindowsTrayEntry, power "-WindowStyle", "Hidden", "-File", quoteRunValue(entry.script), "-BunPath", quoteRunValue(entry.bun), + "-BunRuntimeSource", entry.bunRuntimeSource, "-CliPath", quoteRunValue(entry.cli), "-CodexHome", quoteRunValue(entry.codexHome), "-OpenCodexHome", quoteRunValue(entry.opencodexHome), diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 27d57e9d2..011f40d49 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -78,7 +78,7 @@ this document owns is which module holds which area and what invariant that area | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), and the logical maximum thread count. Selecting `v2` enables the native flag and migrates `[agents] max_threads` to the v2 key; selecting `v1` disables it and migrates the same value back. `default` leaves the native flag unchanged. PUT accepts `enabled`, `multiAgentMode`, and/or the compatibility-named `maxConcurrentThreadsPerSession`; contradictory mode/flag pairs are rejected before writes. Every transition is rollback-safe and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | -| System | `POST /api/system/restart` restarts the proxy in place. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; rides the standard management auth gate and must never move to unauthenticated `/healthz`. Consumed by `ocx doctor`'s Memory/runtime section and the dashboard Memory observability card. | +| System | `POST /api/system/restart` restarts the proxy in place. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; rides the standard management auth gate and must never move to unauthenticated `/healthz`. Consumed by `ocx doctor`'s Memory/runtime section and the dashboard Memory observability card. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning` and `vision.maxDescriptionsPerTurn`; the read and PUT-response payload reports model, backend, and the vision per-turn limit. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | @@ -108,6 +108,34 @@ identity, active selection, and routing never consult these fields. The matching ## Sidebar stop button +## Bun runtime provenance + +`GET /api/system/memory` may report `bunRuntimeSource` — one of `override`, `bundled`, or +`process` — describing how the **running service** obtained its Bun binary. + +The value is stamped into the launched process's environment (`OCX_BUN_RUNTIME_SOURCE`) by +whichever launcher selected the binary: the npm Node launcher, the Windows Task Scheduler +wrapper, the native WinSW service, launchd, systemd, the Codex autostart shim, and the Windows +tray host. Provenance and path come from a single `durableBunRuntime()` resolution at each of +those sites, so the marker can never describe a different binary than the one actually baked. + +**Trust rule: a reporting surface must never resolve provenance for itself.** Calling +`durableBunRuntime()` at report time answers "what would this process pick right now", which is +a different question from "what was the service started with" — and the two diverge exactly when +the answer matters, such as a `doctor` run in a shell whose `OPENCODEX_BUN_PATH` differs from the +installed service's. Read-back goes through `reportedBunRuntimeSource()`, which allowlists the +three values and returns `undefined` for anything else. + +**Backward compatibility: absent is a real answer.** A service installed before this marker +existed reports no provenance, the endpoint omits the field, and consumers must say the origin is +unknown rather than infer one. `ocx doctor` relies on this to avoid its previous behavior of +telling a user to set `OPENCODEX_BUN_PATH` when the override was already active (#848). An +unrecognized wire value is treated as absent rather than passed through. + +`bunRevision` remains informational and carries no capability meaning. Provenance does not feed +the eager-relay decision: the conservative `auto-known-bad` result for canary and otherwise +unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). + The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a confirmation prompt, then fires the request and accepts the connection drop (the proxy exits). The endpoint restores native Codex config, stops any installed service to prevent respawn, and exits. diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index 9951facaa..8e4114ae7 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath } from "../src/lib/bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource } from "../src/lib/bun-runtime"; // realpath the temp root: on macOS /var is a symlink to /private/var, so a path built // from mkdtemp compares unequal to the same path resolved through process.cwd(). @@ -111,3 +111,40 @@ describe("bundledBunPath / durableBunPath", () => { } }); }); + +describe("reportedBunRuntimeSource (#848 launch-time provenance)", () => { + it("reads back each allowlisted marker", () => { + for (const source of ["override", "bundled", "process"] as const) { + expect(reportedBunRuntimeSource({ [BUN_RUNTIME_SOURCE_ENV]: source })).toBe(source); + } + }); + + it("treats an absent marker as unknown rather than guessing from this process", () => { + // A service installed before provenance existed has no marker. Reporting a + // confident wrong origin is exactly the #848 failure, so the answer is undefined. + expect(reportedBunRuntimeSource({})).toBeUndefined(); + expect(reportedBunRuntimeSource({ [BUN_RUNTIME_SOURCE_ENV]: "" })).toBeUndefined(); + }); + + it("rejects values outside the allowlist instead of passing them through", () => { + expect(reportedBunRuntimeSource({ [BUN_RUNTIME_SOURCE_ENV]: "system" })).toBeUndefined(); + expect(reportedBunRuntimeSource({ [BUN_RUNTIME_SOURCE_ENV]: "OVERRIDE" })).toBeUndefined(); + expect(reportedBunRuntimeSource({ [BUN_RUNTIME_SOURCE_ENV]: "override; rm -rf /" })).toBeUndefined(); + }); + + it("does not fall back to the current environment when the marker is missing", () => { + const inherited = process.env.OPENCODEX_BUN_PATH; + const real = join(tmp, "provenance-bun.exe"); + mkdirSync(join(tmp), { recursive: true }); + writeFileSync(real, "x".repeat(2 * 1024 * 1024)); + process.env.OPENCODEX_BUN_PATH = real; + try { + // durableBunRuntime would say "override" here; the reporter must still say unknown. + expect(durableBunRuntime().source).toBe("override"); + expect(reportedBunRuntimeSource({})).toBeUndefined(); + } finally { + if (inherited === undefined) delete process.env.OPENCODEX_BUN_PATH; + else process.env.OPENCODEX_BUN_PATH = inherited; + } + }); +}); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index a1bdff3f4..3bb6abffc 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -60,6 +60,20 @@ describe("Codex autostart shim", () => { expect(script).toContain("OPENCODEX_API_AUTH_TOKEN"); }); + test("every shim flavor exports the Bun provenance it was built with (#848)", () => { + // The shim reaches the daemon through `ocx ensure`, which inherits this env; + // without it a Codex-autostarted service reports no provenance at all. + const unix = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts", "/tmp/token", "override"); + expect(unix).toContain("OCX_BUN_RUNTIME_SOURCE='override'"); + expect(unix).toContain("export OCX_BUN_RUNTIME_SOURCE"); + + expect(buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "override")) + .toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); + + expect(buildWindowsPowerShellCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "process")) + .toContain("$env:OCX_BUN_RUNTIME_SOURCE = 'process'"); + }); + test("builds a Windows shim that starts ocx before running Codex", () => { const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts"); @@ -115,7 +129,7 @@ describe("Codex autostart shim", () => { test("PowerShell shim is written with a UTF-8 BOM (Windows PowerShell 5.1 decodes BOM-less ps1 as ANSI)", async () => { const source = readFileSync(join(import.meta.dir, "..", "src", "codex", "shim.ts"), "utf8"); - expect(source).toContain("`\\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli)}`"); + expect(source).toContain("`\\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`"); }); test("Windows target discovery includes the extensionless Git-Bash launcher and writeShim emits a forward-slash sh shim for it", () => { @@ -123,7 +137,7 @@ describe("Codex autostart shim", () => { expect(source).toContain('const gitBashLauncher = join(dir, "codex");'); expect(source).toContain("for (const path of [cmd, ps1, gitBashLauncher])"); - expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath()))"); + expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath()), bunRuntimeSource)"); }); test("Unix shim accepts an injected token-file path (Git-Bash shims need forward slashes everywhere)", () => { diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index b6e1ff8e2..ff781972e 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -402,12 +402,41 @@ describe("service memory section (#314 WP4)", () => { }); test("guidance gating: win32 + auto-known-bad prints version-claiming guidance", () => { - const lines = formatServiceMemoryLines({ status: "ok", data: baseData }); + // A bundled runtime is the case where "set OPENCODEX_BUN_PATH" is still the right advice. + const lines = formatServiceMemoryLines({ status: "ok", data: { ...baseData, bunRuntimeSource: "bundled" } }); expect(lines.some(l => l.includes("OPENCODEX_BUN_PATH"))).toBe(true); // Version-claiming, never binary-claiming. expect(lines.join("\n")).not.toContain("bundled binary"); }); + test("guidance gating: an active override is never told to set OPENCODEX_BUN_PATH again (#848)", () => { + const lines = formatServiceMemoryLines({ + status: "ok", + data: { ...baseData, bunRuntimeSource: "override" }, + }); + const text = lines.join("\n"); + expect(text).toContain("OPENCODEX_BUN_PATH is already active"); + expect(text).not.toContain("set OPENCODEX_BUN_PATH to a runtime you trust"); + // The affected-version warning itself must survive; only the remedy changes. + expect(text).toContain("affected by the upstream Bun memory issue"); + }); + + test("guidance gating: a legacy payload without provenance says unknown instead of guessing", () => { + const { bunRuntimeSource: _omitted, ...legacy } = { ...baseData, bunRuntimeSource: undefined }; + const text = formatServiceMemoryLines({ status: "ok", data: legacy as ServiceMemoryData }).join("\n"); + expect(text).toContain("records no runtime origin"); + expect(text).not.toContain("set OPENCODEX_BUN_PATH to a runtime you trust"); + }); + + test("guidance gating: a process-provenance runtime is not described as bundled", () => { + const text = formatServiceMemoryLines({ + status: "ok", + data: { ...baseData, bunRuntimeSource: "process" }, + }).join("\n"); + expect(text).toContain("the runtime that launched it"); + expect(text).toContain("set OPENCODEX_BUN_PATH to a runtime you trust"); + }); + test("guidance gating: darwin auto-off or fixed Windows runtime prints no override guidance", () => { const darwin = formatServiceMemoryLines({ status: "ok", diff --git a/tests/memory-watchdog.test.ts b/tests/memory-watchdog.test.ts index ab2453c4a..88b8b08d5 100644 --- a/tests/memory-watchdog.test.ts +++ b/tests/memory-watchdog.test.ts @@ -261,6 +261,33 @@ describe("GET /api/system/memory", () => { expect(body.watchdog).toBeNull(); }); + test("serializes only an allowlisted Bun runtime provenance, omitting it otherwise (#848)", async () => { + const inherited = process.env.OCX_BUN_RUNTIME_SOURCE; + const read = async (): Promise<{ bunRuntimeSource?: unknown; bunRevision?: unknown }> => { + const req = new Request("http://127.0.0.1:10100/api/system/memory"); + const res = await handleManagementAPI(req, new URL(req.url), config()); + return await res!.json() as { bunRuntimeSource?: unknown; bunRevision?: unknown }; + }; + try { + for (const source of ["override", "bundled", "process"]) { + process.env.OCX_BUN_RUNTIME_SOURCE = source; + expect((await read()).bunRuntimeSource).toBe(source); + } + // An unset or unrecognized marker must leave the field absent rather than + // shipping a value doctor would then have to distrust. + delete process.env.OCX_BUN_RUNTIME_SOURCE; + const unset = await read(); + expect(unset.bunRuntimeSource).toBeUndefined(); + expect(typeof unset.bunRevision).toBe("string"); + + process.env.OCX_BUN_RUNTIME_SOURCE = "system"; + expect((await read()).bunRuntimeSource).toBeUndefined(); + } finally { + if (inherited === undefined) delete process.env.OCX_BUN_RUNTIME_SOURCE; + else process.env.OCX_BUN_RUNTIME_SOURCE = inherited; + } + }); + test("GET system memory includes privacy-safe appOwnedBytes scalars", async () => { registerDefaultAppOwnedMemoryStores(); const req = new Request("http://127.0.0.1:10100/api/system/memory"); diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index cad2a6e0a..7dde66488 100644 --- a/tests/ocx-launcher-source.test.ts +++ b/tests/ocx-launcher-source.test.ts @@ -43,7 +43,7 @@ describe("ocx.mjs npm launcher (source invariants)", () => { test("valid Bun overrides are selected before the bundled runtime", () => { expect(source).toContain('const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";'); expect(source).toContain("const overridePath = resolve(override);"); - expect(source).toContain("if (isRealBunBinary(overridePath)) return overridePath;"); + expect(source).toContain('if (isRealBunBinary(overridePath)) return { path: overridePath, source: "override" };'); const resolveStart = source.indexOf("function resolveBun() {"); const overrideCheck = source.indexOf("process.env[BUN_OVERRIDE_ENV]?.trim()", resolveStart); diff --git a/tests/service.test.ts b/tests/service.test.ts index 19bef607d..a2ebc5838 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -76,7 +76,7 @@ describe("service listen-port bake", () => { process.env.OPENCODEX_HOME = TEST_DIR; mkdirSync(TEST_DIR, { recursive: true }); saveConfig({ port: 13337, hostname: "127.0.0.1", defaultProvider: "openai", providers: {} } as OcxConfig); - const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\OpenCodex\\cli.ts" }); + const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", bunRuntimeSource: "bundled", cli: "C:\\OpenCodex\\cli.ts" }); expect(script).toContain("start --port 13337"); expect(buildPlist()).toContain("start --port 13337"); expect(buildUnit()).toContain("start --port 13337"); @@ -495,6 +495,7 @@ describe("Windows service task", () => { test("escapes service executable paths through variables", () => { const script = buildWindowsServiceScript({ bun: "C:\\Bun&Dir\\100%bun^\\bun.exe", + bunRuntimeSource: "bundled", cli: "C:\\OpenCodex&Dir\\cli.ts", }); @@ -505,7 +506,7 @@ describe("Windows service task", () => { }); test("switches the wrapper console to UTF-8 and sleeps via ping (timeout dies without console stdin)", () => { - const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\OpenCodex\\cli.ts" }); + const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", bunRuntimeSource: "bundled", cli: "C:\\OpenCodex\\cli.ts" }); expect(script).toContain("chcp 65001 >nul"); expect(script.indexOf("chcp 65001 >nul")).toBeLessThan(script.indexOf('set "OCX_SERVICE=1"')); @@ -521,6 +522,7 @@ describe("Windows service task", () => { process.env.APPDATA = "C:\\Users\\한글사용자\\AppData\\Roaming"; const script = buildWindowsServiceScript({ bun: "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\bun\\bin\\bun.exe", + bunRuntimeSource: "bundled", cli: "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\opencodex\\src\\cli.ts", }); @@ -545,6 +547,7 @@ describe("Windows service task", () => { process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", + bunRuntimeSource: "bundled", cli: "C:\\OpenCodex\\cli.ts", }); @@ -573,6 +576,41 @@ describe("Windows service task", () => { }); describe("launchd service plist", () => { + test("every durable launcher stamps the Bun provenance paired with the binary it baked (#848)", () => { + const inherited = process.env.OPENCODEX_BUN_PATH; + const overrideBun = join(TEST_DIR, "provenance-override-bun.exe"); + mkdirSync(TEST_DIR, { recursive: true }); + writeFileSync(overrideBun, "x".repeat(2 * 1024 * 1024)); + try { + // With a valid override active, every launcher must bake THAT binary and + // label it `override` — a marker that disagreed with the baked path would be + // worse than no marker at all. + process.env.OPENCODEX_BUN_PATH = overrideBun; + const plist = buildPlist(); + expect(plist).toContain("OCX_BUN_RUNTIME_SOURCEoverride"); + expectTextToContainPath(plist, overrideBun); + + const unit = buildUnit(); + expect(unit).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); + expectTextToContainPath(unit, overrideBun); + + const script = buildWindowsServiceScript(); + expect(script).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); + expect(script).toContain('echo bun_source="override"'); + + // No override: the same three fall back to the bundled/process runtime and say so. + delete process.env.OPENCODEX_BUN_PATH; + const bundledPlist = buildPlist(); + expect(bundledPlist).toMatch(/OCX_BUN_RUNTIME_SOURCE<\/key>(bundled|process)<\/string>/); + expect(bundledPlist).not.toContain(">override<"); + expect(buildUnit()).toMatch(/Environment="OCX_BUN_RUNTIME_SOURCE=(bundled|process)"/); + expect(buildWindowsServiceScript()).toMatch(/set "OCX_BUN_RUNTIME_SOURCE=(bundled|process)"/); + } finally { + if (inherited === undefined) delete process.env.OPENCODEX_BUN_PATH; + else process.env.OPENCODEX_BUN_PATH = inherited; + } + }); + test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; const oldOpenCodexHome = process.env.OPENCODEX_HOME; @@ -1333,7 +1371,7 @@ describe("service serving confirmation", () => { }); test("reads the port out of a real generated WinSW XML", () => { - const xml = buildWinswXml({ bun: "C:\\pkg\\bun.exe", cli: "C:\\pkg\\src\\cli\\index.ts" }); + const xml = buildWinswXml({ bun: "C:\\pkg\\bun.exe", bunRuntimeSource: "bundled", cli: "C:\\pkg\\src\\cli\\index.ts" }); expect(winswListenPort({ readXml: () => xml })).toBe(resolveServiceListenPort()); }); }); diff --git a/tests/windows-tray.test.ts b/tests/windows-tray.test.ts index 32d6e3002..09810a584 100644 --- a/tests/windows-tray.test.ts +++ b/tests/windows-tray.test.ts @@ -40,6 +40,7 @@ import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS } from "./helpers/test-budget"; const entry: WindowsTrayEntry = { bun: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\bun.exe", + bunRuntimeSource: "bundled", cli: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\src\\cli\\index.ts", script: "C:\\사용자 공간\\%TEMP% ! ^ ( ) & 검증\\src\\tray\\windows-tray.ps1", codexHome: "C:\\사용자 공간\\.codex", @@ -99,6 +100,20 @@ describe("Windows tray packaging and command safety", () => { expect(windowsTrayProcessArgs(entry, "Run", 4242)).toContain("4242"); }); + test("passes the Bun provenance through to the tray host (#848)", () => { + // The tray relaunches the proxy itself, so a tray-started service would otherwise + // reach doctor with no provenance and get the legacy/unknown treatment. + const args = windowsTrayProcessArgs(entry); + expect(args).toContain("-BunRuntimeSource"); + expect(args[args.indexOf("-BunRuntimeSource") + 1]).toBe("bundled"); + + const overrideCommand = buildWindowsTrayPowerShellCommand( + { ...entry, bunRuntimeSource: "override" }, + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + ); + expect(overrideCommand).toContain("-BunRuntimeSource override"); + }); + test("quotes metacharacter and Unicode paths without shell interpolation", () => { const powershellCommand = buildWindowsTrayPowerShellCommand(entry, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); expect(powershellCommand).toContain(`-File "${entry.script}"`); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index eb42df8ee..972460a6e 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -7,7 +7,7 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const entry = { bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\Open Codex\\cli & co\\index.ts" }; +const entry = { bun: "C:\\OpenCodex\\bun.exe", bunRuntimeSource: "bundled" as const, cli: "C:\\Open Codex\\cli & co\\index.ts" }; function winswEnvValue(xml: string, name: string): string | null { const match = xml.match(new RegExp(``)); @@ -47,6 +47,15 @@ describe("winsw xml", () => { expect(xml).not.toContain("OPENCODEX_ADMIN_AUTH_TOKEN"); }); + test("carries the Bun provenance paired with the executable it baked (#848)", () => { + expect(buildWinswXml(entry, env)).toContain(''); + // The marker follows the entry, so an override-baked service says override. + const overrideEntry = { ...entry, bun: "C:\\Custom\\bun.exe", bunRuntimeSource: "override" as const }; + const overrideXml = buildWinswXml(overrideEntry, env); + expect(overrideXml).toContain(''); + expect(overrideXml).toContain("C:\\Custom\\bun.exe"); + }); + test("bakes install-time ACL timeout and never embeds the admin token (#764)", () => { const xml = buildWinswXml(entry, { ...env, From 5f5711035f7cdf368121abc6f773fe4d715c1294 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 2 Aug 2026 19:55:40 +0900 Subject: [PATCH 3/5] fix(doctor): carry Bun provenance through execPath relaunches too Review found the marker stopped at the launchers that resolve a binary. The ones that re-exec process.execPath -- ocx ensure, GUI/Claude/OpenCode start, POST /api/system/restart, the update relaunch -- copied the parent environment and handed the daemon nothing, so a service the launcher knew the origin of still reported unknown. withProcessRuntimeProvenance() covers those seven sites. Re-execing the current runtime does not change how that runtime was obtained, so an inherited marker is preserved and only its absence records 'process'; an unrecognized inherited value is replaced rather than forwarded. The shim builders no longer default provenance to 'bundled'. A default let a caller pass an override binary and label it something else, which is the path/marker disagreement the marker exists to prevent, so the argument is now required. Regressions: the launch sites are pinned so a future launcher that copies process.env cannot silently drop the marker again, and the npm launcher's transport is asserted inside the spawn env rather than inferred from the resolver's return shape. Reverting bin/ocx.mjs to its pre-provenance state fails that test. structure/05: the provenance section had been inserted between the sidebar stop-button heading and its own paragraph; restored. --- src/cli/claude.ts | 3 +- src/cli/index.ts | 7 ++-- src/cli/opencode.ts | 3 +- src/codex/shim.ts | 13 ++++--- src/lib/bun-runtime.ts | 17 +++++++++ src/server/management/system-restart.ts | 3 +- src/update/index.ts | 3 +- structure/05_gui-and-management-api.md | 13 +++++-- tests/bun-runtime.test.ts | 50 ++++++++++++++++++++++++- tests/codex-shim.test.ts | 31 ++++++++------- tests/ocx-launcher-source.test.ts | 22 +++++++++++ 11 files changed, 133 insertions(+), 32 deletions(-) diff --git a/src/cli/claude.ts b/src/cli/claude.ts index d4f8270e6..02d6751d2 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -17,6 +17,7 @@ import type { OcxConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect"; import { resolveClaudeAuthMode } from "../claude/auth-mode"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; export interface ClaudeLaunchEnv { [key: string]: string | undefined; @@ -205,7 +206,7 @@ async function ensureProxyForClaude(): Promise { detached: true, stdio: "ignore", windowsHide: true, - env: { ...process.env, OCX_SERVICE: "1" }, + env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), }); child.unref(); const deadline = Date.now() + 8_000; diff --git a/src/cli/index.ts b/src/cli/index.ts index 9bb7654b5..a1eba243c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -44,6 +44,7 @@ import { syncModelsToCodex } from "../codex/sync"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; import { collectOrcaCodexHomeDiagnostic } from "../codex/home"; import { removeOwnedConfigState } from "../lib/config-ownership"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; const args = process.argv.slice(2); const command = args[0]; @@ -385,7 +386,7 @@ async function handleEnsure() { detached: true, stdio: "ignore", windowsHide: true, - env: { ...process.env, OCX_SERVICE: "1" }, + env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), }); child.unref(); @@ -427,7 +428,7 @@ async function handleTrayProxyStart(): Promise { detached: true, stdio: "ignore", windowsHide: true, - env: { ...process.env, OCX_SERVICE: "1" }, + env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), }); child.unref(); }, @@ -867,7 +868,7 @@ switch (command) { detached: true, stdio: "ignore", windowsHide: true, - env: process.env, + env: withProcessRuntimeProvenance(process.env), }); child.unref(); live = await waitForProxy(); diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 60682d428..efd1eca6a 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -40,6 +40,7 @@ import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/servic import { providerCodexAccountMode } from "../providers/registry"; import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness"; import type { OcxConfig } from "../types"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; /** * The provider-block serializer, its constants, and the config-path helpers now live in @@ -497,7 +498,7 @@ async function ensureProxyForOpencode(config: OcxConfig): Promise `if /I "%~1"=="${command}" goto run_codex`).join("\r\n"); const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n"); return `@echo off\r @@ -478,7 +481,7 @@ function psString(value: string): string { return `'${value.replace(/'/g, "''")}'`; } -export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource = "bundled"): string { +export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource): string { const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", "); const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", "); const tokenFile = serviceApiTokenFilePath(); @@ -621,12 +624,12 @@ function writeShim(wrapperPath: string, realCodexPath: string): void { // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths. writeFileSync( wrapperPath, - buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath()), bunRuntimeSource), + buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())), "utf8", ); } } else { - writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli, serviceApiTokenFilePath(), bunRuntimeSource), "utf8"); + writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8"); chmodSync(wrapperPath, 0o755); } } diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index b4396fe29..3545360d7 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -58,6 +58,23 @@ export function reportedBunRuntimeSource( return BUN_RUNTIME_SOURCES.find(source => source === raw); } +/** + * Child environment for a proxy started with `process.execPath` — the runtime this + * process is already using. + * + * These launchers re-exec the current runtime rather than resolving a binary, so the + * provenance they should report is whatever launched THIS process. An inherited marker + * is therefore still accurate and is preserved; only when there is none does the + * executable's own origin (`process`) get recorded. Without this the marker would be + * silently dropped on `ocx ensure`, GUI start, restart, and update-relaunch, and the + * service would report an unknown origin it actually knows. + */ +export function withProcessRuntimeProvenance( + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + return { ...env, [BUN_RUNTIME_SOURCE_ENV]: reportedBunRuntimeSource(env) ?? "process" }; +} + /** * Absolute path to the bundled Bun binary, or null if the `bun` dependency is * not installed/resolvable (or only the un-downloaded placeholder is present). diff --git a/src/server/management/system-restart.ts b/src/server/management/system-restart.ts index d16e0b71a..b7c57dd82 100644 --- a/src/server/management/system-restart.ts +++ b/src/server/management/system-restart.ts @@ -32,6 +32,7 @@ import { } from "../lifecycle"; import { isServiceViable } from "../../service"; import { readRuntimePort } from "../../config"; +import { withProcessRuntimeProvenance } from "../../lib/bun-runtime"; /** Fixed v1 drain window for the memory-card action (not config-driven). */ export const MEMORY_DRAIN_RESTART_MS = 60_000; @@ -98,7 +99,7 @@ function spawnDetachedStart(port?: number): Promise { detached: true, stdio: "ignore", windowsHide: true, - env: { ...process.env, OCX_SERVICE: "1" }, + env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }), }); } catch (err) { reject(err); diff --git a/src/update/index.ts b/src/update/index.ts index 670efbc7a..0c38fcbd8 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config"; import { npmInvocation } from "./npm-invocation.mjs"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; /** * A `codex-history-backup-*.json` surviving a stop means the native-history restore was @@ -364,7 +365,7 @@ export async function runUpdate(): Promise { detached: true, stdio: "ignore", windowsHide: true, - env, + env: withProcessRuntimeProvenance(env), }); child.unref(); console.log(`✅ Proxy starting on port ${capturedListen.port}.`); diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 011f40d49..0ae0f3539 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -108,6 +108,10 @@ identity, active selection, and routing never consult these fields. The matching ## Sidebar stop button +The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a +confirmation prompt, then fires the request and accepts the connection drop (the proxy exits). The +endpoint restores native Codex config, stops any installed service to prevent respawn, and exits. + ## Bun runtime provenance `GET /api/system/memory` may report `bunRuntimeSource` — one of `override`, `bundled`, or @@ -119,6 +123,11 @@ wrapper, the native WinSW service, launchd, systemd, the Codex autostart shim, a tray host. Provenance and path come from a single `durableBunRuntime()` resolution at each of those sites, so the marker can never describe a different binary than the one actually baked. +Launchers that re-exec `process.execPath` instead of resolving a binary — `ocx ensure`, GUI/Claude/ +OpenCode start, `POST /api/system/restart`, and the update relaunch — go through +`withProcessRuntimeProvenance()`. Re-execing the current runtime does not change how that runtime +was obtained, so an inherited marker is preserved and only its absence records `process`. + **Trust rule: a reporting surface must never resolve provenance for itself.** Calling `durableBunRuntime()` at report time answers "what would this process pick right now", which is a different question from "what was the service started with" — and the two diverge exactly when @@ -136,10 +145,6 @@ unrecognized wire value is treated as absent rather than passed through. the eager-relay decision: the conservative `auto-known-bad` result for canary and otherwise unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). -The dashboard sidebar includes a stop button that calls `POST /api/stop`. The button shows a -confirmation prompt, then fires the request and accepts the connection drop (the proxy exits). The -endpoint restores native Codex config, stops any installed service to prevent respawn, and exits. - ## Startup safety **Startup safety** is reachable by route (`/#startup`) and rendered by the app, but it is not a diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index 8e4114ae7..c5c68748c 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, afterAll } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource } from "../src/lib/bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; // realpath the temp root: on macOS /var is a symlink to /private/var, so a path built // from mkdtemp compares unequal to the same path resolved through process.cwd(). @@ -148,3 +148,49 @@ describe("reportedBunRuntimeSource (#848 launch-time provenance)", () => { } }); }); + +describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { + it("records `process` when the relaunching parent carries no marker", () => { + // `ocx ensure`, GUI start, restart, and update-relaunch all re-exec + // process.execPath. Without this they would hand the daemon no provenance at + // all, and doctor would report unknown for an origin the launcher knew. + expect(withProcessRuntimeProvenance({})[BUN_RUNTIME_SOURCE_ENV]).toBe("process"); + }); + + it("preserves an inherited marker instead of relabeling the same runtime", () => { + // Re-execing the current runtime does not change how that runtime was obtained, + // so an override started by the npm launcher stays `override` across a restart. + for (const source of ["override", "bundled", "process"] as const) { + expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: source })[BUN_RUNTIME_SOURCE_ENV]).toBe(source); + } + }); + + it("replaces an unrecognized inherited value rather than forwarding it", () => { + expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "system" })[BUN_RUNTIME_SOURCE_ENV]).toBe("process"); + }); + + it("leaves every other variable untouched", () => { + const result = withProcessRuntimeProvenance({ OCX_SERVICE: "1", PATH: "/usr/bin" }); + expect(result.OCX_SERVICE).toBe("1"); + expect(result.PATH).toBe("/usr/bin"); + }); + + it("is applied by every detached proxy launcher that re-execs process.execPath", () => { + // A launcher added later that copies process.env directly would silently drop + // provenance again, so the launch sites are pinned here rather than left to review. + const launchers = [ + "src/cli/index.ts", + "src/cli/claude.ts", + "src/cli/opencode.ts", + "src/server/management/system-restart.ts", + "src/update/index.ts", + ]; + for (const relative of launchers) { + const text = readFileSync(join(import.meta.dir, "..", relative), "utf8"); + const spawnCount = (text.match(/spawn\(process\.execPath/g) ?? []).length; + const stampCount = (text.match(/env: withProcessRuntimeProvenance\(/g) ?? []).length; + expect(spawnCount).toBeGreaterThan(0); + expect(stampCount).toBe(spawnCount); + } + }); +}); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 3bb6abffc..b1b78cd4d 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -51,7 +51,7 @@ function withInstalledShim(run: (paths: { describe("Codex autostart shim", () => { test("builds a Unix shim that starts ocx before execing Codex", () => { - const script = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts"); + const script = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts", "bundled"); expect(script).toContain(SHIM_MARKER); expect(script).toContain("ensure"); @@ -63,7 +63,7 @@ describe("Codex autostart shim", () => { test("every shim flavor exports the Bun provenance it was built with (#848)", () => { // The shim reaches the daemon through `ocx ensure`, which inherits this env; // without it a Codex-autostarted service reports no provenance at all. - const unix = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts", "/tmp/token", "override"); + const unix = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts", "override", "/tmp/token"); expect(unix).toContain("OCX_BUN_RUNTIME_SOURCE='override'"); expect(unix).toContain("export OCX_BUN_RUNTIME_SOURCE"); @@ -75,7 +75,7 @@ describe("Codex autostart shim", () => { }); test("builds a Windows shim that starts ocx before running Codex", () => { - const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts"); + const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "bundled"); expect(script).toContain(SHIM_MARKER); expect(script).toContain("ensure"); @@ -91,6 +91,7 @@ describe("Codex autostart shim", () => { "C:\\Tools&A\\100%codex^\\codex-real.exe", "C:\\Bun&Dir\\100%bun^\\bun.exe", "C:\\ocx&Dir\\cli.ts", + "bundled", ); expect(script).toContain('set "OCX_REAL_CODEX=C:\\Tools&A\\100%%codex^^\\codex-real.exe"'); @@ -111,6 +112,7 @@ describe("Codex autostart shim", () => { "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\codex.opencodex-real.cmd", "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\bun\\bin\\bun.exe", "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\opencodex\\src\\cli.ts", + "bundled", ); expect(script).toContain('set "OCX_REAL_CODEX=%APPDATA%\\npm\\codex.opencodex-real.cmd"'); @@ -137,7 +139,7 @@ describe("Codex autostart shim", () => { expect(source).toContain('const gitBashLauncher = join(dir, "codex");'); expect(source).toContain("for (const path of [cmd, ps1, gitBashLauncher])"); - expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath()), bunRuntimeSource)"); + expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath()))"); }); test("Unix shim accepts an injected token-file path (Git-Bash shims need forward slashes everywhere)", () => { @@ -145,6 +147,7 @@ describe("Codex autostart shim", () => { "C:/Users/한글사용자/AppData/Roaming/npm/codex.opencodex-real", "C:/Users/한글사용자/AppData/Roaming/npm/node_modules/bun/bin/bun.exe", "C:/Users/한글사용자/AppData/Roaming/npm/node_modules/opencodex/src/cli.ts", + "bundled", "C:/Users/한글사용자/.opencodex/service-api-token", ); @@ -154,8 +157,8 @@ describe("Codex autostart shim", () => { }); test("shim builder output contains the marker that isShim() checks", () => { - const unix = buildUnixCodexShim("/bin/codex", "/bin/bun", "/cli.ts"); - const win = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts"); + const unix = buildUnixCodexShim("/bin/codex", "/bin/bun", "/cli.ts", "bundled"); + const win = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts", "bundled"); const dir = mkdtempSync(join(tmpdir(), "ocx-shim-test-")); const unixPath = join(dir, "codex-shim"); @@ -177,17 +180,17 @@ describe("Codex autostart shim", () => { }); test("Unix shim uses bypass env var to skip proxy start", () => { - const script = buildUnixCodexShim("/bin/codex", "/bin/bun", "/cli.ts"); + const script = buildUnixCodexShim("/bin/codex", "/bin/bun", "/cli.ts", "bundled"); expect(script).toContain("OCX_SHIM_BYPASS"); }); test("Windows shim uses bypass env var to skip proxy start", () => { - const script = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts"); + const script = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts", "bundled"); expect(script).toContain("OCX_SHIM_BYPASS"); }); test("PowerShell shim uses bypass env var to skip proxy start", () => { - const script = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts"); + const script = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts", "bundled"); expect(script).toContain("OCX_SHIM_BYPASS"); expect(script).toContain("Test-Path -LiteralPath"); expect(script).toContain("OPENCODEX_API_AUTH_TOKEN"); @@ -206,7 +209,7 @@ describe("Codex autostart shim", () => { writeFileSync(bunPath, `#!/usr/bin/env sh\necho "bun:$*" >> "${logPath}"\n`, "utf8"); writeFileSync(realCodexPath, `#!/usr/bin/env sh\necho "codex:$*" >> "${logPath}"\n`, "utf8"); - writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, cliPath), "utf8"); + writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, cliPath, "bundled"), "utf8"); chmodSync(bunPath, 0o755); chmodSync(realCodexPath, 0o755); chmodSync(shimPath, 0o755); @@ -236,7 +239,7 @@ describe("Codex autostart shim", () => { writeFileSync(join(dir, "service-api-token"), "local-secret\n", "utf8"); writeFileSync(bunPath, `#!/usr/bin/env sh\nexit 0\n`, "utf8"); writeFileSync(realCodexPath, `#!/usr/bin/env sh\necho "token:$OPENCODEX_API_AUTH_TOKEN" >> "${logPath}"\n`, "utf8"); - writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/opt/opencodex/src/cli.ts"), "utf8"); + writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/opt/opencodex/src/cli.ts", "bundled"), "utf8"); chmodSync(bunPath, 0o755); chmodSync(realCodexPath, 0o755); chmodSync(shimPath, 0o755); @@ -264,7 +267,7 @@ describe("Codex autostart shim", () => { writeFileSync(bunPath, `#!/usr/bin/env sh\necho "bun:$*" >> "${logPath}"\n`, "utf8"); writeFileSync(realCodexPath, `#!/usr/bin/env sh\necho "codex:$*" >> "${logPath}"\n`, "utf8"); - writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/opt/opencodex/src/cli.ts"), "utf8"); + writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/opt/opencodex/src/cli.ts", "bundled"), "utf8"); chmodSync(bunPath, 0o755); chmodSync(realCodexPath, 0o755); chmodSync(shimPath, 0o755); @@ -299,7 +302,7 @@ describe("Codex autostart shim", () => { }); test("Windows shim skips ocx startup only for Codex management commands", () => { - const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts"); + const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "bundled"); expect(script).toContain(':scan_codex_args'); expect(script).toContain('if /I "%~1"=="-s" goto skip_option_value'); @@ -314,7 +317,7 @@ describe("Codex autostart shim", () => { }); test("PowerShell shim scans past value-taking global options", () => { - const script = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts"); + const script = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts", "bundled"); expect(script).toContain("$valueOptions = @("); expect(script).toContain("'-s'"); diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index 7dde66488..15f5f9a61 100644 --- a/tests/ocx-launcher-source.test.ts +++ b/tests/ocx-launcher-source.test.ts @@ -14,6 +14,28 @@ const validatorSource = readFileSync( ); describe("ocx.mjs npm launcher (source invariants)", () => { + 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 + // carry the source resolved alongside the chosen binary rather than a literal. + expect(source).toContain('const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";'); + expect(source).toContain("[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source,"); + + // The stamp must sit inside the spawn's env object, not merely somewhere in the file. + const spawnStart = source.indexOf("const child = spawn(bun, [cliPath"); + expect(spawnStart).toBeGreaterThanOrEqual(0); + const spawnCall = source.slice(spawnStart, source.indexOf("});", spawnStart)); + expect(spawnCall).toContain("[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source"); + + // Path and source come from one resolution, so the marker cannot describe another binary. + expect(source).toContain("const bunRuntime = resolveBun();"); + expect(source).toContain("const bun = bunRuntime.path;"); + expect(source).toContain('return { path: bin, source: "bundled" };'); + + // The launcher's literal name must match the TypeScript constant it mirrors. + expect(runtimeSource).toContain('export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";'); + }); + test("Windows npm spawns use the trusted absolute invocation without shell lookup", () => { expect(source).toContain("const latestInvocation = npmInvocation("); expect(source).toContain("const installInvocation = npmInvocation("); From c0840d2b029a25d6f16cac6befb6676180e8bdca Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 2 Aug 2026 20:04:20 +0900 Subject: [PATCH 4/5] fix(codex): scope the Bun provenance marker to the ensure call Review caught the marker leaking. A shim wraps the real codex, so exporting the marker into the shim's own environment handed it to Codex and everything Codex spawns. A shell beneath that running a different Bun directly would carry a provenance describing a binary it was not executing -- and the execPath relaunch paths would faithfully preserve that contradiction into the daemon. Every flavor now scopes it to the ensure invocation: a one-shot assignment prefix in sh, a setlocal/endlocal pair in cmd, and save/restore in PowerShell. Nothing downstream of the shim inherits it. Inheritance is also no longer trusted on its own. withProcessRuntimeProvenance carries a claim forward only when re-resolving it still lands on process.execPath; a stale marker from an ancestor falls back to what this executable actually is. That keeps a genuine restart labelled correctly without letting a marker outlive the binary it was minted for. Also fixes an old-signature caller in tests/openai-provider-option-tooling that passed the token path where provenance now goes -- it wrote a filesystem path into the marker and silently used the default token file, so its sentinel assertion was partly vacuous. --- src/codex/shim.ts | 23 ++++++++--- src/lib/bun-runtime.ts | 43 +++++++++++++++++--- tests/bun-runtime.test.ts | 26 ++++++++++-- tests/codex-shim.test.ts | 30 +++++++++++++- tests/openai-provider-option-tooling.test.ts | 2 +- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 84b33356e..f289d3524 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -372,13 +372,17 @@ function shQuote(value: string): string { // Provenance is required rather than defaulted: a default would let a caller pass an // override binary and silently label it something else, which is precisely the // path/marker disagreement this feature exists to prevent. +// +// The marker is scoped to the `ensure` invocation in every flavor below and is never +// exported into the shim's own environment. A shim wraps the real `codex`, so an +// exported marker would be inherited by Codex and everything it spawns — a shell that +// then ran a *different* Bun directly would carry a provenance describing a binary it +// is not executing. export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource, tokenFile = serviceApiTokenFilePath()): string { const internalCommands = CODEX_INTERNAL_COMMANDS.join("|"); const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|"); return `#!/usr/bin/env sh # ${SHIM_MARKER} -${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} -export ${BUN_RUNTIME_SOURCE_ENV} if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})" export OPENCODEX_API_AUTH_TOKEN @@ -414,7 +418,7 @@ case "$ocx_subcommand" in ;; *) if [ -z "$OCX_SHIM_BYPASS" ]; then - ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true + ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true fi ;; esac @@ -446,7 +450,6 @@ export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cl rem ${SHIM_MARKER}\r ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r -${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r ${windowsBatchSet("OCX_CLI", cliPath)}\r ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r @@ -471,7 +474,10 @@ if "%~1"=="" goto ensure_ocx\r shift\r goto scan_codex_args\r :ensure_ocx\r +setlocal\r +${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r +endlocal\r :run_codex\r "%OCX_REAL_CODEX%" %*\r `; @@ -487,7 +493,6 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: const tokenFile = serviceApiTokenFilePath(); return `#!/usr/bin/env pwsh # ${SHIM_MARKER} -$env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)} if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) { $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim() } @@ -507,7 +512,13 @@ foreach ($argValue in $args) { } $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $subcommand -or @("--help", "-h", "--version", "-V") -contains $subcommand if (-not $skipEnsure) { - & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null + $priorRuntimeSource = $env:${BUN_RUNTIME_SOURCE_ENV} + $env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)} + try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null } + finally { + if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue } + else { $env:${BUN_RUNTIME_SOURCE_ENV} = $priorRuntimeSource } + } } & ${psString(realCodexPath)} @args exit $LASTEXITCODE diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index 3545360d7..576fa23fc 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -63,16 +63,49 @@ export function reportedBunRuntimeSource( * process is already using. * * These launchers re-exec the current runtime rather than resolving a binary, so the - * provenance they should report is whatever launched THIS process. An inherited marker - * is therefore still accurate and is preserved; only when there is none does the - * executable's own origin (`process`) get recorded. Without this the marker would be - * silently dropped on `ocx ensure`, GUI start, restart, and update-relaunch, and the + * provenance to report is whatever launched THIS process. Without this the marker would + * be silently dropped on `ocx ensure`, GUI start, restart, and update-relaunch, and the * service would report an unknown origin it actually knows. + * + * An inherited marker is only carried forward when it still DESCRIBES the executable + * about to be re-executed. Inheritance travels down a process tree, so a marker can + * outlive the binary it was minted for — something started under a marked process but + * running a different Bun would otherwise relaunch the daemon with a provenance + * contradicting the binary actually serving it. When the claim does not match + * `process.execPath`, the honest answer is this executable's own origin. */ export function withProcessRuntimeProvenance( env: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { - return { ...env, [BUN_RUNTIME_SOURCE_ENV]: reportedBunRuntimeSource(env) ?? "process" }; + return { ...env, [BUN_RUNTIME_SOURCE_ENV]: currentRuntimeProvenance(env) }; +} + +/** + * Provenance for `process.execPath`: the inherited claim when it is corroborated by + * re-resolving that source, otherwise what this executable actually is. + */ +function currentRuntimeProvenance(env: NodeJS.ProcessEnv): BunRuntimeSource { + const claimed = reportedBunRuntimeSource(env); + if (claimed && samePath(resolvedPathForSource(claimed, env), process.execPath)) return claimed; + // No trustworthy claim: report what is running, re-deriving it rather than guessing. + return durableBunRuntime().path === process.execPath ? durableBunRuntime().source : "process"; +} + +function resolvedPathForSource(source: BunRuntimeSource, env: NodeJS.ProcessEnv): string | null { + if (source === "process") return process.execPath; + if (source === "override") { + const value = env[BUN_OVERRIDE_ENV]?.trim(); + return value ? resolve(value) : null; + } + return bundledBunPath(); +} + +/** Windows paths are case-insensitive; everything else compares exactly. */ +function samePath(left: string | null, right: string): boolean { + if (!left) return false; + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; } /** diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index c5c68748c..469a8c1f5 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -159,9 +159,29 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { it("preserves an inherited marker instead of relabeling the same runtime", () => { // Re-execing the current runtime does not change how that runtime was obtained, - // so an override started by the npm launcher stays `override` across a restart. - for (const source of ["override", "bundled", "process"] as const) { - expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: source })[BUN_RUNTIME_SOURCE_ENV]).toBe(source); + // but the claim is only carried forward when it still describes process.execPath. + const overrideEnv = { + [BUN_RUNTIME_SOURCE_ENV]: "override", + OPENCODEX_BUN_PATH: process.execPath, + }; + expect(withProcessRuntimeProvenance(overrideEnv)[BUN_RUNTIME_SOURCE_ENV]).toBe("override"); + expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "process" })[BUN_RUNTIME_SOURCE_ENV]).toBe("process"); + }); + + it("drops an inherited marker that no longer describes the running binary", () => { + // Inheritance travels down a process tree, so a marker can outlive the binary it + // was minted for: something launched under a marked process but running a + // different Bun must not relaunch the daemon claiming that other binary's origin. + const staleOverride = { + [BUN_RUNTIME_SOURCE_ENV]: "override", + OPENCODEX_BUN_PATH: join(tmp, "some-other-bun.exe"), + }; + expect(withProcessRuntimeProvenance(staleOverride)[BUN_RUNTIME_SOURCE_ENV]).not.toBe("override"); + + // Same for a `bundled` claim while the bundled path is not what is executing. + const bundled = bundledBunPath(); + if (bundled && bundled !== process.execPath) { + expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "bundled" })[BUN_RUNTIME_SOURCE_ENV]).not.toBe("bundled"); } }); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index b1b78cd4d..b664664f8 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -64,8 +64,7 @@ describe("Codex autostart shim", () => { // The shim reaches the daemon through `ocx ensure`, which inherits this env; // without it a Codex-autostarted service reports no provenance at all. const unix = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts", "override", "/tmp/token"); - expect(unix).toContain("OCX_BUN_RUNTIME_SOURCE='override'"); - expect(unix).toContain("export OCX_BUN_RUNTIME_SOURCE"); + expect(unix).toContain("OCX_BUN_RUNTIME_SOURCE='override' '/usr/local/bin/bun' '/opt/opencodex/src/cli.ts' ensure"); expect(buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "override")) .toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); @@ -74,6 +73,33 @@ describe("Codex autostart shim", () => { .toContain("$env:OCX_BUN_RUNTIME_SOURCE = 'process'"); }); + test("the provenance marker never leaks into the real Codex process (#848 scoping)", () => { + // A shim wraps `codex` itself, so an exported marker would be inherited by Codex + // and everything it spawns. A shell beneath it running a DIFFERENT Bun directly + // would then carry provenance describing a binary it is not executing, and the + // execPath relaunch paths would preserve that contradiction into the daemon. + const unix = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/cli.ts", "override"); + expect(unix).not.toContain("export OCX_BUN_RUNTIME_SOURCE"); + // The only occurrence is the one-shot assignment prefixed onto `ensure`. + expect((unix.match(/OCX_BUN_RUNTIME_SOURCE/g) ?? []).length).toBe(1); + expect(unix.indexOf("OCX_BUN_RUNTIME_SOURCE")).toBeGreaterThan(unix.indexOf("ocx_subcommand")); + + // cmd.exe: set inside a setlocal/endlocal pair around `ensure` only. + const cmd = buildWindowsCodexShim("C:\\codex-real.exe", "C:\\bun.exe", "C:\\cli.ts", "override"); + const ensureBlock = cmd.slice(cmd.indexOf(":ensure_ocx"), cmd.indexOf(":run_codex")); + expect(ensureBlock).toContain("setlocal"); + expect(ensureBlock).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); + expect(ensureBlock).toContain("endlocal"); + expect((cmd.match(/OCX_BUN_RUNTIME_SOURCE/g) ?? []).length).toBe(1); + + // PowerShell: assigned around the ensure call and restored/removed afterwards. + const ps = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts", "override"); + expect(ps).toContain("$priorRuntimeSource = $env:OCX_BUN_RUNTIME_SOURCE"); + expect(ps).toContain("Remove-Item Env:\\OCX_BUN_RUNTIME_SOURCE"); + expect(ps).toContain("$env:OCX_BUN_RUNTIME_SOURCE = $priorRuntimeSource"); + expect(ps.indexOf("OCX_BUN_RUNTIME_SOURCE")).toBeGreaterThan(ps.indexOf("$skipEnsure")); + }); + test("builds a Windows shim that starts ocx before running Codex", () => { const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "bundled"); diff --git a/tests/openai-provider-option-tooling.test.ts b/tests/openai-provider-option-tooling.test.ts index 06c5cc26e..d01cef954 100644 --- a/tests/openai-provider-option-tooling.test.ts +++ b/tests/openai-provider-option-tooling.test.ts @@ -251,7 +251,7 @@ describe("OpenAI provider-option live policy and runtime isolation", () => { const shim = join(root, "codex"); writeFileSync(tokenFile, "real-state-sentinel\n", { mode: 0o600 }); writeFileSync(realCodex, "#!/bin/sh\nprintf '%s\\n' \"$OPENCODEX_API_AUTH_TOKEN\"\n", { mode: 0o700 }); - writeFileSync(shim, buildUnixCodexShim(realCodex, process.execPath, "/fixture/cli.ts", tokenFile), { mode: 0o700 }); + writeFileSync(shim, buildUnixCodexShim(realCodex, process.execPath, "/fixture/cli.ts", "bundled", tokenFile), { mode: 0o700 }); chmodSync(realCodex, 0o700); chmodSync(shim, 0o700); From 4a3bbbe45e7c366524ff8dda26c4e8f597acd79e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 2 Aug 2026 20:15:54 +0900 Subject: [PATCH 5/5] fix(runtime): record which binary the provenance marker describes The corroboration added last round asked the wrong question. It re-derived the original selection to decide whether an inherited marker was still true, but a service installed with a shell-local override keeps neither that shell nor its OPENCODEX_BUN_PATH -- so a correct 'override' was demoted to 'process' on the service's first relaunch, and doctor could again suggest setting an override that was already in use. The marker now carries the binary it was minted for. OCX_BUN_RUNTIME_PATH is stamped beside the source at every launcher, and a relaunch keeps the claim when that recorded path is the executable about to run. No re-derivation, so a launcher's own environment no longer has to survive for its provenance to. Comparison goes through realpath, so symlinks, junctions, and Windows case differences no longer reject a valid match. The fallback resolves path and source from one durableBunRuntime() call rather than two, which was a small window where the pair could disagree. Full suite 7030 pass / 0 fail; bun-stream-caps.ts and responses/core.ts remain absent from every commit in this unit. --- bin/ocx.mjs | 2 + src/codex/shim.ts | 9 +++- src/lib/bun-runtime.ts | 73 ++++++++++++++++---------- src/lib/winsw.ts | 3 +- src/service.ts | 5 +- src/tray/windows-tray.ps1 | 7 ++- structure/05_gui-and-management-api.md | 26 ++++++--- tests/bun-runtime.test.ts | 36 ++++++++----- tests/codex-shim.test.ts | 6 ++- 9 files changed, 114 insertions(+), 53 deletions(-) diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6e7f2b448..c4fd07680 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -321,6 +321,7 @@ const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; // Node and runs before any TypeScript is loaded, so the name is repeated rather than // imported; tests/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"; function findBunBinary(bunDir) { // The npm `bun` package ships the binary as bin/bun.exe on every platform; @@ -423,6 +424,7 @@ const child = spawn(bun, [cliPath, ...process.argv.slice(2)], { ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(","), [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, + [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, }, }); diff --git a/src/codex/shim.ts b/src/codex/shim.ts index f289d3524..c6a995655 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -19,7 +19,7 @@ import { writeFileSync, } from "node:fs"; import { getConfigDir } from "../config"; -import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "../lib/bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; import { isProcessAlive } from "../lib/process-control"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; @@ -418,7 +418,7 @@ case "$ocx_subcommand" in ;; *) if [ -z "$OCX_SHIM_BYPASS" ]; then - ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true + ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true fi ;; esac @@ -476,6 +476,7 @@ goto scan_codex_args\r :ensure_ocx\r setlocal\r ${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r +${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r endlocal\r :run_codex\r @@ -513,11 +514,15 @@ foreach ($argValue in $args) { $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $subcommand -or @("--help", "-h", "--version", "-V") -contains $subcommand if (-not $skipEnsure) { $priorRuntimeSource = $env:${BUN_RUNTIME_SOURCE_ENV} + $priorRuntimePath = $env:${BUN_RUNTIME_PATH_ENV} $env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)} + $env:${BUN_RUNTIME_PATH_ENV} = ${psString(bunPath)} try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null } finally { if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue } else { $env:${BUN_RUNTIME_SOURCE_ENV} = $priorRuntimeSource } + if ($null -eq $priorRuntimePath) { Remove-Item Env:\\${BUN_RUNTIME_PATH_ENV} -ErrorAction SilentlyContinue } + else { $env:${BUN_RUNTIME_PATH_ENV} = $priorRuntimePath } } } & ${psString(realCodexPath)} @args diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index 576fa23fc..13b1ca87b 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -11,6 +11,7 @@ * back to `process.execPath` (which is itself Bun when run via `bun src/cli/index.ts`). */ import { createRequire } from "node:module"; +import { realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { isRealBunBinary } from "./bun-binary-validator.mjs"; @@ -30,6 +31,13 @@ const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; */ export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; +/** + * The binary the marker was minted for. Stamped beside the source so a reader can tell + * whether a marker still describes the process holding it, without re-deriving the + * selection from an environment that may no longer contain it. + */ +export const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; + export type BunRuntimeSource = "override" | "bundled" | "process"; /** The only provenance values any surface may accept off the wire or out of the env. */ @@ -58,6 +66,11 @@ export function reportedBunRuntimeSource( return BUN_RUNTIME_SOURCES.find(source => source === raw); } +/** Env pair a launcher stamps for the binary it just selected. */ +export function bunRuntimeProvenanceEnv(runtime: DurableBunRuntime): Record { + return { [BUN_RUNTIME_SOURCE_ENV]: runtime.source, [BUN_RUNTIME_PATH_ENV]: runtime.path }; +} + /** * Child environment for a proxy started with `process.execPath` — the runtime this * process is already using. @@ -67,45 +80,51 @@ export function reportedBunRuntimeSource( * be silently dropped on `ocx ensure`, GUI start, restart, and update-relaunch, and the * service would report an unknown origin it actually knows. * - * An inherited marker is only carried forward when it still DESCRIBES the executable - * about to be re-executed. Inheritance travels down a process tree, so a marker can - * outlive the binary it was minted for — something started under a marked process but - * running a different Bun would otherwise relaunch the daemon with a provenance - * contradicting the binary actually serving it. When the claim does not match - * `process.execPath`, the honest answer is this executable's own origin. + * An inherited marker is carried forward only when the binary it was minted for is the + * one about to be re-executed. Inheritance travels down a process tree, so a marker can + * outlive its binary — something started under a marked process but running a different + * Bun would otherwise relaunch the daemon with a provenance contradicting the binary + * actually serving it. The check compares the recorded path rather than re-deriving the + * selection, because a service installed with a shell-local override keeps neither that + * shell nor its `OPENCODEX_BUN_PATH`, and re-deriving would demote a correct `override` + * to `process` on its first relaunch. */ export function withProcessRuntimeProvenance( env: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { - return { ...env, [BUN_RUNTIME_SOURCE_ENV]: currentRuntimeProvenance(env) }; + return { ...env, ...bunRuntimeProvenanceEnv(currentRuntimeProvenance(env)) }; } /** - * Provenance for `process.execPath`: the inherited claim when it is corroborated by - * re-resolving that source, otherwise what this executable actually is. + * Provenance for `process.execPath`: the inherited claim when it was minted for this + * exact executable, otherwise what this executable actually is. */ -function currentRuntimeProvenance(env: NodeJS.ProcessEnv): BunRuntimeSource { +function currentRuntimeProvenance(env: NodeJS.ProcessEnv): DurableBunRuntime { const claimed = reportedBunRuntimeSource(env); - if (claimed && samePath(resolvedPathForSource(claimed, env), process.execPath)) return claimed; - // No trustworthy claim: report what is running, re-deriving it rather than guessing. - return durableBunRuntime().path === process.execPath ? durableBunRuntime().source : "process"; -} - -function resolvedPathForSource(source: BunRuntimeSource, env: NodeJS.ProcessEnv): string | null { - if (source === "process") return process.execPath; - if (source === "override") { - const value = env[BUN_OVERRIDE_ENV]?.trim(); - return value ? resolve(value) : null; + const claimedPath = env[BUN_RUNTIME_PATH_ENV]?.trim(); + if (claimed && claimedPath && samePath(claimedPath, process.execPath)) { + return { path: process.execPath, source: claimed, overrideEnv: BUN_OVERRIDE_ENV }; } - return bundledBunPath(); + // No marker that describes this binary: report what is running. One resolution + // supplies both halves so the pair can never disagree. + const runtime = durableBunRuntime(); + return samePath(runtime.path, process.execPath) + ? runtime + : { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV }; } -/** Windows paths are case-insensitive; everything else compares exactly. */ -function samePath(left: string | null, right: string): boolean { - if (!left) return false; - return process.platform === "win32" - ? left.toLowerCase() === right.toLowerCase() - : left === right; +/** + * Same file, allowing for the aliases a path can pick up between launch and relaunch: + * symlinks/junctions, mapped drives, and Windows case differences. Falls back to a + * lexical comparison when a path cannot be resolved (it may be gone). + */ +function samePath(left: string, right: string): boolean { + const canonical = (value: string): string => { + let resolved = value; + try { resolved = realpathSync(value); } catch { /* keep the literal path */ } + return process.platform === "win32" ? resolved.toLowerCase() : resolved; + }; + return canonical(left) === canonical(right); } /** diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 21d04d3e4..b2302e538 100644 --- a/src/lib/winsw.ts +++ b/src/lib/winsw.ts @@ -20,7 +20,7 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { expandUserPath, getConfigDir, loadConfig } from "../config"; import { recordOwnedConfigPath } from "./config-ownership"; -import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./bun-runtime"; import type { BunRuntimeSource } from "./bun-runtime"; import { serviceApiTokenFilePath } from "./service-secrets"; @@ -99,6 +99,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces const envLines = [ ` `, ` `, + ` `, ` `, ` `, env.CODEX_HOME?.trim() ? ` ` : null, diff --git a/src/service.ts b/src/service.ts index 1b646435a..364a6953c 100644 --- a/src/service.ts +++ b/src/service.ts @@ -15,7 +15,7 @@ import { loadConfig } from "./config"; import { restoreNativeCodex } from "./codex/inject"; import { stripGrokConfig } from "./grok/inject"; import { isWslRuntime } from "./codex/home"; -import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime"; import type { BunRuntimeSource } from "./lib/bun-runtime"; import { isProcessAlive, stopProxy } from "./lib/process-control"; import { serviceApiTokenFilePath } from "./lib/service-secrets"; @@ -281,6 +281,7 @@ export function buildPlist(): string { const envLines = [ ` OCX_SERVICE1`, ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, + ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, @@ -1343,6 +1344,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ "chcp 65001 >nul", windowsBatchSet("OCX_SERVICE", "1"), windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), + windowsBatchSet(BUN_RUNTIME_PATH_ENV, bun, "path"), windowsBatchSet("PATH", path, "pathList"), windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), @@ -1936,6 +1938,7 @@ export function buildUnit(): string { const envLines = [ systemdEnvironmentAssignment("OCX_SERVICE", "1"), systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource), + systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun), systemdEnvironmentAssignment("PATH", path), codexHome, opencodexHome, diff --git a/src/tray/windows-tray.ps1 b/src/tray/windows-tray.ps1 index cedcbb42b..ba3d25a70 100644 --- a/src/tray/windows-tray.ps1 +++ b/src/tray/windows-tray.ps1 @@ -95,7 +95,12 @@ function Start-OcxCommand([string[]]$CommandArgs) { $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden $psi.EnvironmentVariables["CODEX_HOME"] = $CodexHome $psi.EnvironmentVariables["OPENCODEX_HOME"] = $OpenCodexHome - if ($BunRuntimeSource) { $psi.EnvironmentVariables["OCX_BUN_RUNTIME_SOURCE"] = $BunRuntimeSource } + if ($BunRuntimeSource) { + $psi.EnvironmentVariables["OCX_BUN_RUNTIME_SOURCE"] = $BunRuntimeSource + # Paired with the source so a later relaunch can tell the marker still describes + # this binary rather than one it merely inherited. + $psi.EnvironmentVariables["OCX_BUN_RUNTIME_PATH"] = $BunPath + } $process = [System.Diagnostics.Process]::Start($psi) if ($null -ne $process) { $process.Dispose() } Write-ActionLog "dispatched $($CommandArgs -join ' ')" diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 0ae0f3539..3b9e155ad 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -117,16 +117,28 @@ endpoint restores native Codex config, stops any installed service to prevent re `GET /api/system/memory` may report `bunRuntimeSource` — one of `override`, `bundled`, or `process` — describing how the **running service** obtained its Bun binary. -The value is stamped into the launched process's environment (`OCX_BUN_RUNTIME_SOURCE`) by -whichever launcher selected the binary: the npm Node launcher, the Windows Task Scheduler -wrapper, the native WinSW service, launchd, systemd, the Codex autostart shim, and the Windows -tray host. Provenance and path come from a single `durableBunRuntime()` resolution at each of -those sites, so the marker can never describe a different binary than the one actually baked. +The value is stamped into the launched process's environment as a pair — +`OCX_BUN_RUNTIME_SOURCE` plus `OCX_BUN_RUNTIME_PATH`, the binary it was minted for — by whichever +launcher selected that binary: the npm Node launcher, the Windows Task Scheduler wrapper, the +native WinSW service, launchd, systemd, the Codex autostart shim, and the Windows tray host. Both +halves come from a single `durableBunRuntime()` resolution at each site, so the marker can never +describe a different binary than the one actually baked. Launchers that re-exec `process.execPath` instead of resolving a binary — `ocx ensure`, GUI/Claude/ OpenCode start, `POST /api/system/restart`, and the update relaunch — go through -`withProcessRuntimeProvenance()`. Re-execing the current runtime does not change how that runtime -was obtained, so an inherited marker is preserved and only its absence records `process`. +`withProcessRuntimeProvenance()`. An inherited marker is carried forward only when its recorded +path is the executable about to run, compared through `realpath` so symlinks, junctions, and +Windows case differences do not break a valid match. The recorded path is what settles this rather +than re-deriving the original selection: a service installed with a shell-local override keeps +neither that shell nor its `OPENCODEX_BUN_PATH`, so re-deriving would demote a correct `override` +to `process` on the first relaunch. A marker that describes some other binary — inheritance +travels down a process tree and can outlive the binary it was minted for — is dropped in favor of +what is actually executing. + +The Codex shims scope the pair to their `ensure` invocation (an assignment prefix in `sh`, +`setlocal`/`endlocal` in `cmd`, save-and-restore in PowerShell) rather than exporting it. A shim +wraps the real `codex`, so an exported marker would be inherited by Codex and everything it +spawns. **Trust rule: a reporting surface must never resolve provenance for itself.** Calling `durableBunRuntime()` at report time answers "what would this process pick right now", which is diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index 469a8c1f5..a7c63c5a3 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; // realpath the temp root: on macOS /var is a symlink to /private/var, so a path built // from mkdtemp compares unequal to the same path resolved through process.cwd(). @@ -150,22 +150,35 @@ describe("reportedBunRuntimeSource (#848 launch-time provenance)", () => { }); describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { - it("records `process` when the relaunching parent carries no marker", () => { + // Under `bun test` the runner may itself BE the bundled binary, in which case + // `bundled` is the correct answer rather than `process`. Both are legitimate; + // what matters is that a real origin is always recorded. + const executingOrigin = bundledBunPath() === process.execPath ? "bundled" : "process"; + + it("records the executable's real origin when the relaunching parent carries no marker", () => { // `ocx ensure`, GUI start, restart, and update-relaunch all re-exec // process.execPath. Without this they would hand the daemon no provenance at // all, and doctor would report unknown for an origin the launcher knew. - expect(withProcessRuntimeProvenance({})[BUN_RUNTIME_SOURCE_ENV]).toBe("process"); + expect(withProcessRuntimeProvenance({})[BUN_RUNTIME_SOURCE_ENV]).toBe(executingOrigin); }); it("preserves an inherited marker instead of relabeling the same runtime", () => { // Re-execing the current runtime does not change how that runtime was obtained, - // but the claim is only carried forward when it still describes process.execPath. + // but the claim is only carried forward when the binary it was minted for is the + // one about to run. The recorded path is what settles that — re-deriving the + // selection would demote a service installed with a shell-local override. const overrideEnv = { [BUN_RUNTIME_SOURCE_ENV]: "override", - OPENCODEX_BUN_PATH: process.execPath, + [BUN_RUNTIME_PATH_ENV]: process.execPath, }; expect(withProcessRuntimeProvenance(overrideEnv)[BUN_RUNTIME_SOURCE_ENV]).toBe("override"); - expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "process" })[BUN_RUNTIME_SOURCE_ENV]).toBe("process"); + + // Crucially this holds with no OPENCODEX_BUN_PATH in the environment at all: an + // installed service keeps neither the shell that installed it nor its variables. + expect(overrideEnv).not.toHaveProperty("OPENCODEX_BUN_PATH"); + + // The pair is re-stamped for the child, so the next relaunch can do the same check. + expect(withProcessRuntimeProvenance(overrideEnv)[BUN_RUNTIME_PATH_ENV]).toBe(process.execPath); }); it("drops an inherited marker that no longer describes the running binary", () => { @@ -174,19 +187,16 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { // different Bun must not relaunch the daemon claiming that other binary's origin. const staleOverride = { [BUN_RUNTIME_SOURCE_ENV]: "override", - OPENCODEX_BUN_PATH: join(tmp, "some-other-bun.exe"), + [BUN_RUNTIME_PATH_ENV]: join(tmp, "some-other-bun.exe"), }; expect(withProcessRuntimeProvenance(staleOverride)[BUN_RUNTIME_SOURCE_ENV]).not.toBe("override"); - // Same for a `bundled` claim while the bundled path is not what is executing. - const bundled = bundledBunPath(); - if (bundled && bundled !== process.execPath) { - expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "bundled" })[BUN_RUNTIME_SOURCE_ENV]).not.toBe("bundled"); - } + // A source with no recorded path cannot be corroborated and is not carried forward. + expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "override" })[BUN_RUNTIME_SOURCE_ENV]).not.toBe("override"); }); it("replaces an unrecognized inherited value rather than forwarding it", () => { - expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "system" })[BUN_RUNTIME_SOURCE_ENV]).toBe("process"); + expect(withProcessRuntimeProvenance({ [BUN_RUNTIME_SOURCE_ENV]: "system" })[BUN_RUNTIME_SOURCE_ENV]).toBe(executingOrigin); }); it("leaves every other variable untouched", () => { diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index b664664f8..31137b245 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -64,7 +64,8 @@ describe("Codex autostart shim", () => { // The shim reaches the daemon through `ocx ensure`, which inherits this env; // without it a Codex-autostarted service reports no provenance at all. const unix = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts", "override", "/tmp/token"); - expect(unix).toContain("OCX_BUN_RUNTIME_SOURCE='override' '/usr/local/bin/bun' '/opt/opencodex/src/cli.ts' ensure"); + // Source and the binary it describes are stamped as a pair. + expect(unix).toContain("OCX_BUN_RUNTIME_SOURCE='override' OCX_BUN_RUNTIME_PATH='/usr/local/bin/bun' '/usr/local/bin/bun' '/opt/opencodex/src/cli.ts' ensure"); expect(buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts", "override")) .toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); @@ -82,6 +83,7 @@ describe("Codex autostart shim", () => { expect(unix).not.toContain("export OCX_BUN_RUNTIME_SOURCE"); // The only occurrence is the one-shot assignment prefixed onto `ensure`. expect((unix.match(/OCX_BUN_RUNTIME_SOURCE/g) ?? []).length).toBe(1); + expect((unix.match(/OCX_BUN_RUNTIME_PATH/g) ?? []).length).toBe(1); expect(unix.indexOf("OCX_BUN_RUNTIME_SOURCE")).toBeGreaterThan(unix.indexOf("ocx_subcommand")); // cmd.exe: set inside a setlocal/endlocal pair around `ensure` only. @@ -90,7 +92,9 @@ describe("Codex autostart shim", () => { expect(ensureBlock).toContain("setlocal"); expect(ensureBlock).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); expect(ensureBlock).toContain("endlocal"); + expect(ensureBlock).toContain("OCX_BUN_RUNTIME_PATH"); expect((cmd.match(/OCX_BUN_RUNTIME_SOURCE/g) ?? []).length).toBe(1); + expect((cmd.match(/OCX_BUN_RUNTIME_PATH/g) ?? []).length).toBe(1); // PowerShell: assigned around the ensure call and restored/removed afterwards. const ps = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\bun.exe", "C:\\cli.ts", "override");