Skip to content

Commit 32bd685

Browse files
committed
fix(doctor): address runtime provenance review
1 parent 3bf4c76 commit 32bd685

9 files changed

Lines changed: 125 additions & 26 deletions

File tree

docs-site/src/content/docs/troubleshooting/windows-memory.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ runtime the leak itself remains an upstream problem:
3636
Windows working-set/RSS counters can under-report committed external
3737
retention.
3838
- **`ocx doctor`** — a "Memory / runtime" section shows the *service*
39-
process's Bun version, RSS, external/ArrayBuffers counters, JS-heap context,
40-
and stream-mode decision. On the bundled Bun 1.3.14 runtime, `heapUsed` /
39+
process's Bun version/revision, launcher-reported runtime source, RSS,
40+
external/ArrayBuffers counters, JS-heap context, and stream-mode decision.
41+
Source is allowlisted as `override`, `bundled`, or `process`; an older service
42+
or invalid/missing marker is shown as unknown instead of being guessed from
43+
the current shell. An active override remains unvalidated and keeps the
44+
conservative runtime gate, but doctor no longer repeats the already-completed
45+
`OPENCODEX_BUN_PATH` setup step. On the bundled Bun 1.3.14 runtime, `heapUsed` /
4146
`jscHeap` alone are not a leak discriminator; compare observed memory with
4247
`responseState` and repeated samples before assigning an app-level leak.
4348
- **`GET /api/system/memory`** — the same data over the authenticated

src/cli/doctor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,7 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
673673
if (d.bunRuntimeSource === "override") {
674674
lines.push(" OPENCODEX_BUN_PATH is already active, but this runtime remains unvalidated for automatic eager relay.");
675675
lines.push(" The conservative auto-known-bad decision remains in effect; bunRevision is informational only.");
676+
lines.push(" You can still opt into streamMode \"eager-relay\" via PUT /api/settings (crash risk on this runtime; see docs).");
676677
return lines;
677678
}
678679
if (d.bunRuntimeSource === undefined) {

src/codex/shim.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
writeFileSync,
2020
} from "node:fs";
2121
import { getConfigDir } from "../config";
22-
import { durableBunPath } from "../lib/bun-runtime";
22+
import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type BunRuntimeSource } from "../lib/bun-runtime";
2323
import { isProcessAlive } from "../lib/process-control";
2424
import { serviceApiTokenFilePath } from "../lib/service-secrets";
2525
import { recordOwnedConfigPath } from "../lib/config-ownership";
@@ -120,11 +120,16 @@ export type CodexShimAutoRestoreResult =
120120
| { status: "ineligible" | "deferred"; message?: string }
121121
| { status: "restored"; message: string };
122122

123-
function cliEntry(): { bun: string; cli: string } {
124-
// Bundled Bun path (survives `ocx update`); all three shim builders
125-
// (Unix / Windows cmd / Windows PowerShell) receive it via this entry.
123+
function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
124+
// Resolve the durable path and its diagnostic provenance together; all three shim
125+
// builders (Unix / Windows cmd / Windows PowerShell) receive the same pair.
126126
// This module lives in src/codex/, the CLI entry in src/cli/index.ts.
127-
return { bun: durableBunPath(), cli: join(import.meta.dir, "..", "cli", "index.ts") };
127+
const bunRuntime = durableBunRuntime();
128+
return {
129+
bun: bunRuntime.path,
130+
bunRuntimeSource: bunRuntime.source,
131+
cli: join(import.meta.dir, "..", "cli", "index.ts"),
132+
};
128133
}
129134

130135
function commandNames(name: string): string[] {
@@ -366,11 +371,19 @@ function shQuote(value: string): string {
366371
return `'${value.replace(/'/g, "'\\''")}'`;
367372
}
368373

369-
export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, tokenFile = serviceApiTokenFilePath()): string {
374+
export function buildUnixCodexShim(
375+
realCodexPath: string,
376+
bunPath: string,
377+
cliPath: string,
378+
tokenFile = serviceApiTokenFilePath(),
379+
bunRuntimeSource: BunRuntimeSource = "process",
380+
): string {
370381
const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
371382
const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|");
372383
return `#!/usr/bin/env sh
373384
# ${SHIM_MARKER}
385+
${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)}
386+
export ${BUN_RUNTIME_SOURCE_ENV}
374387
if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
375388
OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})"
376389
export OPENCODEX_API_AUTH_TOKEN
@@ -431,14 +444,20 @@ function windowsBatchSet(name: string, value: string): string {
431444
return `set "${name}=${windowsEnvIndirectBatchValue(value, windowsBatchValue)}"`;
432445
}
433446

434-
export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
447+
export function buildWindowsCodexShim(
448+
realCodexPath: string,
449+
bunPath: string,
450+
cliPath: string,
451+
bunRuntimeSource: BunRuntimeSource = "process",
452+
): string {
435453
const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n");
436454
const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n");
437455
return `@echo off\r
438456
rem ${SHIM_MARKER}\r
439457
${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r
440458
${windowsBatchSet("OCX_BUN", bunPath)}\r
441459
${windowsBatchSet("OCX_CLI", cliPath)}\r
460+
${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r
442461
${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r
443462
if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
444463
if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
@@ -472,12 +491,18 @@ function psString(value: string): string {
472491
return `'${value.replace(/'/g, "''")}'`;
473492
}
474493

475-
export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
494+
export function buildWindowsPowerShellCodexShim(
495+
realCodexPath: string,
496+
bunPath: string,
497+
cliPath: string,
498+
bunRuntimeSource: BunRuntimeSource = "process",
499+
): string {
476500
const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", ");
477501
const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", ");
478502
const tokenFile = serviceApiTokenFilePath();
479503
return `#!/usr/bin/env pwsh
480504
# ${SHIM_MARKER}
505+
$env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)}
481506
if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) {
482507
$env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim()
483508
}
@@ -601,25 +626,35 @@ function gitBashPath(path: string): string {
601626
}
602627

603628
function writeShim(wrapperPath: string, realCodexPath: string): void {
604-
const { bun, cli } = cliEntry();
629+
const { bun, bunRuntimeSource, cli } = cliEntry();
605630
if (process.platform === "win32") {
606631
const lower = wrapperPath.toLowerCase();
607632
if (lower.endsWith(".ps1")) {
608633
// UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
609634
// codepage, which mangles non-ASCII paths embedded in the shim.
610-
writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli)}`, "utf8");
635+
writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8");
611636
} else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
612-
writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8");
637+
writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8");
613638
} else {
614639
// Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
615640
writeFileSync(
616641
wrapperPath,
617-
buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath())),
642+
buildUnixCodexShim(
643+
gitBashPath(realCodexPath),
644+
gitBashPath(bun),
645+
gitBashPath(cli),
646+
gitBashPath(serviceApiTokenFilePath()),
647+
bunRuntimeSource,
648+
),
618649
"utf8",
619650
);
620651
}
621652
} else {
622-
writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli), "utf8");
653+
writeFileSync(
654+
wrapperPath,
655+
buildUnixCodexShim(realCodexPath, bun, cli, serviceApiTokenFilePath(), bunRuntimeSource),
656+
"utf8",
657+
);
623658
chmodSync(wrapperPath, 0o755);
624659
}
625660
}

src/lib/winsw.ts

Lines changed: 9 additions & 4 deletions
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 { BUN_RUNTIME_SOURCE_ENV, durableBunPath, durableBunRuntime } from "./bun-runtime";
23+
import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type BunRuntimeSource } from "./bun-runtime";
2424
import { serviceApiTokenFilePath } from "./service-secrets";
2525

2626
export const WINSW_VERSION = "2.12.0";
@@ -64,6 +64,7 @@ function currentCodexHomeAbsolute(): string {
6464

6565
export interface WinswEntry {
6666
bun: string;
67+
bunRuntimeSource: BunRuntimeSource;
6768
cli: string;
6869
}
6970

@@ -74,7 +75,6 @@ export interface WinswEntry {
7475
* user's interactive PATH, which provider subprocesses may need.
7576
*/
7677
export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = process.env, port?: number): string {
77-
const bunRuntime = durableBunRuntime();
7878
const domain = env.USERDOMAIN?.trim() || ".";
7979
const user = env.USERNAME?.trim() || "";
8080
const listenPort = (() => {
@@ -96,7 +96,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
9696
const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
9797
const envLines = [
9898
` <env name="OCX_SERVICE" value="1"/>`,
99-
` <env name="${BUN_RUNTIME_SOURCE_ENV}" value="${bunRuntime.source}"/>`,
99+
` <env name="${BUN_RUNTIME_SOURCE_ENV}" value="${entry.bunRuntimeSource}"/>`,
100100
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
101101
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
102102
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
@@ -373,5 +373,10 @@ export function winswStatusSummary(): string {
373373

374374
/** Default entry mirrors the Task Scheduler baking: durable Bun + cli.ts. */
375375
export function defaultWinswEntry(cliDir: string): WinswEntry {
376-
return { bun: durableBunPath(), cli: join(cliDir, "cli", "index.ts") };
376+
const bunRuntime = durableBunRuntime();
377+
return {
378+
bun: bunRuntime.path,
379+
bunRuntimeSource: bunRuntime.source,
380+
cli: join(cliDir, "cli", "index.ts"),
381+
};
377382
}

src/tray/windows.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync,
44
import { homedir } from "node:os";
55
import { join, resolve } from "node:path";
66
import { expandUserPath, getConfigDir } from "../config";
7-
import { durableBunPath } from "../lib/bun-runtime";
7+
import { BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type BunRuntimeSource } from "../lib/bun-runtime";
88
import { forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
99
import { recordOwnedConfigPath } from "../lib/config-ownership";
1010

@@ -20,6 +20,7 @@ const TRAY_ICON_FILES = [
2020

2121
export interface WindowsTrayEntry {
2222
bun: string;
23+
bunRuntimeSource?: BunRuntimeSource;
2324
cli: string;
2425
script: string;
2526
codexHome: string;
@@ -81,8 +82,10 @@ function currentCodexHome(): string {
8182
}
8283

8384
function currentEntry(): WindowsTrayEntry {
85+
const bunRuntime = durableBunRuntime();
8486
return {
85-
bun: durableBunPath(),
87+
bun: bunRuntime.path,
88+
bunRuntimeSource: bunRuntime.source,
8689
cli: join(import.meta.dir, "..", "cli", "index.ts"),
8790
script: installedTrayScriptPath(),
8891
codexHome: currentCodexHome(),
@@ -522,9 +525,27 @@ function parseTrayHostEntry(): WindowsTrayEntry {
522525
if (typeof value[key] !== "string") throw new Error(`Invalid tray host field: ${key}`);
523526
safePath(value[key]);
524527
}
528+
if (
529+
value.bunRuntimeSource !== undefined
530+
&& value.bunRuntimeSource !== "override"
531+
&& value.bunRuntimeSource !== "bundled"
532+
&& value.bunRuntimeSource !== "process"
533+
) {
534+
throw new Error("Invalid tray host field: bunRuntimeSource");
535+
}
525536
return value as WindowsTrayEntry;
526537
}
527538

539+
export function windowsTrayHostEnvironment(
540+
entry: Pick<WindowsTrayEntry, "bunRuntimeSource">,
541+
env: NodeJS.ProcessEnv = process.env,
542+
): NodeJS.ProcessEnv {
543+
const childEnv = { ...env };
544+
if (entry.bunRuntimeSource) childEnv[BUN_RUNTIME_SOURCE_ENV] = entry.bunRuntimeSource;
545+
else delete childEnv[BUN_RUNTIME_SOURCE_ENV];
546+
return childEnv;
547+
}
548+
528549
/** Detached Bun host keeps the attached WinForms PowerShell process alive. */
529550
export async function runWindowsTrayHost(): Promise<void> {
530551
assertWindows();
@@ -535,7 +556,7 @@ export async function runWindowsTrayHost(): Promise<void> {
535556
const child = spawn(windowsPowerShellPath(), windowsTrayProcessArgs(entry, "Run", process.pid), {
536557
stdio: "ignore",
537558
windowsHide: true,
538-
env: process.env,
559+
env: windowsTrayHostEnvironment(entry),
539560
});
540561
await new Promise<void>((resolvePromise, rejectPromise) => {
541562
child.once("error", rejectPromise);

tests/codex-shim.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ describe("Codex autostart shim", () => {
5858
expect(script).not.toContain("sync-cache");
5959
expect(script).toContain("exec '/usr/local/bin/codex-real' \"$@\"");
6060
expect(script).toContain("OPENCODEX_API_AUTH_TOKEN");
61+
expect(script).toContain("OCX_BUN_RUNTIME_SOURCE='process'");
6162
});
6263

6364
test("builds a Windows shim that starts ocx before running Codex", () => {
@@ -68,6 +69,7 @@ describe("Codex autostart shim", () => {
6869
expect(script).not.toContain("sync-cache");
6970
expect(script).toContain('set "OCX_REAL_CODEX=C:\\Tools\\codex-real.exe"');
7071
expect(script).toContain('set "OCX_API_TOKEN_FILE=');
72+
expect(script).toContain('set "OCX_BUN_RUNTIME_SOURCE=process"');
7173
expect(script).toContain('set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"');
7274
expect(script).toContain('"%OCX_REAL_CODEX%" %*');
7375
});
@@ -115,15 +117,16 @@ describe("Codex autostart shim", () => {
115117
test("PowerShell shim is written with a UTF-8 BOM (Windows PowerShell 5.1 decodes BOM-less ps1 as ANSI)", async () => {
116118
const source = readFileSync(join(import.meta.dir, "..", "src", "codex", "shim.ts"), "utf8");
117119

118-
expect(source).toContain("`\\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli)}`");
120+
expect(source).toContain("`\\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`");
119121
});
120122

121123
test("Windows target discovery includes the extensionless Git-Bash launcher and writeShim emits a forward-slash sh shim for it", () => {
122124
const source = readFileSync(join(import.meta.dir, "..", "src", "codex", "shim.ts"), "utf8");
123125

124126
expect(source).toContain('const gitBashLauncher = join(dir, "codex");');
125127
expect(source).toContain("for (const path of [cmd, ps1, gitBashLauncher])");
126-
expect(source).toContain("buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath()))");
128+
expect(source).toContain("gitBashPath(serviceApiTokenFilePath()),");
129+
expect(source).toContain("bunRuntimeSource,");
127130
});
128131

129132
test("Unix shim accepts an injected token-file path (Git-Bash shims need forward slashes everywhere)", () => {
@@ -177,6 +180,7 @@ describe("Codex autostart shim", () => {
177180
expect(script).toContain("OCX_SHIM_BYPASS");
178181
expect(script).toContain("Test-Path -LiteralPath");
179182
expect(script).toContain("OPENCODEX_API_AUTH_TOKEN");
183+
expect(script).toContain("$env:OCX_BUN_RUNTIME_SOURCE = 'process'");
180184
expect(script).toContain("& 'C:\\codex-real.ps1' @args");
181185
});
182186

tests/doctor.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ describe("service memory section (#314 WP4)", () => {
430430
expect(text).toContain("OPENCODEX_BUN_PATH is already active");
431431
expect(text).not.toContain("set OPENCODEX_BUN_PATH");
432432
expect(text).toContain("auto-known-bad");
433+
expect(text).toContain('streamMode "eager-relay"');
433434
});
434435

435436
test("legacy payload keeps runtime source unknown without circular override advice", async () => {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect, test } from "bun:test";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { windowsTrayHostEnvironment } from "../src/tray/windows";
5+
6+
test("Windows tray forwards the recorded Bun source and leaves legacy entries unknown", () => {
7+
expect(windowsTrayHostEnvironment(
8+
{ bunRuntimeSource: "override" },
9+
{ OCX_BUN_RUNTIME_SOURCE: "bundled", KEEP: "yes" },
10+
)).toEqual({ OCX_BUN_RUNTIME_SOURCE: "override", KEEP: "yes" });
11+
12+
expect(windowsTrayHostEnvironment(
13+
{},
14+
{ OCX_BUN_RUNTIME_SOURCE: "bundled", KEEP: "yes" },
15+
)).toEqual({ KEEP: "yes" });
16+
});
17+
18+
test("Windows tray resolves its durable Bun path and source as one entry", () => {
19+
const source = readFileSync(join(import.meta.dir, "..", "src", "tray", "windows.ts"), "utf8");
20+
expect(source).toContain("const bunRuntime = durableBunRuntime();");
21+
expect(source).toContain("bun: bunRuntime.path");
22+
expect(source).toContain("bunRuntimeSource: bunRuntime.source");
23+
});

tests/winsw.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
77
import { tmpdir } from "node:os";
88
import { join } from "node:path";
99

10-
const entry = { bun: "C:\\OpenCodex\\bun.exe", cli: "C:\\Open Codex\\cli & co\\index.ts" };
10+
const entry = {
11+
bun: "C:\\OpenCodex\\bun.exe",
12+
bunRuntimeSource: "override" as const,
13+
cli: "C:\\Open Codex\\cli & co\\index.ts",
14+
};
1115

1216
function winswEnvValue(xml: string, name: string): string | null {
1317
const match = xml.match(new RegExp(`<env name="${name}" value="([^"]*)"/>`));
@@ -39,7 +43,7 @@ describe("winsw xml", () => {
3943
const xml = buildWinswXml(entry, env);
4044

4145
expect(xml).toContain('<env name="OCX_SERVICE" value="1"/>');
42-
expect(xml).toMatch(/<env name="OCX_BUN_RUNTIME_SOURCE" value="(override|bundled|process)"\/>/);
46+
expect(xml).toContain('<env name="OCX_BUN_RUNTIME_SOURCE" value="override"/>');
4347
expect(xml).toContain('<env name="OCX_API_TOKEN_FILE"');
4448
expect(xml).toContain('<env name="PATH" value="C:\\bin;C:\\tools &amp; more"/>');
4549
expect(winswEnvValue(xml, "OPENCODEX_HOME")).toBe(getConfigDir());

0 commit comments

Comments
 (0)