Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.`,
);
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
31 changes: 28 additions & 3 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -523,6 +524,8 @@ export async function probeWham(fetchImpl: typeof fetch = fetch): Promise<WhamPr
export type ServiceMemoryData = {
pid: number;
bunVersion: string;
bunRevision?: string;
bunRuntimeSource?: BunRuntimeSource;
platform: string;
rss: number;
heapUsed: number;
Expand Down Expand Up @@ -579,6 +582,12 @@ export async function fetchServiceMemory(
data: {
pid: body.pid,
bunVersion: body.bunVersion,
bunRevision: typeof body.bunRevision === "string" ? body.bunRevision : undefined,
bunRuntimeSource: body.bunRuntimeSource === "override"
|| body.bunRuntimeSource === "bundled"
|| body.bunRuntimeSource === "process"
? body.bunRuntimeSource
: undefined,
platform: typeof body.platform === "string" ? body.platform : "unknown",
rss: body.rss,
heapUsed: typeof body.heapUsed === "number" ? body.heapUsed : 0,
Expand Down Expand Up @@ -625,7 +634,13 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
return lines;
}
const d = report.data;
lines.push(` ok service pid ${d.pid}: Bun ${d.bunVersion} on ${d.platform}`);
const sourceLabel = d.bunRuntimeSource === "override"
? "OPENCODEX_BUN_PATH override"
: d.bunRuntimeSource ?? "unknown";
lines.push(
` ok service pid ${d.pid}: Bun ${d.bunVersion} on ${d.platform}`
+ `${d.bunRevision ? ` (revision=${d.bunRevision}, source=${sourceLabel})` : ` (source=${sourceLabel})`}`,
);
const observed = observedMemory(d);
const observedBytes = d.observedBytes ?? d.watchdog?.observedBytes ?? observed.bytes;
const observedMetric = d.observedMetric ?? d.watchdog?.observedMetric ?? observed.metric;
Expand All @@ -652,9 +667,19 @@ export function formatServiceMemoryLines(report: ServiceMemoryReport): string[]
} else {
lines.push(" !! high RSS, indeterminate split — capture two doctor runs over time to see the trend");
}
// Version-claiming (never binary-claiming): the endpoint cannot distinguish
// the bundled binary from an OPENCODEX_BUN_PATH override of the same version.
// Runtime provenance is launcher-asserted and allowlisted. A missing marker is
// intentionally unknown; never infer an already-running service from this shell.
if (d.platform === "win32" && d.eagerRelay?.reason === "auto-known-bad") {
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.");
return lines;
Comment thread
luvs01 marked this conversation as resolved.
}
if (d.bunRuntimeSource === undefined) {
lines.push(" runtime source was not reported by this legacy service/payload; an active override cannot be determined safely.");
lines.push(" Repair or reinstall the service to add provenance; the conservative auto-known-bad decision remains in effect.");
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).");
Expand Down
21 changes: 20 additions & 1 deletion src/lib/bun-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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;
Comment thread
luvs01 marked this conversation as resolved.
}

/**
* 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
Expand Down
4 changes: 3 additions & 1 deletion src/lib/winsw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, durableBunPath, durableBunRuntime } from "./bun-runtime";
import { serviceApiTokenFilePath } from "./service-secrets";

export const WINSW_VERSION = "2.12.0";
Expand Down Expand Up @@ -74,6 +74,7 @@ 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 = (() => {
Expand All @@ -95,6 +96,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
const aclTimeout = env.OPENCODEX_ACL_TIMEOUT_MS?.trim();
const envLines = [
` <env name="OCX_SERVICE" value="1"/>`,
` <env name="${BUN_RUNTIME_SOURCE_ENV}" value="${bunRuntime.source}"/>`,
` <env name="OCX_API_TOKEN_FILE" value="${xmlEscape(serviceApiTokenFilePath())}"/>`,
` <env name="PATH" value="${xmlEscape(env.PATH ?? "")}"/>`,
env.CODEX_HOME?.trim() ? ` <env name="CODEX_HOME" value="${xmlEscape(currentCodexHomeAbsolute())}"/>` : null,
Expand Down
2 changes: 2 additions & 0 deletions src/server/management/system-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -78,6 +79,7 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
pid: process.pid,
bunVersion: Bun.version,
bunRevision: Bun.revision,
bunRuntimeSource: reportedBunRuntimeSource(),
platform: process.platform,
uptimeSeconds: process.uptime(),
rss: usage.rss,
Expand Down
8 changes: 6 additions & 2 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { loadConfig } from "./config";
import { restoreNativeCodex } from "./codex/inject";
import { stripGrokConfig } from "./grok/inject";
import { isWslRuntime } from "./codex/home";
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
import { BUN_RUNTIME_SOURCE_ENV, durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
import { isProcessAlive, stopProxy } from "./lib/process-control";
import { serviceApiTokenFilePath } from "./lib/service-secrets";
import { randomUUID } from "node:crypto";
Expand Down Expand Up @@ -269,12 +269,14 @@ function writeServiceApiTokenFile(): string | null {

export function buildPlist(): string {
const { bun, cli } = cliEntry();
const bunRuntime = durableBunRuntime();
const log = logPath();
const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
const codexHome = process.env.CODEX_HOME?.trim();
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
const envLines = [
` <key>OCX_SERVICE</key><string>1</string>`,
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntime.source}</string>`,
` <key>PATH</key><string>${plistString(path)}</string>`,
codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2163,4 +2168,3 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
process.exit(1);
}
}

2 changes: 1 addition & 1 deletion structure/05_gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Comment thread
luvs01 marked this conversation as resolved.
| 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. |
Expand Down
45 changes: 42 additions & 3 deletions tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading