Skip to content

Commit 3bf4c76

Browse files
committed
fix(doctor): report Bun runtime provenance
1 parent fa5780d commit 3bf4c76

12 files changed

Lines changed: 141 additions & 19 deletions

File tree

bin/ocx.mjs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ function bunBinDir() {
289289
}
290290

291291
const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";
292+
const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";
292293

293294
function findBunBinary(bunDir) {
294295
// The npm `bun` package ships the binary as bin/bun.exe on every platform;
@@ -319,7 +320,7 @@ function resolveBun() {
319320
const override = process.env[BUN_OVERRIDE_ENV]?.trim();
320321
if (override) {
321322
const overridePath = resolve(override);
322-
if (isRealBunBinary(overridePath)) return overridePath;
323+
if (isRealBunBinary(overridePath)) return { path: overridePath, source: "override" };
323324
console.error(
324325
`opencodex: ${BUN_OVERRIDE_ENV} is missing, unreadable, or not a complete Bun binary; falling back to the bundled runtime.`,
325326
);
@@ -333,7 +334,7 @@ function resolveBun() {
333334
}
334335

335336
let bin = findBunBinary(bunDir);
336-
if (bin) return bin;
337+
if (bin) return { path: bin, source: "bundled" };
337338

338339
// Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the
339340
// ~450-byte placeholder stub. Run the bun package's own installer once.
@@ -343,7 +344,7 @@ function resolveBun() {
343344
if (r.status === 0) bin = findBunBinary(bunDir);
344345
}
345346
if (!bin) fail("Bun binary missing after install attempt.");
346-
return bin;
347+
return { path: bin, source: "bundled" };
347348
}
348349

349350
// `ocx update --help` prints usage and exits WITHOUT side effects. The npm launcher
@@ -361,7 +362,8 @@ if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstal
361362
runNpmSelfUpdate();
362363
}
363364

364-
const bun = resolveBun();
365+
const runtime = resolveBun();
366+
const bun = runtime.path;
365367

366368
// Run the Bun child asynchronously and FORWARD termination signals to it, then wait
367369
// for its graceful shutdown before this launcher exits. The previous blocking
@@ -386,7 +388,11 @@ const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
386388
.filter(name => typeof process.env[name] === "string" && process.env[name] !== "");
387389
const child = spawn(bun, [cliPath, ...process.argv.slice(2)], {
388390
stdio: "inherit",
389-
env: { ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(",") },
391+
env: {
392+
...process.env,
393+
OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(","),
394+
[BUN_RUNTIME_SOURCE_ENV]: runtime.source,
395+
},
390396
});
391397

392398
// Windows has no real POSIX signals (no SIGHUP); forwarding is best-effort there.

src/cli/doctor.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { gracefulStopHost } from "../lib/process-control";
1616
import { maskAccountId } from "../lib/privacy";
1717
import { PROXY_ENV_KEYS, proxyEnvPresent } from "../lib/proxy-env";
1818
import { configuredAdminToken } from "../lib/admin-secrets";
19+
import type { BunRuntimeSource } from "../lib/bun-runtime";
1920
import { readCodexTokens } from "../codex/auth-collision";
2021
import { collectOrcaCodexHomeDiagnostic, resolveCodexHomeDir as resolveCodexHomeDirImpl, isWslRuntime, listWslWindowsCodexHomes, wslAutomountRoot, type CodexHomeDeps } from "../codex/home";
2122
import { findCodexOnPath, isWindowsInteropDir } from "../codex/shim";
@@ -523,6 +524,8 @@ export async function probeWham(fetchImpl: typeof fetch = fetch): Promise<WhamPr
523524
export type ServiceMemoryData = {
524525
pid: number;
525526
bunVersion: string;
527+
bunRevision?: string;
528+
bunRuntimeSource?: BunRuntimeSource;
526529
platform: string;
527530
rss: number;
528531
heapUsed: number;
@@ -579,6 +582,12 @@ export async function fetchServiceMemory(
579582
data: {
580583
pid: body.pid,
581584
bunVersion: body.bunVersion,
585+
bunRevision: typeof body.bunRevision === "string" ? body.bunRevision : undefined,
586+
bunRuntimeSource: body.bunRuntimeSource === "override"
587+
|| body.bunRuntimeSource === "bundled"
588+
|| body.bunRuntimeSource === "process"
589+
? body.bunRuntimeSource
590+
: undefined,
582591
platform: typeof body.platform === "string" ? body.platform : "unknown",
583592
rss: body.rss,
584593
heapUsed: typeof body.heapUsed === "number" ? body.heapUsed : 0,
@@ -625,7 +634,13 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
625634
return lines;
626635
}
627636
const d = report.data;
628-
lines.push(` ok service pid ${d.pid}: Bun ${d.bunVersion} on ${d.platform}`);
637+
const sourceLabel = d.bunRuntimeSource === "override"
638+
? "OPENCODEX_BUN_PATH override"
639+
: d.bunRuntimeSource ?? "unknown";
640+
lines.push(
641+
` ok service pid ${d.pid}: Bun ${d.bunVersion} on ${d.platform}`
642+
+ `${d.bunRevision ? ` (revision=${d.bunRevision}, source=${sourceLabel})` : ` (source=${sourceLabel})`}`,
643+
);
629644
const observed = observedMemory(d);
630645
const observedBytes = d.observedBytes ?? d.watchdog?.observedBytes ?? observed.bytes;
631646
const observedMetric = d.observedMetric ?? d.watchdog?.observedMetric ?? observed.metric;
@@ -652,9 +667,19 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
652667
} else {
653668
lines.push(" !! high RSS, indeterminate split — capture two doctor runs over time to see the trend");
654669
}
655-
// Version-claiming (never binary-claiming): the endpoint cannot distinguish
656-
// the bundled binary from an OPENCODEX_BUN_PATH override of the same version.
670+
// Runtime provenance is launcher-asserted and allowlisted. A missing marker is
671+
// intentionally unknown; never infer an already-running service from this shell.
657672
if (d.platform === "win32" && d.eagerRelay?.reason === "auto-known-bad") {
673+
if (d.bunRuntimeSource === "override") {
674+
lines.push(" OPENCODEX_BUN_PATH is already active, but this runtime remains unvalidated for automatic eager relay.");
675+
lines.push(" The conservative auto-known-bad decision remains in effect; bunRevision is informational only.");
676+
return lines;
677+
}
678+
if (d.bunRuntimeSource === undefined) {
679+
lines.push(" runtime source was not reported by this legacy service/payload; an active override cannot be determined safely.");
680+
lines.push(" Repair or reinstall the service to add provenance; the conservative auto-known-bad decision remains in effect.");
681+
return lines;
682+
}
658683
lines.push(` service is running Bun ${d.bunVersion} on Windows — a version affected by the upstream Bun memory issue.`);
659684
lines.push(" Options: wait for a bundled runtime update, or set OPENCODEX_BUN_PATH to a runtime you trust (unvalidated — own risk),");
660685
lines.push(" or opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs).");

src/lib/bun-runtime.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ export { isRealBunBinary };
1919
const require = createRequire(import.meta.url);
2020

2121
const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";
22+
export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";
23+
24+
export type BunRuntimeSource = "override" | "bundled" | "process";
2225

2326
export type DurableBunRuntime = {
2427
path: string;
25-
source: "override" | "bundled" | "process";
28+
source: BunRuntimeSource;
2629
overrideEnv: typeof BUN_OVERRIDE_ENV;
2730
};
2831

@@ -60,6 +63,22 @@ export function durableBunRuntime(): DurableBunRuntime {
6063
return { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV };
6164
}
6265

66+
/**
67+
* Runtime origin asserted by the launcher that started this process.
68+
*
69+
* This is diagnostic provenance, not a security boundary. Accept only the
70+
* launcher allowlist and leave a missing/invalid marker unknown. In particular,
71+
* never inspect the current shell to guess how an already-running service began.
72+
*/
73+
export function reportedBunRuntimeSource(
74+
env: NodeJS.ProcessEnv = process.env,
75+
): BunRuntimeSource | undefined {
76+
const source = env[BUN_RUNTIME_SOURCE_ENV]?.trim();
77+
return source === "override" || source === "bundled" || source === "process"
78+
? source
79+
: undefined;
80+
}
81+
6382
/**
6483
* Bun path to bake into durable artifacts (launchd/systemd/Task Scheduler and
6584
* the Codex auto-start shim). Prefer the bundled binary — it lives under the

src/lib/winsw.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { homedir } from "node:os";
2020
import { join, resolve } from "node:path";
2121
import { expandUserPath, getConfigDir, loadConfig } from "../config";
2222
import { recordOwnedConfigPath } from "./config-ownership";
23-
import { durableBunPath } from "./bun-runtime";
23+
import { BUN_RUNTIME_SOURCE_ENV, durableBunPath, durableBunRuntime } from "./bun-runtime";
2424
import { serviceApiTokenFilePath } from "./service-secrets";
2525

2626
export const WINSW_VERSION = "2.12.0";
@@ -74,6 +74,7 @@ export interface WinswEntry {
7474
* user's interactive PATH, which provider subprocesses may need.
7575
*/
7676
export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env, port?: number): string {
77+
const bunRuntime = durableBunRuntime();
7778
const domain = env.USERDOMAIN?.trim() || ".";
7879
const user = env.USERNAME?.trim() || "";
7980
const listenPort = (() => {
@@ -95,6 +96,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
9596
const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
9697
const envLines = [
9798
` <env name="OCX_SERVICE" value="1"/>`,
99+
` <env name="${BUN_RUNTIME_SOURCE_ENV}" value="${bunRuntime.source}"/>`,
98100
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
99101
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
100102
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,

src/server/management/system-routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* dashboard drain-and-restart confirm UX — never request bodies or IDs.
2323
*/
2424
import { selectEagerPath } from "../../lib/bun-stream-caps";
25+
import { reportedBunRuntimeSource } from "../../lib/bun-runtime";
2526
import { getActiveTurnCount, isDraining } from "../lifecycle";
2627
import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
2728
import { responseStateMetrics } from "../../responses/state";
@@ -78,6 +79,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
7879
pid: process.pid,
7980
bunVersion: Bun.version,
8081
bunRevision: Bun.revision,
82+
bunRuntimeSource: reportedBunRuntimeSource(),
8183
platform: process.platform,
8284
uptimeSeconds: process.uptime(),
8385
rss: usage.rss,

src/service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { loadConfig } from "./config";
1515
import { restoreNativeCodex } from "./codex/inject";
1616
import { stripGrokConfig } from "./grok/inject";
1717
import { isWslRuntime } from "./codex/home";
18-
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
18+
import { BUN_RUNTIME_SOURCE_ENV, durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
1919
import { isProcessAlive, stopProxy } from "./lib/process-control";
2020
import { serviceApiTokenFilePath } from "./lib/service-secrets";
2121
import { randomUUID } from "node:crypto";
@@ -269,12 +269,14 @@ function writeServiceApiTokenFile(): string | null {
269269

270270
export function buildPlist(): string {
271271
const { bun, cli } = cliEntry();
272+
const bunRuntime = durableBunRuntime();
272273
const log = logPath();
273274
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
274275
const codexHome = process.env.CODEX_HOME?.trim();
275276
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
276277
const envLines = [
277278
` <key>OCX_SERVICE</key><string>1</string>`,
279+
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntime.source}</string>`,
278280
` <key>PATH</key><string>${plistString(path)}</string>`,
279281
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
280282
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
@@ -1032,6 +1034,7 @@ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServ
10321034
// it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
10331035
"chcp 65001 >nul",
10341036
windowsBatchSet("OCX_SERVICE", "1"),
1037+
windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntime.source),
10351038
windowsBatchSet("PATH", path, "pathList"),
10361039
windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
10371040
windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
@@ -1571,12 +1574,14 @@ function unitPath(): string {
15711574

15721575
export function buildUnit(): string {
15731576
const { bun, cli } = cliEntry();
1577+
const bunRuntime = durableBunRuntime();
15741578
const log = logPath();
15751579
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
15761580
const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
15771581
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
15781582
const envLines = [
15791583
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
1584+
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntime.source),
15801585
systemdEnvironmentAssignment("PATH", path),
15811586
codexHome,
15821587
opencodexHome,
@@ -2163,4 +2168,3 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
21632168
process.exit(1);
21642169
}
21652170
}
2166-

structure/05_gui-and-management-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ this document owns is which module holds which area and what invariant that area
7878
| 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. |
7979
| 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). |
8080
| 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. |
81-
| 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. |
81+
| 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. |
8282
| Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. |
8383
| 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. |
8484
| 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. |

tests/doctor.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ describe("service memory section (#314 WP4)", () => {
326326
const baseData: ServiceMemoryData = {
327327
pid: 4242,
328328
bunVersion: "1.3.14",
329+
bunRevision: "1.3.14+0d9b296a",
330+
bunRuntimeSource: "bundled",
329331
platform: "win32",
330332
rss: 5 * 1024 ** 3,
331333
heapUsed: 200 * 1024 ** 2,
@@ -355,6 +357,11 @@ describe("service memory section (#314 WP4)", () => {
355357
(async () => Response.json({ hello: "world" })) as typeof fetch);
356358
expect(malformed.status).toBe("unreachable");
357359
if (malformed.status === "unreachable") expect(malformed.error).toBe("malformed response");
360+
361+
const invalidSource = await fetchServiceMemory("127.0.0.1", 10100, null,
362+
(async () => Response.json({ ...baseData, bunRuntimeSource: "shell" })) as typeof fetch);
363+
expect(invalidSource.status).toBe("ok");
364+
if (invalidSource.status === "ok") expect(invalidSource.data.bunRuntimeSource).toBeUndefined();
358365
});
359366

360367
test("identity labels: doctor process is never presented as the service", () => {
@@ -401,11 +408,43 @@ describe("service memory section (#314 WP4)", () => {
401408
expect(lines.some(l => l.includes("looks normal"))).toBe(false);
402409
});
403410

404-
test("guidance gating: win32 + auto-known-bad prints version-claiming guidance", () => {
411+
test("guidance gating: bundled win32 + auto-known-bad offers an override", () => {
405412
const lines = formatServiceMemoryLines({ status: "ok", data: baseData });
413+
expect(lines.some(l => l.includes("source=bundled"))).toBe(true);
406414
expect(lines.some(l => l.includes("OPENCODEX_BUN_PATH"))).toBe(true);
407-
// Version-claiming, never binary-claiming.
408-
expect(lines.join("\n")).not.toContain("bundled binary");
415+
});
416+
417+
test("guidance gating: active override reports revision without repeating setup advice", () => {
418+
const lines = formatServiceMemoryLines({
419+
status: "ok",
420+
data: {
421+
...baseData,
422+
bunVersion: "1.4.0",
423+
bunRevision: "1.4.0-canary.1+5f65d3785",
424+
bunRuntimeSource: "override",
425+
},
426+
});
427+
const text = lines.join("\n");
428+
expect(text).toContain("revision=1.4.0-canary.1+5f65d3785");
429+
expect(text).toContain("source=OPENCODEX_BUN_PATH override");
430+
expect(text).toContain("OPENCODEX_BUN_PATH is already active");
431+
expect(text).not.toContain("set OPENCODEX_BUN_PATH");
432+
expect(text).toContain("auto-known-bad");
433+
});
434+
435+
test("legacy payload keeps runtime source unknown without circular override advice", async () => {
436+
const legacy = { ...baseData };
437+
delete legacy.bunRevision;
438+
delete legacy.bunRuntimeSource;
439+
const report = await fetchServiceMemory("127.0.0.1", 10100, null,
440+
(async () => Response.json(legacy)) as typeof fetch);
441+
expect(report.status).toBe("ok");
442+
if (report.status !== "ok") return;
443+
expect(report.data.bunRuntimeSource).toBeUndefined();
444+
const text = formatServiceMemoryLines(report).join("\n");
445+
expect(text).toContain("source=unknown");
446+
expect(text).toContain("legacy service/payload");
447+
expect(text).not.toContain("set OPENCODEX_BUN_PATH");
409448
});
410449

411450
test("guidance gating: darwin auto-off or fixed Windows runtime prints no override guidance", () => {

0 commit comments

Comments
 (0)