Skip to content

Commit 68e6d5c

Browse files
committed
fix(launcher): harden override handling
1 parent fa455f7 commit 68e6d5c

4 files changed

Lines changed: 210 additions & 38 deletions

File tree

bin/ocx.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,9 @@ function bunBinDir() {
290290
// The `bun` package ships a tiny ASCII placeholder at bin/bun.exe until its
291291
// postinstall downloads the real ~60MB binary. --ignore-scripts / pnpm leave
292292
// the ~450-byte stub in place, which is NOT executable (ENOEXEC). A size gate
293-
// cleanly distinguishes the stub from a real binary on every platform.
293+
// cleanly distinguishes the stub from a real binary on every platform. Keep
294+
// this Node-safe copy aligned with src/lib/bun-runtime.ts: the launcher cannot
295+
// import Bun-native TypeScript until after it has resolved a Bun executable.
294296
const REAL_BUN_MIN_BYTES = 1_000_000;
295297
const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";
296298

@@ -329,7 +331,13 @@ function resolveBun() {
329331
// Keep direct npm-launcher starts aligned with durable service/shim installs:
330332
// a valid explicit runtime must win even when the bundled dependency exists.
331333
const override = process.env[BUN_OVERRIDE_ENV]?.trim();
332-
if (override && isRealBunBinary(override)) return override;
334+
if (override) {
335+
const overridePath = resolve(override);
336+
if (isRealBunBinary(overridePath)) return overridePath;
337+
console.error(
338+
`opencodex: ${BUN_OVERRIDE_ENV} is missing, unreadable, or not a complete Bun binary; falling back to the bundled runtime.`,
339+
);
340+
}
333341

334342
let bunDir;
335343
try {

tests/bun-runtime.test.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,18 @@ describe("bundledBunPath / durableBunPath", () => {
6969
});
7070

7171
it("durableBunPath returns the bundled path when present, else process.execPath", () => {
72-
const bundled = bundledBunPath();
73-
const durable = durableBunPath();
74-
expect(typeof durable).toBe("string");
75-
expect(durable.length).toBeGreaterThan(0);
76-
if (bundled) expect(durable).toBe(bundled);
77-
else expect(durable).toBe(process.execPath);
72+
const inheritedOverride = process.env.OPENCODEX_BUN_PATH;
73+
delete process.env.OPENCODEX_BUN_PATH;
74+
try {
75+
const bundled = bundledBunPath();
76+
const durable = durableBunPath();
77+
expect(typeof durable).toBe("string");
78+
expect(durable.length).toBeGreaterThan(0);
79+
if (bundled) expect(durable).toBe(bundled);
80+
else expect(durable).toBe(process.execPath);
81+
} finally {
82+
if (inheritedOverride === undefined) delete process.env.OPENCODEX_BUN_PATH;
83+
else process.env.OPENCODEX_BUN_PATH = inheritedOverride;
84+
}
7885
});
7986
});

tests/ocx-launcher-runtime.test.ts

Lines changed: 174 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
3-
import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3+
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
44
import { createServer } from "node:net";
55
import { tmpdir } from "node:os";
66
import { join, resolve } from "node:path";
@@ -128,6 +128,33 @@ function sameProcess(actual: WindowsProcessIdentity | null, expected: WindowsPro
128128
&& sameWindowsPath(actual.executablePath, expected.executablePath);
129129
}
130130

131+
function captureWindowsProcessIdentity(pid: number): WindowsProcessIdentity {
132+
let lastError: unknown;
133+
for (let attempt = 0; attempt < 20; attempt += 1) {
134+
try {
135+
const identity = windowsProcessIdentity(pid);
136+
if (identity) return identity;
137+
} catch (error) {
138+
lastError = error;
139+
}
140+
Bun.sleepSync(100);
141+
}
142+
throw new Error(`could not capture process identity for PID ${pid}: ${String(lastError ?? "process not found")}`);
143+
}
144+
145+
function inspectWindowsProcessIdentity(pid: number): WindowsProcessIdentity | null {
146+
let lastError: unknown;
147+
for (let attempt = 0; attempt < 5; attempt += 1) {
148+
try {
149+
return windowsProcessIdentity(pid);
150+
} catch (error) {
151+
lastError = error;
152+
Bun.sleepSync(100);
153+
}
154+
}
155+
throw lastError;
156+
}
157+
131158
function removeTree(path: string): void {
132159
// Windows can retain the copied executable's image handle briefly after
133160
// taskkill returns. Retry only transient fixture-cleanup errors, with a cap.
@@ -153,15 +180,19 @@ async function effectiveRuntime(override: string): Promise<string> {
153180
const opencodexHome = join(root, "opencodex");
154181
const codexHome = join(root, "codex");
155182
const grokHome = join(root, "grok");
156-
mkdirSync(opencodexHome, { recursive: true });
157-
mkdirSync(codexHome, { recursive: true });
158-
mkdirSync(grokHome, { recursive: true });
159-
160-
const port = await freePort();
183+
let port: number | null = null;
161184
let launcher: ChildProcess | null = null;
185+
let launcherPid: number | null = null;
162186
let ownedLauncher: WindowsProcessIdentity | null = null;
163187
let ownedProxy: WindowsProcessIdentity | null = null;
188+
let runtimePath: string | undefined;
189+
let hasPrimaryError = false;
190+
let primaryError: unknown;
164191
try {
192+
mkdirSync(opencodexHome, { recursive: true });
193+
mkdirSync(codexHome, { recursive: true });
194+
mkdirSync(grokHome, { recursive: true });
195+
port = await freePort();
165196
launcher = spawn("node", [BIN_OCX, "start", "--port", String(port)], {
166197
stdio: "ignore",
167198
windowsHide: true,
@@ -176,8 +207,8 @@ async function effectiveRuntime(override: string): Promise<string> {
176207
},
177208
});
178209
if (!launcher.pid) throw new Error("Node launcher has no process id");
179-
ownedLauncher = windowsProcessIdentity(launcher.pid);
180-
if (!ownedLauncher) throw new Error("could not capture Node launcher process identity");
210+
launcherPid = launcher.pid;
211+
ownedLauncher = captureWindowsProcessIdentity(launcherPid);
181212

182213
const health = await waitForHealth(port, 25_000, launcher);
183214
if (!health) throw new Error("proxy did not become healthy");
@@ -186,34 +217,150 @@ async function effectiveRuntime(override: string): Promise<string> {
186217
throw new Error("health PID is not the spawned Node launcher's direct Bun child");
187218
}
188219
ownedProxy = identity;
189-
return identity.executablePath;
190-
} finally {
191-
// Both identities were captured from processes this test spawned. Re-query
192-
// before each kill so a reused PID can never become a cleanup target.
193-
const cleanupErrors: string[] = [];
194-
for (const [label, identity] of [
195-
["launcher", ownedLauncher],
196-
["proxy", ownedProxy],
197-
] as const) {
198-
if (!identity || !sameProcess(windowsProcessIdentity(identity.pid), identity)) continue;
199-
try {
200-
killProxy(identity.pid);
201-
} catch (error) {
202-
cleanupErrors.push(`${label} cleanup failed: ${String(error)}`);
220+
runtimePath = identity.executablePath;
221+
} catch (error) {
222+
hasPrimaryError = true;
223+
primaryError = error;
224+
}
225+
226+
const cleanupErrors: string[] = [];
227+
let launcherTreeStopped = false;
228+
229+
// Prefer the creation-time/path identity. If the initial CIM capture failed,
230+
// the live ChildProcess handle and its PID are still positive ownership of
231+
// this test's launcher, so terminate that exact process tree as a fallback.
232+
if (ownedLauncher) {
233+
try {
234+
if (sameProcess(inspectWindowsProcessIdentity(ownedLauncher.pid), ownedLauncher)) {
235+
killProxy(ownedLauncher.pid);
236+
launcherTreeStopped = true;
203237
}
204-
if (sameProcess(windowsProcessIdentity(identity.pid), identity)) {
205-
cleanupErrors.push(`owned ${label} PID ${identity.pid} remained after bounded cleanup`);
238+
} catch (error) {
239+
cleanupErrors.push(`launcher cleanup failed: ${String(error)}`);
240+
}
241+
}
242+
if (!launcherTreeStopped && launcher && launcherPid && launcher.exitCode === null && launcher.signalCode === null) {
243+
try {
244+
killProxy(launcherPid);
245+
launcherTreeStopped = true;
246+
} catch (error) {
247+
cleanupErrors.push(`launcher tree fallback failed: ${String(error)}`);
248+
}
249+
}
250+
251+
// The launcher tree kill normally removes the Bun child. Retain the
252+
// identity-verified proxy fallback in case the launcher exited first.
253+
if (ownedProxy) {
254+
try {
255+
if (sameProcess(inspectWindowsProcessIdentity(ownedProxy.pid), ownedProxy)) {
256+
killProxy(ownedProxy.pid);
206257
}
258+
} catch (error) {
259+
cleanupErrors.push(`proxy cleanup failed: ${String(error)}`);
207260
}
261+
}
262+
263+
// Inspection failures are reported, but never prevent the remaining
264+
// process checks or fixture cleanup from running.
265+
for (const [label, identity] of [
266+
["launcher", ownedLauncher],
267+
["proxy", ownedProxy],
268+
] as const) {
269+
if (!identity) continue;
208270
try {
209-
removeTree(root);
271+
if (sameProcess(inspectWindowsProcessIdentity(identity.pid), identity)) {
272+
cleanupErrors.push(`owned ${label} PID ${identity.pid} remained after bounded cleanup`);
273+
}
210274
} catch (error) {
211-
cleanupErrors.push(`fixture cleanup failed: ${String(error)}`);
275+
cleanupErrors.push(`${label} cleanup verification failed: ${String(error)}`);
212276
}
213-
if (cleanupErrors.length > 0) throw new Error(cleanupErrors.join("; "));
214277
}
278+
if (port !== null) {
279+
const lingeringProxy = await healthAt(port);
280+
if (lingeringProxy) {
281+
cleanupErrors.push(`OpenCodex proxy PID ${lingeringProxy.pid} remained on owned port ${port}`);
282+
}
283+
}
284+
try {
285+
removeTree(root);
286+
} catch (error) {
287+
cleanupErrors.push(`fixture cleanup failed: ${String(error)}`);
288+
}
289+
290+
if (hasPrimaryError) {
291+
if (cleanupErrors.length > 0) console.error(`additional cleanup errors: ${cleanupErrors.join("; ")}`);
292+
throw primaryError;
293+
}
294+
if (cleanupErrors.length > 0) throw new Error(cleanupErrors.join("; "));
295+
if (!runtimePath) throw new Error("proxy runtime path was not captured");
296+
return runtimePath;
297+
}
298+
299+
function isolatedLauncherEnv(root: string, override: string): NodeJS.ProcessEnv {
300+
const opencodexHome = join(root, "opencodex");
301+
const codexHome = join(root, "codex");
302+
const grokHome = join(root, "grok");
303+
mkdirSync(opencodexHome, { recursive: true });
304+
mkdirSync(codexHome, { recursive: true });
305+
mkdirSync(grokHome, { recursive: true });
306+
return {
307+
...process.env,
308+
HOME: root,
309+
USERPROFILE: root,
310+
OPENCODEX_HOME: opencodexHome,
311+
CODEX_HOME: codexHome,
312+
GROK_HOME: grokHome,
313+
OPENCODEX_BUN_PATH: override,
314+
};
215315
}
216316

317+
describe.skipIf(!nodeAvailable)("ocx npm launcher relative Bun override", () => {
318+
test("resolves a valid bare relative override before spawning", () => {
319+
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-relative-"));
320+
try {
321+
const overrideName = `custom-bun${process.platform === "win32" ? ".exe" : ""}`;
322+
const override = join(root, overrideName);
323+
copyFileSync(process.execPath, override);
324+
chmodSync(override, 0o755);
325+
326+
const result = spawnSync("node", [BIN_OCX, "--version"], {
327+
cwd: root,
328+
encoding: "utf8",
329+
timeout: 30_000,
330+
windowsHide: true,
331+
env: isolatedLauncherEnv(root, overrideName),
332+
});
333+
334+
expect(result.status).toBe(0);
335+
expect(result.stderr).not.toContain("OPENCODEX_BUN_PATH is missing");
336+
} finally {
337+
removeTree(root);
338+
}
339+
}, 60_000);
340+
341+
test("warns without exposing the rejected override path before bundled fallback", () => {
342+
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-invalid-"));
343+
try {
344+
const overrideName = `stub-bun${process.platform === "win32" ? ".exe" : ""}`;
345+
writeFileSync(join(root, overrideName), "not a Bun executable", "utf8");
346+
347+
const result = spawnSync("node", [BIN_OCX, "--version"], {
348+
cwd: root,
349+
encoding: "utf8",
350+
timeout: 30_000,
351+
windowsHide: true,
352+
env: isolatedLauncherEnv(root, overrideName),
353+
});
354+
355+
expect(result.status).toBe(0);
356+
expect(result.stderr).toContain("OPENCODEX_BUN_PATH is missing, unreadable, or not a complete Bun binary");
357+
expect(result.stderr).not.toContain(root);
358+
} finally {
359+
removeTree(root);
360+
}
361+
}, 60_000);
362+
});
363+
217364
describe.skipIf(!runnable)("ocx npm launcher effective Bun runtime", () => {
218365
test("uses a valid OPENCODEX_BUN_PATH for the actual proxy process", async () => {
219366
const root = mkdtempSync(join(tmpdir(), "ocx-launcher-runtime-copy-"));

tests/ocx-launcher-source.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,28 @@ describe("ocx.mjs npm launcher (source invariants)", () => {
2525

2626
test("valid Bun overrides are selected before the bundled runtime", () => {
2727
expect(source).toContain('const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";');
28-
expect(source).toContain("if (override && isRealBunBinary(override)) return override;");
28+
expect(source).toContain("const overridePath = resolve(override);");
29+
expect(source).toContain("if (isRealBunBinary(overridePath)) return overridePath;");
2930

3031
const resolveStart = source.indexOf("function resolveBun() {");
3132
const overrideCheck = source.indexOf("process.env[BUN_OVERRIDE_ENV]?.trim()", resolveStart);
33+
const overrideResolve = source.indexOf("resolve(override)", overrideCheck);
3234
const bundledLookup = source.indexOf("bunDir = bunBinDir()", resolveStart);
3335
expect(resolveStart).toBeGreaterThanOrEqual(0);
3436
expect(overrideCheck).toBeGreaterThan(resolveStart);
35-
expect(bundledLookup).toBeGreaterThan(overrideCheck);
37+
expect(overrideResolve).toBeGreaterThan(overrideCheck);
38+
expect(bundledLookup).toBeGreaterThan(overrideResolve);
3639
});
3740

38-
test("invalid Bun overrides fall back without throwing", () => {
41+
test("invalid Bun overrides warn safely and fall back without throwing", () => {
3942
expect(source).toContain("function isRealBunBinary(path) {");
4043
expect(source).toMatch(/function isRealBunBinary\(path\) \{[\s\S]*?try \{[\s\S]*?statSync\(path\)[\s\S]*?catch \{[\s\S]*?return false;/);
44+
expect(source).toContain("is missing, unreadable, or not a complete Bun binary; falling back to the bundled runtime.");
45+
expect(source).not.toContain('${override} is missing, unreadable');
46+
});
47+
48+
test("documents why the Node launcher keeps a synchronized validator copy", () => {
49+
expect(source).toContain("this Node-safe copy aligned with src/lib/bun-runtime.ts");
50+
expect(source).toContain("cannot\n// import Bun-native TypeScript until after it has resolved a Bun executable");
4151
});
4252
});

0 commit comments

Comments
 (0)