From 3915acb8fdcb4895f0b44573f9588b25c032f98d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:49:13 +0900 Subject: [PATCH 1/5] fix(doctor): report Bun runtime provenance --- bin/ocx.mjs | 16 ++++++--- src/cli/doctor.ts | 31 ++++++++++++++++-- src/lib/bun-runtime.ts | 21 +++++++++++- src/lib/winsw.ts | 4 ++- src/server/management/system-routes.ts | 2 ++ src/service.ts | 8 +++-- structure/05_gui-and-management-api.md | 2 +- tests/doctor.test.ts | 45 ++++++++++++++++++++++++-- tests/memory-watchdog.test.ts | 14 ++++++-- tests/ocx-launcher-source.test.ts | 9 +++++- tests/service.test.ts | 7 ++++ tests/winsw.test.ts | 1 + 12 files changed, 141 insertions(+), 19 deletions(-) diff --git a/bin/ocx.mjs b/bin/ocx.mjs index fd1cc9194..32dff52ea 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -317,6 +317,7 @@ function bunBinDir() { } const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; +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 +348,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 +362,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 +372,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 +390,8 @@ if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstal runNpmSelfUpdate(); } -const bun = resolveBun(); +const runtime = resolveBun(); +const bun = runtime.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 +416,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]: runtime.source, + }, }); // Windows has no real POSIX signals (no SIGHUP); forwarding is best-effort there. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 1128d95fb..785359581 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -16,6 +16,7 @@ import { gracefulStopHost } from "../lib/process-control"; import { maskAccountId } from "../lib/privacy"; import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env"; import { configuredAdminToken } from "../lib/admin-secrets"; +import type { BunRuntimeSource } from "../lib/bun-runtime"; import { readCodexTokens } from "../codex/auth-collision"; import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home"; import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim"; @@ -523,6 +524,8 @@ export async function probeWham(fetchImpl: typeof fetch = fetch): Promise { @@ -95,6 +96,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, diff --git a/src/server/management/system-routes.ts b/src/server/management/system-routes.ts index cb7dba89e..c34c1460a 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,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): PromiseOCX_SERVICE1`, + ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntime.source}`, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, opencodexHome ? ` OPENCODEX_HOME${plistString(opencodexHome)}` : null, @@ -1032,6 +1034,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, bunRuntime.source), windowsBatchSet("PATH", path, "pathList"), windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"), windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"), @@ -1571,12 +1574,14 @@ function unitPath(): string { export function buildUnit(): string { const { bun, cli } = cliEntry(); + const bunRuntime = durableBunRuntime(); 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, bunRuntime.source), systemdEnvironmentAssignment("PATH", path), codexHome, opencodexHome, @@ -2172,4 +2177,3 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise { const baseData: ServiceMemoryData = { pid: 4242, bunVersion: "1.3.14", + bunRevision: "1.3.14+0d9b296a", + bunRuntimeSource: "bundled", platform: "win32", rss: 5 * 1024 ** 3, heapUsed: 200 * 1024 ** 2, @@ -355,6 +357,11 @@ describe("service memory section (#314 WP4)", () => { (async () => Response.json({ hello: "world" })) as typeof fetch); expect(malformed.status).toBe("unreachable"); if (malformed.status === "unreachable") expect(malformed.error).toBe("malformed response"); + + const invalidSource = await fetchServiceMemory("127.0.0.1", 10100, null, + (async () => Response.json({ ...baseData, bunRuntimeSource: "shell" })) as typeof fetch); + expect(invalidSource.status).toBe("ok"); + if (invalidSource.status === "ok") expect(invalidSource.data.bunRuntimeSource).toBeUndefined(); }); test("identity labels: doctor process is never presented as the service", () => { @@ -401,11 +408,43 @@ describe("service memory section (#314 WP4)", () => { expect(lines.some(l => l.includes("looks normal"))).toBe(false); }); - test("guidance gating: win32 + auto-known-bad prints version-claiming guidance", () => { + test("guidance gating: bundled win32 + auto-known-bad offers an override", () => { const lines = formatServiceMemoryLines({ status: "ok", data: baseData }); + expect(lines.some(l => l.includes("source=bundled"))).toBe(true); 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: active override reports revision without repeating setup advice", () => { + const lines = formatServiceMemoryLines({ + status: "ok", + data: { + ...baseData, + bunVersion: "1.4.0", + bunRevision: "1.4.0-canary.1+5f65d3785", + bunRuntimeSource: "override", + }, + }); + const text = lines.join("\n"); + expect(text).toContain("revision=1.4.0-canary.1+5f65d3785"); + expect(text).toContain("source=OPENCODEX_BUN_PATH override"); + expect(text).toContain("OPENCODEX_BUN_PATH is already active"); + expect(text).not.toContain("set OPENCODEX_BUN_PATH"); + expect(text).toContain("auto-known-bad"); + }); + + test("legacy payload keeps runtime source unknown without circular override advice", async () => { + const legacy = { ...baseData }; + delete legacy.bunRevision; + delete legacy.bunRuntimeSource; + const report = await fetchServiceMemory("127.0.0.1", 10100, null, + (async () => Response.json(legacy)) as typeof fetch); + expect(report.status).toBe("ok"); + if (report.status !== "ok") return; + expect(report.data.bunRuntimeSource).toBeUndefined(); + const text = formatServiceMemoryLines(report).join("\n"); + expect(text).toContain("source=unknown"); + expect(text).toContain("legacy service/payload"); + expect(text).not.toContain("set OPENCODEX_BUN_PATH"); }); test("guidance gating: darwin auto-off or fixed Windows runtime prints no override guidance", () => { diff --git a/tests/memory-watchdog.test.ts b/tests/memory-watchdog.test.ts index ab2453c4a..a48d93968 100644 --- a/tests/memory-watchdog.test.ts +++ b/tests/memory-watchdog.test.ts @@ -180,11 +180,19 @@ describe("GET /api/system/memory", () => { }); await new Promise(resolve => setTimeout(resolve, 20)); const req = new Request("http://127.0.0.1:10100/api/system/memory"); - const res = await handleManagementAPI(req, new URL(req.url), config()); + const previousRuntimeSource = process.env.OCX_BUN_RUNTIME_SOURCE; + process.env.OCX_BUN_RUNTIME_SOURCE = "process"; + let res: Response | null; + try { + res = await handleManagementAPI(req, new URL(req.url), config()); + } finally { + if (previousRuntimeSource === undefined) delete process.env.OCX_BUN_RUNTIME_SOURCE; + else process.env.OCX_BUN_RUNTIME_SOURCE = previousRuntimeSource; + } expect(res).not.toBeNull(); expect(res!.status).toBe(200); const body = await res!.json() as { - pid: number; bunVersion: string; platform: string; rss: number; + pid: number; bunVersion: string; bunRevision: string; bunRuntimeSource: string; platform: string; rss: number; heapUsed: number; external: number; arrayBuffers: number; observedBytes: number; observedMetric: string; jscHeap: { heapSize: number } | null; responseState: { @@ -203,6 +211,8 @@ describe("GET /api/system/memory", () => { }; expect(body.pid).toBe(process.pid); expect(body.bunVersion).toBe(Bun.version); + expect(body.bunRevision).toBe(Bun.revision); + expect(body.bunRuntimeSource).toBe("process"); expect(body.rss).toBeGreaterThan(0); expect(body.heapUsed).toBeGreaterThan(0); expect(body.external).toBeGreaterThanOrEqual(0); diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index cad2a6e0a..2f4a5cc40 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); @@ -55,6 +55,13 @@ describe("ocx.mjs npm launcher (source invariants)", () => { expect(bundledLookup).toBeGreaterThan(overrideResolve); }); + test("the direct Node launcher passes its allowlisted runtime source to Bun", () => { + expect(source).toContain('const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";'); + expect(source).toContain('[BUN_RUNTIME_SOURCE_ENV]: runtime.source'); + expect(source).toContain('source: "override"'); + expect(source).toContain('source: "bundled"'); + }); + test("invalid Bun overrides warn safely and fall back without throwing", () => { expect(source).toContain('import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs";'); expect(source).toContain("is missing, unreadable, or not a complete Bun binary; falling back to the bundled runtime."); diff --git a/tests/service.test.ts b/tests/service.test.ts index bf0ca62d5..215364021 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -79,6 +79,13 @@ describe("service listen-port bake", () => { expect(buildPlist()).toContain("start --port 13337"); expect(buildUnit()).toContain("start --port 13337"); }); + + test("scheduler, launchd, and systemd artifacts bake the selected Bun runtime source", () => { + const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\OpenCodex\\cli.ts" }); + expect(script).toMatch(/set "OCX_BUN_RUNTIME_SOURCE=(override|bundled|process)"/); + expect(buildPlist()).toMatch(/OCX_BUN_RUNTIME_SOURCE<\/key>(override|bundled|process)<\/string>/); + expect(buildUnit()).toMatch(/Environment="OCX_BUN_RUNTIME_SOURCE=(override|bundled|process)"/); + }); }); describe("systemd service unit", () => { diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index eb42df8ee..d9ae005f9 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -39,6 +39,7 @@ describe("winsw xml", () => { const xml = buildWinswXml(entry, env); expect(xml).toContain(''); + expect(xml).toMatch(//); expect(xml).toContain(''); expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir()); From 9792af1bc5558db7f24c2789490c915cc1dcd48f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:15:53 +0900 Subject: [PATCH 2/5] fix(doctor): address runtime provenance review --- .../docs/troubleshooting/windows-memory.md | 9 ++- src/cli/doctor.ts | 1 + src/codex/shim.ts | 61 +++++++++++++++---- src/lib/winsw.ts | 13 ++-- src/tray/windows.ts | 27 +++++++- tests/codex-shim.test.ts | 8 ++- tests/doctor.test.ts | 1 + tests/windows-tray-runtime-source.test.ts | 23 +++++++ tests/winsw.test.ts | 8 ++- 9 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 tests/windows-tray-runtime-source.test.ts diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 149db672f..1a0bc1cb3 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -36,8 +36,13 @@ runtime the leak itself remains an upstream problem: Windows working-set/RSS counters can under-report committed external retention. - **`ocx doctor`** — a "Memory / runtime" section shows the *service* - process's Bun version, RSS, external/ArrayBuffers counters, JS-heap context, - and stream-mode decision. On the bundled Bun 1.3.14 runtime, `heapUsed` / + process's Bun version/revision, launcher-reported runtime source, RSS, + external/ArrayBuffers counters, JS-heap context, and stream-mode decision. + Source is allowlisted as `override`, `bundled`, or `process`; an older service + or invalid/missing marker is shown as unknown instead of being guessed from + the current shell. An active override remains unvalidated and keeps the + conservative runtime gate, but doctor no longer repeats the already-completed + `OPENCODEX_BUN_PATH` setup step. On the bundled Bun 1.3.14 runtime, `heapUsed` / `jscHeap` alone are not a leak discriminator; compare observed memory with `responseState` and repeated samples before assigning an app-level leak. - **`GET /api/system/memory`** — the same data over the authenticated diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 785359581..ab7a55cf5 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -673,6 +673,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] if (d.bunRuntimeSource === "override") { lines.push(" OPENCODEX_BUN_PATH is already active, but this runtime remains unvalidated for automatic eager relay."); lines.push(" The conservative auto-known-bad decision remains in effect; bunRevision is informational only."); + lines.push(" You can still opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs)."); return lines; } if (d.bunRuntimeSource === undefined) { diff --git a/src/codex/shim.ts b/src/codex/shim.ts index 97bf424e1..77124769a 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 { durableBunPath } from "../lib/bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, 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 +120,16 @@ export type CodexShimAutoRestoreResult = | { status: "ineligible" | "deferred"; message?: string } | { status: "restored"; message: string }; -function cliEntry(): { bun: string; cli: string } { - // Bundled Bun path (survives `ocx update`); all three shim builders - // (Unix / Windows cmd / Windows PowerShell) receive it via this entry. +function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } { + // Resolve the durable path and its diagnostic provenance together; all three shim + // builders (Unix / Windows cmd / Windows PowerShell) receive the same pair. // 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") }; + const bunRuntime = durableBunRuntime(); + return { + bun: bunRuntime.path, + bunRuntimeSource: bunRuntime.source, + cli: join(import.meta.dir, "..", "cli", "index.ts"), + }; } function commandNames(name: string): string[] { @@ -366,11 +371,19 @@ 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 = "process", +): 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,7 +444,12 @@ 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 = "process", +): 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 @@ -439,6 +457,7 @@ rem ${SHIM_MARKER}\r ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r ${windowsBatchSet("OCX_CLI", cliPath)}\r +${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\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 if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r @@ -472,12 +491,18 @@ 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 = "process", +): 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 +626,35 @@ 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/winsw.ts b/src/lib/winsw.ts index a470f6cfd..1c8b7c8d6 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, durableBunPath, durableBunRuntime } from "./bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type BunRuntimeSource } from "./bun-runtime"; import { serviceApiTokenFilePath } from "./service-secrets"; export const WINSW_VERSION = "2.12.0"; @@ -64,6 +64,7 @@ function currentCodexHomeAbsolute(): string { export interface WinswEntry { bun: string; + bunRuntimeSource: BunRuntimeSource; cli: string; } @@ -74,7 +75,6 @@ export interface WinswEntry { * user's interactive PATH, which provider subprocesses may need. */ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env, port?: number): string { - const bunRuntime = durableBunRuntime(); const domain = env.USERDOMAIN?.trim() || "."; const user = env.USERNAME?.trim() || ""; const listenPort = (() => { @@ -96,7 +96,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, @@ -373,5 +373,10 @@ 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 bunRuntime = durableBunRuntime(); + return { + bun: bunRuntime.path, + bunRuntimeSource: bunRuntime.source, + cli: join(cliDir, "cli", "index.ts"), + }; } diff --git a/src/tray/windows.ts b/src/tray/windows.ts index 78c2e681b..c2aea981b 100644 --- a/src/tray/windows.ts +++ b/src/tray/windows.ts @@ -4,7 +4,7 @@ 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 { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type BunRuntimeSource } from "../lib/bun-runtime"; import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import { recordOwnedConfigPath } from "../lib/config-ownership"; @@ -20,6 +20,7 @@ const TRAY_ICON_FILES = [ export interface WindowsTrayEntry { bun: string; + bunRuntimeSource?: BunRuntimeSource; cli: string; script: string; codexHome: string; @@ -81,8 +82,10 @@ function currentCodexHome(): string { } function currentEntry(): WindowsTrayEntry { + const bunRuntime = durableBunRuntime(); return { - bun: durableBunPath(), + bun: bunRuntime.path, + bunRuntimeSource: bunRuntime.source, cli: join(import.meta.dir, "..", "cli", "index.ts"), script: installedTrayScriptPath(), codexHome: currentCodexHome(), @@ -522,9 +525,27 @@ function parseTrayHostEntry(): WindowsTrayEntry { if (typeof value[key] !== "string") throw new Error(`Invalid tray host field: ${key}`); safePath(value[key]); } + if ( + value.bunRuntimeSource !== undefined + && value.bunRuntimeSource !== "override" + && value.bunRuntimeSource !== "bundled" + && value.bunRuntimeSource !== "process" + ) { + throw new Error("Invalid tray host field: bunRuntimeSource"); + } return value as WindowsTrayEntry; } +export function windowsTrayHostEnvironment( + entry: Pick, + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const childEnv = { ...env }; + if (entry.bunRuntimeSource) childEnv[BUN_RUNTIME_SOURCE_ENV] = entry.bunRuntimeSource; + else delete childEnv[BUN_RUNTIME_SOURCE_ENV]; + return childEnv; +} + /** Detached Bun host keeps the attached WinForms PowerShell process alive. */ export async function runWindowsTrayHost(): Promise { assertWindows(); @@ -535,7 +556,7 @@ export async function runWindowsTrayHost(): Promise { const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), { stdio: "ignore", windowsHide: true, - env: process.env, + env: windowsTrayHostEnvironment(entry), }); await new Promise((resolvePromise, rejectPromise) => { child.once("error", rejectPromise); diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index a1bdff3f4..297e40a61 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -58,6 +58,7 @@ describe("Codex autostart shim", () => { expect(script).not.toContain("sync-cache"); expect(script).toContain("exec '/usr/local/bin/codex-real' \"$@\""); expect(script).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(script).toContain("OCX_BUN_RUNTIME_SOURCE='process'"); }); test("builds a Windows shim that starts ocx before running Codex", () => { @@ -68,6 +69,7 @@ describe("Codex autostart shim", () => { expect(script).not.toContain("sync-cache"); expect(script).toContain('set "OCX_REAL_CODEX=C:\\Tools\\codex-real.exe"'); expect(script).toContain('set "OCX_API_TOKEN_FILE='); + expect(script).toContain('set "OCX_BUN_RUNTIME_SOURCE=process"'); expect(script).toContain('set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"'); expect(script).toContain('"%OCX_REAL_CODEX%" %*'); }); @@ -115,7 +117,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 +125,8 @@ 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("gitBashPath(serviceApiTokenFilePath()),"); + expect(source).toContain("bunRuntimeSource,"); }); test("Unix shim accepts an injected token-file path (Git-Bash shims need forward slashes everywhere)", () => { @@ -177,6 +180,7 @@ describe("Codex autostart shim", () => { expect(script).toContain("OCX_SHIM_BYPASS"); expect(script).toContain("Test-Path -LiteralPath"); expect(script).toContain("OPENCODEX_API_AUTH_TOKEN"); + expect(script).toContain("$env:OCX_BUN_RUNTIME_SOURCE = 'process'"); expect(script).toContain("& 'C:\\codex-real.ps1' @args"); }); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index e5af96f0b..c8a874511 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -430,6 +430,7 @@ describe("service memory section (#314 WP4)", () => { expect(text).toContain("OPENCODEX_BUN_PATH is already active"); expect(text).not.toContain("set OPENCODEX_BUN_PATH"); expect(text).toContain("auto-known-bad"); + expect(text).toContain('streamMode "eager-relay"'); }); test("legacy payload keeps runtime source unknown without circular override advice", async () => { diff --git a/tests/windows-tray-runtime-source.test.ts b/tests/windows-tray-runtime-source.test.ts new file mode 100644 index 000000000..e0b186431 --- /dev/null +++ b/tests/windows-tray-runtime-source.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { windowsTrayHostEnvironment } from "../src/tray/windows"; + +test("Windows tray forwards the recorded Bun source and leaves legacy entries unknown", () => { + expect(windowsTrayHostEnvironment( + { bunRuntimeSource: "override" }, + { OCX_BUN_RUNTIME_SOURCE: "bundled", KEEP: "yes" }, + )).toEqual({ OCX_BUN_RUNTIME_SOURCE: "override", KEEP: "yes" }); + + expect(windowsTrayHostEnvironment( + {}, + { OCX_BUN_RUNTIME_SOURCE: "bundled", KEEP: "yes" }, + )).toEqual({ KEEP: "yes" }); +}); + +test("Windows tray resolves its durable Bun path and source as one entry", () => { + const source = readFileSync(join(import.meta.dir, "..", "src", "tray", "windows.ts"), "utf8"); + expect(source).toContain("const bunRuntime = durableBunRuntime();"); + expect(source).toContain("bun: bunRuntime.path"); + expect(source).toContain("bunRuntimeSource: bunRuntime.source"); +}); diff --git a/tests/winsw.test.ts b/tests/winsw.test.ts index d9ae005f9..bd1f5f18d 100644 --- a/tests/winsw.test.ts +++ b/tests/winsw.test.ts @@ -7,7 +7,11 @@ 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: "override" as const, + cli: "C:\\Open Codex\\cli & co\\index.ts", +}; function winswEnvValue(xml: string, name: string): string | null { const match = xml.match(new RegExp(``)); @@ -39,7 +43,7 @@ describe("winsw xml", () => { const xml = buildWinswXml(entry, env); expect(xml).toContain(''); - expect(xml).toMatch(//); + expect(xml).toContain(''); expect(xml).toContain(''); expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir()); From d061583f0eb49245f8a510110958f9700272da49 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:35:16 +0900 Subject: [PATCH 3/5] test(codex): cover non-default shim provenance --- tests/codex-shim.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index 297e40a61..2bb406c43 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -184,6 +184,18 @@ describe("Codex autostart shim", () => { expect(script).toContain("& 'C:\\codex-real.ps1' @args"); }); + test("shim builders preserve explicit Bun runtime provenance", () => { + for (const source of ["override", "bundled"] as const) { + const unix = buildUnixCodexShim("/bin/codex", "/bin/bun", "/cli.ts", undefined, source); + const cmd = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts", source); + const powershell = buildWindowsPowerShellCodexShim("C:\\codex.ps1", "C:\\bun.exe", "C:\\cli.ts", source); + + expect(unix).toContain(`OCX_BUN_RUNTIME_SOURCE='${source}'`); + expect(cmd).toContain(`set "OCX_BUN_RUNTIME_SOURCE=${source}"`); + expect(powershell).toContain(`$env:OCX_BUN_RUNTIME_SOURCE = '${source}'`); + } + }); + test("Unix shim treats executable paths as literals instead of shell interpolation", () => { if (process.platform === "win32") return; From 0c16897bf14f11504a9c8029d9c651f58b450718 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:06:52 +0900 Subject: [PATCH 4/5] fix service runtime provenance guidance --- src/cli/doctor.ts | 6 +++++ src/service.ts | 27 +++++++++++----------- tests/doctor.test.ts | 11 +++++++++ tests/service.test.ts | 52 ++++++++++++++++++++++++++++++------------- 4 files changed, 68 insertions(+), 28 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index ab7a55cf5..5ff5a7139 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -681,6 +681,12 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[] lines.push(" Repair or reinstall the service to add provenance; the conservative auto-known-bad decision remains in effect."); return lines; } + if (d.bunRuntimeSource === "process") { + lines.push(" service is using the process runtime (process.execPath), not a bundled Bun runtime."); + lines.push(" To use a different runtime, set OPENCODEX_BUN_PATH explicitly and reinstall the service."); + lines.push(" You can still opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs)."); + return lines; + } 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)."); diff --git a/src/service.ts b/src/service.ts index 01c21bcb1..2a00302cb 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, durableBunPath, durableBunRuntime } from "./lib/bun-runtime"; +import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "./lib/bun-runtime"; import { isProcessAlive, stopProxy } from "./lib/process-control"; import { serviceApiTokenFilePath } from "./lib/service-secrets"; import { randomUUID } from "node:crypto"; @@ -43,11 +43,11 @@ const TASK = "opencodex-proxy"; export type ServiceBackend = "scheduler" | "native"; -function cliEntry(): { bun: string; cli: string } { +function cliEntry(bunRuntime: DurableBunRuntime, cli = join(import.meta.dir, "cli", "index.ts")): { bun: string; cli: string } { // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts. - return { bun: durableBunPath(), cli: join(import.meta.dir, "cli", "index.ts") }; + return { bun: bunRuntime.path, cli }; } function plistPath(): string { @@ -137,7 +137,7 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState | } function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void { - const { bun, cli } = cliEntry(); + const { bun, cli } = cliEntry(durableBunRuntime()); const state: ServiceInstallState = { version: 2, codexHome: currentCodexHome(), @@ -267,9 +267,8 @@ function writeServiceApiTokenFile(): string | null { return path; } -export function buildPlist(): string { - const { bun, cli } = cliEntry(); - const bunRuntime = durableBunRuntime(); +export function buildPlist(bunRuntime = durableBunRuntime()): string { + const { bun, cli } = cliEntry(bunRuntime); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = process.env.CODEX_HOME?.trim(); @@ -1023,9 +1022,12 @@ function taskXmlRunLevelAcceptable(principal: string): boolean { return value === "leastprivilege" || value === "highestavailable"; } -export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string { - const { bun, cli } = entry; - const bunRuntime = durableBunRuntime(); +export function buildWindowsServiceScript( + bunRuntime = durableBunRuntime(), + port = resolveServiceListenPort(), + cliPath?: string, +): string { + const { bun, cli } = cliEntry(bunRuntime, cliPath); const path = process.env.PATH ?? ""; const lines = [ "@echo off", @@ -1572,9 +1574,8 @@ function unitPath(): string { return join(unitDir(), `${TASK}.service`); } -export function buildUnit(): string { - const { bun, cli } = cliEntry(); - const bunRuntime = durableBunRuntime(); +export function buildUnit(bunRuntime = durableBunRuntime()): string { + const { bun, cli } = cliEntry(bunRuntime); const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim()); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index c8a874511..81e0fd055 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -433,6 +433,17 @@ describe("service memory section (#314 WP4)", () => { expect(text).toContain('streamMode "eager-relay"'); }); + test("guidance gating: process runtime does not offer bundled-runtime remediation", () => { + const text = formatServiceMemoryLines({ + status: "ok", + data: { ...baseData, bunRuntimeSource: "process" }, + }).join("\n"); + + expect(text).toContain("process runtime (process.execPath)"); + expect(text).toContain("OPENCODEX_BUN_PATH explicitly and reinstall the service"); + expect(text).not.toContain("wait for a bundled runtime update"); + }); + test("legacy payload keeps runtime source unknown without circular override advice", async () => { const legacy = { ...baseData }; delete legacy.bunRevision; diff --git a/tests/service.test.ts b/tests/service.test.ts index 215364021..a1d2e97bd 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveConfig } from "../src/config"; +import type { BunRuntimeSource, DurableBunRuntime } from "../src/lib/bun-runtime"; import { windowsEnvIndirectBatchValue } from "../src/lib/win-paths"; import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsServiceScript, buildWindowsTaskXml, deriveWindowsServiceDiagnostic, normalizeServiceSubcommand, parseServiceInstallState, readWindowsSchedulerXmlState, repairService, resolveServiceListenPort, serviceLogPath, serviceStartableFromTray, serviceStatusSummary, windowsTaskRegistrationHealthy } from "../src/service"; import { serviceApiTokenFilePath } from "../src/lib/service-secrets"; @@ -50,6 +51,10 @@ function expectTextToContainPath(text: string, path: string): void { expect(pathVariants(path).some(candidate => text.includes(candidate))).toBe(true); } +function testBunRuntime(path: string, source: BunRuntimeSource = "process"): DurableBunRuntime { + return { path, source, overrideEnv: "OPENCODEX_BUN_PATH" }; +} + describe("service listen-port bake", () => { test("resolveServiceListenPort prefers override, then OCX_BAKE_PORT, then config", () => { process.env.OPENCODEX_HOME = TEST_DIR; @@ -74,14 +79,34 @@ 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(testBunRuntime("C:\\OpenCodex\\bun.exe")); expect(script).toContain("start --port 13337"); expect(buildPlist()).toContain("start --port 13337"); expect(buildUnit()).toContain("start --port 13337"); }); + test("service artifacts pair each runtime source with its selected Bun path", () => { + const runtimes: DurableBunRuntime[] = [ + testBunRuntime("C:\\OpenCodex\\override-bun.exe", "override"), + testBunRuntime("C:\\OpenCodex\\bundled-bun.exe", "bundled"), + testBunRuntime("C:\\OpenCodex\\process-bun.exe", "process"), + ]; + + for (const runtime of runtimes) { + const script = buildWindowsServiceScript(runtime); + const plist = buildPlist(runtime); + const unit = buildUnit(runtime); + expect(script).toContain(`set "OCX_BUN_RUNTIME_SOURCE=${runtime.source}"`); + expect(plist).toContain(`OCX_BUN_RUNTIME_SOURCE${runtime.source}`); + expect(unit).toContain(`Environment="OCX_BUN_RUNTIME_SOURCE=${runtime.source}"`); + expectTextToContainPath(script, runtime.path); + expectTextToContainPath(plist, runtime.path); + expectTextToContainPath(unit, runtime.path); + } + }); + test("scheduler, launchd, and systemd artifacts bake the selected Bun runtime source", () => { - const script = buildWindowsServiceScript({ bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\OpenCodex\\cli.ts" }); + const script = buildWindowsServiceScript(testBunRuntime("C:\\OpenCodex\\bun.exe")); expect(script).toMatch(/set "OCX_BUN_RUNTIME_SOURCE=(override|bundled|process)"/); expect(buildPlist()).toMatch(/OCX_BUN_RUNTIME_SOURCE<\/key>(override|bundled|process)<\/string>/); expect(buildUnit()).toMatch(/Environment="OCX_BUN_RUNTIME_SOURCE=(override|bundled|process)"/); @@ -498,10 +523,11 @@ describe("Windows service task", () => { }); test("escapes service executable paths through variables", () => { - const script = buildWindowsServiceScript({ - bun: "C:\\Bun&Dir\\100%bun^\\bun.exe", - cli: "C:\\OpenCodex&Dir\\cli.ts", - }); + const script = buildWindowsServiceScript( + testBunRuntime("C:\\Bun&Dir\\100%bun^\\bun.exe"), + undefined, + "C:\\OpenCodex&Dir\\cli.ts", + ); expect(script).toContain('set "OCX_BUN=C:\\Bun&Dir\\100%%bun^^\\bun.exe"'); expect(script).toContain('set "OCX_CLI=C:\\OpenCodex&Dir\\cli.ts"'); @@ -510,7 +536,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(testBunRuntime("C:\\OpenCodex\\bun.exe")); expect(script).toContain("chcp 65001 >nul"); expect(script.indexOf("chcp 65001 >nul")).toBeLessThan(script.indexOf('set "OCX_SERVICE=1"')); @@ -524,10 +550,9 @@ describe("Windows service task", () => { try { process.env.USERPROFILE = "C:\\Users\\한글사용자"; process.env.APPDATA = "C:\\Users\\한글사용자\\AppData\\Roaming"; - const script = buildWindowsServiceScript({ - bun: "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\bun\\bin\\bun.exe", - cli: "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\opencodex\\src\\cli.ts", - }); + const script = buildWindowsServiceScript(testBunRuntime( + "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\bun\\bin\\bun.exe", + ), undefined, "C:\\Users\\한글사용자\\AppData\\Roaming\\npm\\node_modules\\opencodex\\src\\cli.ts"); expect(script).toContain('set "OCX_BUN=%APPDATA%\\npm\\node_modules\\bun\\bin\\bun.exe"'); expect(script).toContain('set "OCX_CLI=%APPDATA%\\npm\\node_modules\\opencodex\\src\\cli.ts"'); @@ -548,10 +573,7 @@ describe("Windows service task", () => { process.env.CODEX_HOME = "C:\\codex-home"; process.env.OPENCODEX_HOME = TEST_DIR; process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret"; - const script = buildWindowsServiceScript({ - bun: "C:\\OpenCodex\\bun.exe", - cli: "C:\\OpenCodex\\cli.ts", - }); + const script = buildWindowsServiceScript(testBunRuntime("C:\\OpenCodex\\bun.exe")); expectTextToContainPath(script, serviceLogPath()); expect(script).toContain('set "OCX_SERVICE_LOG='); From b2034002c0156c35a5d44487e03dd8223a10a71e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:21:17 +0900 Subject: [PATCH 5/5] fix(service): preserve runtime helper compatibility --- src/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service.ts b/src/service.ts index 2a00302cb..6973b09dc 100644 --- a/src/service.ts +++ b/src/service.ts @@ -43,7 +43,7 @@ const TASK = "opencodex-proxy"; export type ServiceBackend = "scheduler" | "native"; -function cliEntry(bunRuntime: DurableBunRuntime, cli = join(import.meta.dir, "cli", "index.ts")): { bun: string; cli: string } { +function cliEntry(bunRuntime: DurableBunRuntime = durableBunRuntime(), cli = join(import.meta.dir, "cli", "index.ts")): { bun: string; cli: string } { // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.