diff --git a/bin/ocx.mjs b/bin/ocx.mjs index c69ee692e..319c2ab86 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -289,6 +289,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; @@ -319,7 +320,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.`, ); @@ -333,7 +334,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. @@ -343,7 +344,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 @@ -361,7 +362,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 @@ -386,7 +388,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/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 1128d95fb..ab7a55cf5 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 `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/bun-runtime.ts b/src/lib/bun-runtime.ts index b352d84ac..2ca17e278 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -19,10 +19,13 @@ export { isRealBunBinary }; const require = createRequire(import.meta.url); const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; +export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; + +export type BunRuntimeSource = "override" | "bundled" | "process"; export type DurableBunRuntime = { path: string; - source: "override" | "bundled" | "process"; + source: BunRuntimeSource; overrideEnv: typeof BUN_OVERRIDE_ENV; }; @@ -60,6 +63,22 @@ export function durableBunRuntime(): DurableBunRuntime { return { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV }; } +/** + * Runtime origin asserted by the launcher that started this process. + * + * This is diagnostic provenance, not a security boundary. Accept only the + * launcher allowlist and leave a missing/invalid marker unknown. In particular, + * never inspect the current shell to guess how an already-running service began. + */ +export function reportedBunRuntimeSource( + env: NodeJS.ProcessEnv = process.env, +): BunRuntimeSource | undefined { + const source = env[BUN_RUNTIME_SOURCE_ENV]?.trim(); + return source === "override" || source === "bundled" || source === "process" + ? source + : undefined; +} + /** * Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and * the Codex auto-start shim). Prefer the bundled binary — it lives under the diff --git a/src/lib/winsw.ts b/src/lib/winsw.ts index 5222e1228..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 { durableBunPath } 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; } @@ -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, @@ -371,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/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, @@ -2163,4 +2168,3 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise, + 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/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 27d57e9d2..bd39f1996 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -78,7 +78,7 @@ this document owns is which module holds which area and what invariant that area | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), and the logical maximum thread count. Selecting `v2` enables the native flag and migrates `[agents] max_threads` to the v2 key; selecting `v1` disables it and migrates the same value back. `default` leaves the native flag unchanged. PUT accepts `enabled`, `multiAgentMode`, and/or the compatibility-named `maxConcurrentThreadsPerSession`; contradictory mode/flag pairs are rejected before writes. Every transition is rollback-safe and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | | Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | -| System | `POST /api/system/restart` restarts the proxy in place. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; rides the standard management auth gate and must never move to unauthenticated `/healthz`. Consumed by `ocx doctor`'s Memory/runtime section and the dashboard Memory observability card. | +| System | `POST /api/system/restart` restarts the proxy in place. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision/runtime source, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. `bunRuntimeSource` is optional diagnostic provenance asserted by the launcher from the allowlist `override`, `bundled`, or `process`; it is not a security boundary. Legacy services and invalid or missing markers omit the field, and reporting must not inspect the current shell to guess an already-running process's origin. `bunRevision` remains informational and neither field relaxes the conservative eager-relay policy. Scalar-only payload; rides the standard management auth gate and must never move to unauthenticated `/healthz`. Consumed by `ocx doctor`'s Memory/runtime section and the dashboard Memory observability card. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | | Sidecar/shadow-call settings | `src/server/management/config-routes.ts` — `GET/PUT /api/sidecar-settings` and `GET/PUT /api/shadow-call-settings`. PUT accepts model and backend plus optional `webSearch.reasoning` and `vision.maxDescriptionsPerTurn`; the read and PUT-response payload reports model, backend, and the vision per-turn limit. Credentials live in the provider and OAuth stores instead. Both shadow-call responses also report the resolved `sourceModels` — the prefixes the runtime actually intercepts (`src/lib/shadow-call.ts`, default `gpt-5.4-mini` + `gpt-5.6-luna`), so no client hard-codes a helper slug that a Codex release can invalidate. | diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index a1bdff3f4..2bb406c43 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,9 +180,22 @@ 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"); }); + 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; diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index b6e1ff8e2..c8a874511 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -326,6 +326,8 @@ describe("service memory section (#314 WP4)", () => { 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,44 @@ 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"); + expect(text).toContain('streamMode "eager-relay"'); + }); + + 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 e2005372a..d0d307025 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/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 eb42df8ee..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,6 +43,7 @@ describe("winsw xml", () => { const xml = buildWinswXml(entry, env); expect(xml).toContain(''); + expect(xml).toContain(''); expect(xml).toContain(''); expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir());