Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -1048,15 +1058,15 @@ 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
// well over a second, so the total budget is generous rather than interactive-tight.
const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS });
if (pathBun) {
console.error(`opencodex: using PATH Bun ${pathBun.version}.`);
return { path: pathBun.path, source: "process" };
return { path: pathBun.path, source: "process", version: pathBun.version };
}
fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed.");
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions src/lib/bun-transpiler-cache-scope.mjs
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>} env
* @param {string | null | undefined} runtimeVersion
* @param {(...parts: string[]) => string} join
* @param {NodeJS.Platform} [platform]
* @returns {Record<string, string | undefined>}
*/
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept versions the PATH resolver already validates

When the bundled runtime is unavailable and an accepted PATH Bun reports a version with build metadata such as 1.4.0+build.1, findPathBun returns that version (and tests/cli/ocx-launcher-runtime.test.ts explicitly requires it), but this regex rejects it. The launcher consequently leaves the shared cache unscoped, recreating the cross-version eviction and Windows timeout behavior this change is intended to fix; align this validation with the PATH resolver's accepted stable-version grammar.

Useful? React with 👍 / 👎.

const scoped = { ...env };
for (const variant of names) delete scoped[variant];
scoped[TRANSPILER_CACHE_ENV] = join(cache, `bun-${runtimeVersion}`);
return scoped;
}

6 changes: 6 additions & 0 deletions structure/ops/cross-platform-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cache>/bun-<version>` (`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:
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>` 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. |
Expand Down
62 changes: 62 additions & 0 deletions tests/cli/bun-transpiler-cache-scope.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});

Loading
Loading