From 3c53685c8c124425db50df8307609e5dcf9820fe Mon Sep 17 00:00:00 2001 From: ismell0992-afk Date: Wed, 7 Oct 2026 08:37:52 +1100 Subject: [PATCH] fix(service): write-probe bundled Bun before baking it into durable launchers Windows app-sandbox/WDAC policies can deny profile writes to executables under sandbox-tagged trees: identical bun binary bytes pass from one path and get EPERM from another (observed: the service wrapper crash-looping on mkdir of its ownership lease since the policy began tagging the checkout). cliEntry() (Task Scheduler wrapper, Codex autostart shim) now write-probes the bundled binary against the opencodex config dir before baking it, and falls back to a demonstrably-writable runtime (current runtime, PATH bun, npm/official install locations) when the probe fails. Off Windows and for non-bundled provenance the behavior is unchanged. --- src/codex/shim.ts | 7 ++- src/lib/bun-runtime.ts | 82 ++++++++++++++++++++++++++ src/service/state.ts | 13 ++-- tests/ci-workflows/bun-runtime.test.ts | 43 +++++++++++++- 4 files changed, 137 insertions(+), 8 deletions(-) diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 25638f231a9..0f7f9d22128 100644 --- a/src/codex/shim.ts +++ b/src/codex/shim.ts @@ -9,10 +9,11 @@ import { writeFileSync, } from "node:fs"; import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path"; -import { durableBunRuntime } from "../lib/bun-runtime"; +import { durableBunRuntime, sandboxAwareBunRuntime } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; import { isWslRuntime, wslAutomountRoot } from "./home"; +import { getConfigDir } from "../config"; import { truncateRetainedUtf8 } from "../lib/admission"; import { buildUnixCodexShim, @@ -110,7 +111,9 @@ function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: str // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. // This module lives in src/codex/, the CLI entry in src/cli/index.ts. // Path and provenance resolve together so the marker always describes this binary. - const runtime = durableBunRuntime(); + // Same sandbox write-probe as the service entry: a shim baking a binary the + // sandbox denies profile writes would launch a proxy that crash-loops. + const runtime = sandboxAwareBunRuntime(durableBunRuntime(), getConfigDir()); return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") }; } diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index ffe5e0b53f2..a169ad6b581 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -12,6 +12,8 @@ */ import { createRequire } from "node:module"; import { realpathSync } from "node:fs"; +import { existsSync, rmdirSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { isRealBunBinary } from "./bun-binary-validator.mjs"; import { isStandaloneBinary } from "./standalone"; @@ -186,3 +188,83 @@ export function durableBunRuntime(): DurableBunRuntime { export function durableBunPath(): string { return durableBunRuntime().path; } + +/** + * Demonstrate that `bunPath` can create and remove a directory inside + * `targetDir`, by spawning it. Some Windows app-sandbox/WDAC policies deny + * profile writes to executables living under sandbox-tagged trees: the same + * binary bytes pass from one path and get EPERM from another, and the failure + * only shows up at runtime (observed: the service wrapper crash-looping on + * `mkdir` of its ownership lease). Durable artifacts bake a bun path, so the + * selection probes before baking. Windows-only: elsewhere the answer is + * uniformly "yes" and no probe child is worth spawning. + */ +export function probeBunWriteAccess(bunPath: string, targetDir: string): boolean { + if (process.platform !== "win32") return true; + const key = `${bunPath}\0${targetDir}`; + const memoized = writeAccessProbeResults.get(key); + if (memoized !== undefined) return memoized; + let granted = false; + if (isRealBunBinary(bunPath)) { + const probe = join(targetDir, `.ocx-bun-write-probe-${process.pid}-${Date.now().toString(36)}`); + const script = `import{mkdirSync,rmdirSync}from"node:fs";try{mkdirSync(${JSON.stringify(probe)});rmdirSync(${JSON.stringify(probe)})}catch{process.exit(1)}`; + try { + const result = Bun.spawnSync([bunPath, "-e", script], { + stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 20_000, windowsHide: true, + }); + granted = result.exitCode === 0 && !existsSync(probe); + } catch { + granted = false; + } + try { rmdirSync(probe); } catch { /* probe child already cleaned up */ } + } + writeAccessProbeResults.set(key, granted); + return granted; +} +const writeAccessProbeResults = new Map(); + +/** + * Durable-artifact runtime selection with the Windows sandbox write-block in + * mind: when the bundled binary cannot write into the directory the managed + * service will need (the opencodex config dir), bake a demonstrably-writable + * runtime instead of one that crash-loops at startup. Candidates are the + * runtime running this selection, a `bun.exe` on PATH (name lookup only — the + * executable still comes from a probed absolute path), and the two common + * installer locations. Provenance for a fallback is reported as "process": + * it names a real running binary, and no existing source fits better. + */ +export function sandboxAwareBunRuntime(runtime: DurableBunRuntime, targetDir: string): DurableBunRuntime { + if (process.platform !== "win32") return runtime; + // A non-binary path (placeholder, fixture, or another platform's layout in tests) + // cannot be judged blocked — only a real binary that fails the probe is evidence. + if (runtime.source !== "bundled" || !isRealBunBinary(runtime.path) || probeBunWriteAccess(runtime.path, targetDir)) return runtime; + for (const candidate of windowsWritableBunCandidates()) { + if (samePath(candidate, runtime.path)) continue; + if (probeBunWriteAccess(candidate, targetDir)) { + return { path: candidate, source: "process", overrideEnv: BUN_OVERRIDE_ENV }; + } + } + return runtime; +} + +function windowsWritableBunCandidates(): string[] { + const candidates: string[] = []; + if (isRealBunBinary(process.execPath)) candidates.push(process.execPath); + try { + const found = Bun.spawnSync(["C:\\Windows\\System32\\where.exe", "bun.exe"], { + stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: 10_000, windowsHide: true, + }); + if (found.exitCode === 0 && found.stdout) { + for (const line of found.stdout.toString().split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed) candidates.push(trimmed); + } + } + } catch { + // where.exe is System32-fixed and should exist; common locations below still apply. + } + const appdata = process.env.APPDATA?.trim(); + if (appdata) candidates.push(join(appdata, "npm", "node_modules", "bun", "bin", "bun.exe")); + candidates.push(join(homedir(), ".bun", "bin", "bun.exe")); + return candidates; +} diff --git a/src/service/state.ts b/src/service/state.ts index 8f2eda5a6cc..c1f28d4d7d5 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -5,7 +5,7 @@ import { expandUserPath, getConfigDir } from "../config"; import { atomicWriteFileStreamed } from "../config/atomic-write"; import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home"; import { resolveCodexSqliteHome } from "../codex/paths"; -import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; +import { durableBunRuntime, sandboxAwareBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw"; import { isTransientServiceLauncherPath } from "../lib/transient-service-path"; export { filterTransientServicePath, isTransientServiceLauncherPath } from "../lib/transient-service-path"; @@ -54,11 +54,14 @@ export function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bu // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts. // // Path and provenance come from ONE resolution so the marker can never describe a - // different binary than the one actually baked. + // different binary than the one actually baked. On Windows the bundled binary is + // write-probed against the config dir first: sandbox policies that deny it profile + // writes would otherwise bake a binary that crash-loops at service start. + const checked = sandboxAwareBunRuntime(runtime, getConfigDir()); return { - bun: runtime.path, - bunRuntimeSource: runtime.source, - cli: runtime.source === "standalone" || isStandaloneBinary() ? null : join(serviceSourceDir, "cli", "index.ts"), + bun: checked.path, + bunRuntimeSource: checked.source, + cli: checked.source === "standalone" || isStandaloneBinary() ? null : join(serviceSourceDir, "cli", "index.ts"), }; } diff --git a/tests/ci-workflows/bun-runtime.test.ts b/tests/ci-workflows/bun-runtime.test.ts index 0bbb22d8e46..689937b2522 100644 --- a/tests/ci-workflows/bun-runtime.test.ts +++ b/tests/ci-workflows/bun-runtime.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, afterAll, afterEach } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import { homedir } from "node:os"; import { join } from "node:path"; -import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../../src/lib/bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, probeBunWriteAccess, reportedBunRuntimeSource, sandboxAwareBunRuntime, withProcessRuntimeProvenance } from "../../src/lib/bun-runtime"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -258,3 +259,43 @@ describe("withProcessRuntimeProvenance (execPath relaunch paths)", () => { } }); }); + +describe("sandboxAwareBunRuntime (Windows sandbox write-block)", () => { + it("returns the runtime unchanged off Windows", () => { + if (process.platform === "win32") return; + const runtime = { path: "/nonexistent/bun", source: "bundled" as const, overrideEnv: "OPENCODEX_BUN_PATH" }; + expect(sandboxAwareBunRuntime(runtime, tmp)).toEqual(runtime); + expect(probeBunWriteAccess("/nonexistent/bun", tmp)).toBe(true); + }); + + it("returns the runtime unchanged when the provenance is not bundled", () => { + if (process.platform !== "win32") return; + const runtime = { path: process.execPath, source: "process" as const, overrideEnv: "OPENCODEX_BUN_PATH" }; + expect(sandboxAwareBunRuntime(runtime, tmp)).toEqual(runtime); + }); + + describe.skipIf(process.platform !== "win32")("on Windows", () => { + it("probe reports false when the target cannot be written", () => { + const blocker = join(tmp, "probe-blocker-file"); + writeFileSync(blocker, "x", "utf8"); + expect(probeBunWriteAccess(process.execPath, join(blocker, "probe"))).toBe(false); + }); + + it("keeps the bundled runtime when it can write the target dir", () => { + const bundled = bundledBunPath(); + if (!bundled || !probeBunWriteAccess(bundled, tmp)) return; + const runtime = { path: bundled, source: "bundled" as const, overrideEnv: "OPENCODEX_BUN_PATH" }; + expect(sandboxAwareBunRuntime(runtime, tmp)).toEqual(runtime); + }); + + it("falls back to a writable runtime when the sandbox denies the bundled binary", () => { + const bundled = bundledBunPath(); + const configDir = process.env.OPENCODEX_HOME?.trim() || join(homedir(), ".opencodex"); + if (!bundled || probeBunWriteAccess(bundled, configDir)) return; + const runtime = { path: bundled, source: "bundled" as const, overrideEnv: "OPENCODEX_BUN_PATH" }; + const checked = sandboxAwareBunRuntime(runtime, configDir); + expect(checked.path).not.toBe(bundled); + expect(probeBunWriteAccess(checked.path, configDir)).toBe(true); + }); + }); +});