diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 6baf5168b5f..0d235f59556 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -37,6 +37,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; +import { scopeTranspilerCacheToRuntime } from "../src/lib/bun-transpiler-cache-scope.mjs"; import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; @@ -1003,6 +1004,15 @@ function findBunBinary(bunDir) { return null; } +function bundledBunVersion(bunDir) { + try { + const version = JSON.parse(readFileSync(join(bunDir, "package.json"), "utf8")).version; + return typeof version === "string" ? version : null; + } catch { + return null; + } +} + function fail(msg) { const reinstall = installMethod === "pnpm" ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" @@ -1039,7 +1049,7 @@ function resolveBun({ allowInstall = true } = {}) { } catch { /* Missing dependency can still fall back to a validated PATH Bun. */ } let bin = bunDir ? findBunBinary(bunDir) : null; - if (bin) return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) }; // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. @@ -1048,7 +1058,7 @@ function resolveBun({ allowInstall = true } = {}) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } - if (bin) return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) }; // Reached only when the bundled runtime is unusable. The two probes start the candidate Bun // cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for @@ -1056,7 +1066,7 @@ function resolveBun({ allowInstall = true } = {}) { const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); if (pathBun) { console.error(`opencodex: using PATH Bun ${pathBun.version}.`); - return { path: pathBun.path, source: "process" }; + return { path: pathBun.path, source: "process", version: pathBun.version }; } fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } @@ -1230,7 +1240,8 @@ const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, // the long-running Bun child, and closing that window kills the proxy (#1236). windowsHide: true, env: { - ...inheritedEnv, + // A cache shared with another Bun version (the CI test runner's) is evicted on every start. + ...scopeTranspilerCacheToRuntime(inheritedEnv, bunRuntime.version, join), [NODE_LAUNCH_CONTEXT_ENV]: launchContext, [BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source, [BUN_RUNTIME_PATH_ENV]: bunRuntime.path, diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ae8abf42f8b..1c6e4ff4d1b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -288,6 +288,7 @@ "build-release-changelog.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", + "bun-transpiler-cache-scope.test.ts": "cli", "bun-stream-caps.test.ts": "lib", "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", diff --git a/src/lib/bun-transpiler-cache-scope.mjs b/src/lib/bun-transpiler-cache-scope.mjs new file mode 100644 index 00000000000..e497186c3d3 --- /dev/null +++ b/src/lib/bun-transpiler-cache-scope.mjs @@ -0,0 +1,42 @@ +/** + * Scope an inherited Bun runtime transpiler cache to the Bun version this launcher starts. + * + * Bun keys `.pile` entries by source-content hash alone and stamps them with a format + * version that changes between releases (1.4.2 writes 28, 1.4.3 writes 34). A reader that + * finds the other format deletes and rewrites the entry. When one explicit cache directory + * is shared by two Bun versions, each start of one evicts what the other just wrote, so + * neither ever runs warm. That is the CI shape: the test runner (`testRunnerBun`) and the + * bundled runtime this launcher starts share the shard's `BUN_RUNTIME_TRANSPILER_CACHE_PATH`, + * and every launcher spawn re-transpiled its whole CLI graph from nothing. + * + * A per-version subdirectory keeps each runtime's entries stable. Bun creates the directory. + * A disabled cache ("0" or empty), an unset one, or an unknown version is left as given. + * Windows environment names are case-insensitive (Bun's lookup too) while this object is not, + * so on win32 every spelling of the name is matched and replaced by one scoped entry. + */ +export const TRANSPILER_CACHE_ENV = "BUN_RUNTIME_TRANSPILER_CACHE_PATH"; + +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; + +/** + * @param {Record} env + * @param {string | null | undefined} runtimeVersion + * @param {(...parts: string[]) => string} join + * @param {NodeJS.Platform} [platform] + * @returns {Record} + */ +export function scopeTranspilerCacheToRuntime(env, runtimeVersion, join, platform = process.platform) { + const wanted = TRANSPILER_CACHE_ENV.toLowerCase(); + const names = platform === "win32" + ? Object.keys(env).filter(name => name.toLowerCase() === wanted) + : Object.hasOwn(env, TRANSPILER_CACHE_ENV) ? [TRANSPILER_CACHE_ENV] : []; + const name = names.includes(TRANSPILER_CACHE_ENV) ? TRANSPILER_CACHE_ENV : names[0]; + const cache = name === undefined ? undefined : env[name]; + if (typeof cache !== "string" || cache === "" || cache === "0") return env; + if (typeof runtimeVersion !== "string" || !VERSION.test(runtimeVersion)) return env; + const scoped = { ...env }; + for (const variant of names) delete scoped[variant]; + scoped[TRANSPILER_CACHE_ENV] = join(cache, `bun-${runtimeVersion}`); + return scoped; +} + diff --git a/structure/ops/cross-platform-ci.md b/structure/ops/cross-platform-ci.md index 09640f00b86..4b479732eed 100644 --- a/structure/ops/cross-platform-ci.md +++ b/structure/ops/cross-platform-ci.md @@ -88,6 +88,12 @@ must be evaluated without raising test deadlines or weakening assertions. An exp including "" or "0" to disable caching, is preserved and remains the operator's protected-path responsibility. `tests/ci-workflows/test-runner.test.ts` covers isolation, legacy-path refusal, cleanup ownership, overrides and actual Bun children with different homes. +The published launcher (`bin/ocx.mjs`) starts the bundled Bun, which is not the test-runner Bun. +Bun keys `.pile` entries by source hash and deletes an entry written in another release's format, +so the two versions sharing one cache evict each other on every start, and each launcher child +loaded its CLI graph cold (a 15 s Windows `spawnSync` timeout in run 38697651428). The launcher +therefore passes its child `/bun-` (`src/lib/bun-transpiler-cache-scope.mjs`); +`tests/cli/bun-transpiler-cache-scope.test.ts` covers it. Startup ACL reads use .NET, never module-autoloaded `Get-Acl`/`Set-Acl` (`tests/ci-workflows/ci-review-lanes.test.ts` scans `src/`). Across the jobs, the workflow runs: diff --git a/structure/runtime.md b/structure/runtime.md index b2e7d8a85dd..3585af3cc68 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -102,7 +102,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). An inherited explicit `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is scoped to `bun-` of the bundled or PATH runtime it starts (`src/lib/bun-transpiler-cache-scope.mjs`), because another Bun version sharing the directory evicts its entries. Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/cli/bun-transpiler-cache-scope.test.ts b/tests/cli/bun-transpiler-cache-scope.test.ts new file mode 100644 index 00000000000..9936d853821 --- /dev/null +++ b/tests/cli/bun-transpiler-cache-scope.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { scopeTranspilerCacheToRuntime, TRANSPILER_CACHE_ENV } from "../../src/lib/bun-transpiler-cache-scope.mjs"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { COLD_SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); + +describe("launcher transpiler cache scope", () => { + test("an explicit cache is scoped to the started runtime's version", () => { + const env = { [TRANSPILER_CACHE_ENV]: "/cache", OTHER: "kept" }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.2", join)).toEqual({ [TRANSPILER_CACHE_ENV]: join("/cache", "bun-1.4.2"), OTHER: "kept" }); + expect(env[TRANSPILER_CACHE_ENV]).toBe("/cache"); + }); + + test("a disabled, unset or unknown-version cache is left as given", () => { + for (const value of ["0", "", undefined]) { + const env = { [TRANSPILER_CACHE_ENV]: value }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.2", join)).toBe(env); + } + const env = { [TRANSPILER_CACHE_ENV]: "/cache" }; + for (const version of [null, undefined, "", "../1.4.2", "1.4", "latest"]) { + expect(scopeTranspilerCacheToRuntime(env, version, join)).toBe(env); + } + }); + + test("Windows matches every spelling of the name and leaves one scoped entry", () => { + const env = { Bun_Runtime_Transpiler_Cache_Path: "C:\\cache", OTHER: "kept" }; + expect(scopeTranspilerCacheToRuntime(env, "1.4.2", join, "win32")) + .toEqual({ [TRANSPILER_CACHE_ENV]: join("C:\\cache", "bun-1.4.2"), OTHER: "kept" }); + const both = { [TRANSPILER_CACHE_ENV]: "C:\\exact", bun_runtime_transpiler_cache_path: "C:\\other" }; + expect(scopeTranspilerCacheToRuntime(both, "1.4.2", join, "win32")) + .toEqual({ [TRANSPILER_CACHE_ENV]: join("C:\\exact", "bun-1.4.2") }); + // POSIX names are case-sensitive: Bun never reads the lower-case spelling there. + const posix = { bun_runtime_transpiler_cache_path: "/cache" }; + expect(scopeTranspilerCacheToRuntime(posix, "1.4.2", join, "linux")).toBe(posix); + }); + + // CI shares one cache between the test-runner Bun and the bundled Bun the launcher starts. + // Their .pile formats differ and each deletes the other's entries, so before this scope every + // launcher child re-transpiled its CLI graph cold (15 s spawnSync timeouts on Windows). + test.skipIf(spawnSync("node", ["--version"], { windowsHide: true }).status !== 0)( + "the published launcher writes its runtime's entries beside the runner's, never over them", () => { + const cache = mkdtempSync(join(tmpdir(), "ocx-transpiler-scope-")); + roots.push(cache); + const bundled = JSON.parse(readFileSync(repoPath("node_modules", "bun", "package.json"), "utf8")).version as string; + const result = spawnSync("node", [repoPath("bin", "ocx.mjs"), "--version"], { + cwd: repoRoot(), encoding: "utf8", timeout: COLD_SPAWN_BUDGET_MS - 5_000, windowsHide: true, + env: { ...process.env, [TRANSPILER_CACHE_ENV]: cache, OPENCODEX_BUN_PATH: "" }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(readdirSync(cache)).toEqual([`bun-${bundled}`]); + expect(existsSync(join(cache, `bun-${bundled}`)) && readdirSync(join(cache, `bun-${bundled}`)).length > 0).toBe(true); + }, COLD_SPAWN_BUDGET_MS); +}); + diff --git a/tests/codex-integration/codex-cli-update-zero-effect.test.ts b/tests/codex-integration/codex-cli-update-zero-effect.test.ts index bd239caaefb..d985ea3144d 100644 --- a/tests/codex-integration/codex-cli-update-zero-effect.test.ts +++ b/tests/codex-integration/codex-cli-update-zero-effect.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath, repoRoot } from "../helpers/repo-root"; -import { COLD_SPAWN_BUDGET_MS, SPAWN_BUDGET_MS } from "../helpers/test-budget"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; const roots: string[] = []; afterEach(() => { @@ -43,15 +43,15 @@ describe("Codex CLI updater zero-effect boundary", () => { })); const stateFiles = ["codex-shim.json", "codex-runtime.json", "config.json"].map(name => join(home, name)); for (const path of stateFiles) writeFileSync(path, "{broken"); - // This first Node -> Bun inspection also loads the Windows native reader cold. - // Dev run 38697651428 exhausted 15s; the next attest took 4.6s (previously 0.5–0.8s). - // Keep the real launcher and every zero-effect assertion; use the shared cold budget - // only here, leaving 5s for assertions/cleanup before the surrounding test deadline. + // Dev run 38697651428 exhausted 15s here: the launcher's bundled Bun shared the shard's + // transpiler cache with the differently versioned test runner, and each evicted the other's + // entries, so this child re-transpiled the CLI graph cold. The launcher now scopes that + // cache per runtime version (tests/cli/bun-transpiler-cache-scope.test.ts). const result = spawnSync("node", [ repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", "attest", "--candidate", candidate, "--npm-prefix", prefix, "--npm-cli", npmCli, "--node", node, "--json", ], { - cwd: repoRoot(), encoding: "utf8", timeout: COLD_SPAWN_BUDGET_MS - 5_000, windowsHide: true, + cwd: repoRoot(), encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, windowsHide: true, env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: home, CODEX_CLI_PATH: selectedCandidate, npm_config_registry: "http://127.0.0.1:9", npm_config_offline: "true", @@ -70,7 +70,7 @@ describe("Codex CLI updater zero-effect boundary", () => { expect(result.stdout).not.toContain("ocx-codex-attest-zero-effect-"); for (const path of stateFiles) expect(readFileSync(path, "utf8")).toBe("{broken"); for (const marker of [candidateMarker, npmMarker, selectedMarker]) expect(existsSync(marker)).toBe(false); - }, COLD_SPAWN_BUDGET_MS, + }, SPAWN_BUDGET_MS, ); (process.platform === "win32" && process.arch === "x64" ? test : test.skip)( diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bdb6b3af5ee..b7c535715ab 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -287,6 +287,7 @@ "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", "build-desktop-icon-set.test.ts": "ci-workflows", "build-release-changelog.test.ts": "ci-workflows", "ci-gui-typecheck-gate.test.ts": "ci-workflows", "bump-dev-version.test.ts": "ci-workflows", "bun-runtime.test.ts": "ci-workflows", + "bun-transpiler-cache-scope.test.ts": "cli", "bun-stream-caps.test.ts": "lib", "cache-diagnostic.test.ts": "usage", "cancel-body-on-abort.test.ts": "server", "catalog-auto-refresh-client-fanout.test.ts": "codex-integration", "catalog-auto-refresh-scheduler.test.ts": "codex-integration", "catalog-cursor-search.test.ts": "codex-integration", "catalog-custom-ordering.test.ts": "codex-integration",