Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ jobs:
# only one available here. GitHub's own guidance is to avoid self-hosted
# runners on public repositories for this reason.
#
# So read the routing below as a COST control that keeps honest pull requests
# on GitHub-hosted runners, not as a guarantee about hostile ones.
# So read the routing below as a STABILITY/OPERATIONS control that keeps
# honest pull requests on GitHub-hosted runners and lets trusted branch runs
# avoid the hosted-Windows Bun crashes. It is not the security boundary.
#
# `push` on dev/main/preview requires the push permission, and
# `workflow_dispatch` requires write access, so both carry a trusted author.
Expand Down
27 changes: 18 additions & 9 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* src/cli/index.ts — only the published npm `bin` routes through here.)
*/
import { spawn, spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { createRequire } from "node:module";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
Expand All @@ -22,6 +23,8 @@ const PKG = "@bitkyc08/opencodex";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const cliPath = join(here, "..", "src", "cli", "index.ts");
const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT";
const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof=";

function isNodeModulesInstall() {
return here.split(/[\\/]/).includes("node_modules");
Expand Down Expand Up @@ -407,22 +410,28 @@ const bun = bunRuntime.path;
// Provenance seam for issue #701: THIS launcher runs under Node, which does not
// auto-load a project `.env`/`.env.local`; the Bun child does, before any opencodex
// code evaluates. So this is the last point that can still tell a real shell export
// from a working-directory dotenv value, and we record which Anthropic credential
// slots already existed. `src/cli/claude.ts` then treats anything present in the Bun
// child but missing from this list as ambient project pollution rather than user auth,
// from a working-directory dotenv value, and we record which Anthropic credential or
// destination slots already existed. The context is paired with a random proof carried
// in argv, which project dotenv cannot modify during an ordinary `ocx` invocation.
// `src/cli/claude.ts` treats anything present in the Bun child but missing from this
// list as ambient project pollution rather than user auth or destination,
// which stopped a project dotenv from silently moving a claude.ai subscriber onto API
// billing. An EMPTY value is meaningful (the launcher ran and saw no slots) and is
// distinct from the variable being absent (no launcher at all — change nothing), so
// this must stay a plain assignment and never be collapsed to a falsy check.
// billing and prevents it from redirecting the subscriber's OAuth bearer.
// Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config
// interpolation and provider settings legitimately read the project environment.
const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]
.filter(name => typeof process.env[name] === "string" && process.env[name] !== "");
const child = spawn(bun, [cliPath, ...process.argv.slice(2)], {
const launchProof = randomBytes(32).toString("base64url");
const launchContext = JSON.stringify({
version: 1,
proof: launchProof,
anthropicEnvSlots: preBunAnthropicSlots,
});
const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], {
stdio: "inherit",
env: {
...process.env,
OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(","),
[NODE_LAUNCH_CONTEXT_ENV]: launchContext,
[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source,
[BUN_RUNTIME_PATH_ENV]: bunRuntime.path,
},
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { compileGoogleWireBody } from "./google-wire-compiler";
import { identifyRoutedModel } from "./identity";
import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
import {
isTranslatorBudgetExceededError,
retainTranslatedEventBatch,
Expand Down Expand Up @@ -404,6 +405,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT).");
const location = provider.location || process.env.GOOGLE_CLOUD_LOCATION;
if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION).");
const locationError = googleVertexLocationConfigError(location);
if (locationError) throw new Error(locationError);
const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`;
const token = await getVertexAccessToken();
Expand Down
47 changes: 25 additions & 22 deletions src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { configuredAdminToken } from "../lib/admin-secrets";
import { PROXY_MARKER, ownAdmissionTokens, defaultAuthDetectDeps, detectClaudeAuth, type AuthDetectDeps } from "../claude/auth-detect";
import { resolveClaudeAuthMode } from "../claude/auth-mode";
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context";

export interface ClaudeLaunchEnv {
[key: string]: string | undefined;
Expand All @@ -27,13 +28,18 @@ export interface ClaudeLaunchEnv {
* Injectable IO for tests. `env` is deliberately NOT injectable: it is bound to the
* launch base so detection and the spawned process can never disagree (audit R3-3).
*/
export type ClaudeEnvDeps = { authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens"> };
export type ClaudeEnvDeps = {
authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens">;
/** Test seam; production uses the authenticated Node-launcher context. */
preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null;
};

/**
* Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
* token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
* overrides variables the user already exported, apart from stale loopback
* ANTHROPIC_BASE_URL values owned by a previous opencodex launch.
* preserves Anthropic variables proven to exist in the parent Node launcher,
* apart from stale loopback ANTHROPIC_BASE_URL values owned by a previous
* opencodex launch. Unproven ambient values fail closed as project dotenv.
*/
export function buildClaudeEnv(
config: OcxConfig,
Expand All @@ -49,27 +55,24 @@ export function buildClaudeEnv(
// leaving the child with no token at all (audit R2-1). It is opencodex state, never
// user auth, so dropping it unconditionally is safe.
if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
// Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
// project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
// moment either token slot is populated, so an ambient project file silently moved a
// subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
// runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
// before Bun started; anything populated now but absent then came from the working
// directory, not from the user. A genuine shell export is still honored, which keeps
// auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
// (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
// change nothing rather than guess — an EMPTY marker is different: the launcher ran
// and saw no pre-existing slots.
const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
if (preBunSlots !== undefined) {
const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
const value = env[name];
if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
}
// Step 1b — drop Anthropic credentials AND destinations that Bun synthesized from a
// project `.env`/`.env.local`. Preserving a dotenv-only ANTHROPIC_BASE_URL while
// selecting subscription auth sends Claude's OAuth bearer and prompt to that host.
// The plain-Node launcher records genuine parent exports before Bun starts and pairs
// that context with an argv proof. Without a trusted context (direct Bun or an older
// launcher) we fail closed and treat all three ambient slots as project-controlled.
const explicitSlots = deps.preBunAnthropicSlots;
const trustedSlots = explicitSlots === undefined
? trustedNodeLauncherContext()?.anthropicEnvSlots ?? []
: explicitSlots ?? [];
const exported = new Set<AnthropicParentEnvSlot>(trustedSlots);
for (const name of ANTHROPIC_PARENT_ENV_SLOTS) {
const value = env[name];
if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
}
// Never forward the seam itself to Claude Code.
// Never forward old or current provenance seams to Claude Code.
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
delete env.OCX_NODE_LAUNCH_CONTEXT;
const setDefault = (name: string, value: string | undefined) => {
if (value === undefined || value.length === 0) return;
if (env[name] !== undefined && env[name] !== "") return; // user wins
Expand Down
8 changes: 6 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
import { removeOwnedConfigState } from "../lib/config-ownership";
import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
import { initializeNodeLauncherContext } from "./launcher-context";
import { createLocalAttestationSecret } from "../lib/local-management-attestation";

initializeNodeLauncherContext();
const args = process.argv.slice(2);
const command = args[0];

Expand Down Expand Up @@ -194,9 +197,10 @@ async function handleStart(options: { block?: boolean } = {}) {
// the same port only (never hop — that was the remaining PR #152 gap).
let port = await chooseListenPort(requestedPort);
let server: ReturnType<typeof startServer>;
const localAttestationSecret = createLocalAttestationSecret();
for (let attempt = 0; ; attempt++) {
try {
server = startServer(port);
server = startServer(port, localAttestationSecret);
// Prewarm the live provider model cache as soon as the port is bound so the
// first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
// instead of racing duplicate upstream /models fetches.
Expand Down Expand Up @@ -224,7 +228,7 @@ async function handleStart(options: { block?: boolean } = {}) {
writePid(process.pid);

const config = loadConfig();
writeRuntimePort({ pid: process.pid, port, hostname: config.hostname });
writeRuntimePort({ pid: process.pid, port, hostname: config.hostname, attestationSecret: localAttestationSecret });
// No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it
// is about to transform; snapshotting earlier only captured a baseline that could
// already be stale by the time injection ran (#477).
Expand Down
77 changes: 77 additions & 0 deletions src/cli/launcher-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Trusted facts captured by the plain-Node npm launcher before Bun auto-loads
* project dotenv files. The random proof travels in argv while the context
* travels in the environment, so a project `.env` cannot forge the pair during
* an ordinary `ocx ...` invocation.
*/
export const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT";
export const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof=";

export const ANTHROPIC_PARENT_ENV_SLOTS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
] as const;

export type AnthropicParentEnvSlot = typeof ANTHROPIC_PARENT_ENV_SLOTS[number];

export type TrustedNodeLaunchContext = {
anthropicEnvSlots: readonly AnthropicParentEnvSlot[];
};

let trustedContext: TrustedNodeLaunchContext | null = null;

function isLaunchProof(value: string): boolean {
return /^[A-Za-z0-9_-]{43}$/.test(value);
}

/** Consume the internal proof before normal CLI argument parsing. */
export function initializeNodeLauncherContext(
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
): TrustedNodeLaunchContext | null {
const proofArgs: string[] = [];
for (let index = argv.length - 1; index >= 2; index -= 1) {
const value = argv[index];
if (!value?.startsWith(NODE_LAUNCH_PROOF_PREFIX)) continue;
proofArgs.push(value.slice(NODE_LAUNCH_PROOF_PREFIX.length));
argv.splice(index, 1);
}

const raw = env[NODE_LAUNCH_CONTEXT_ENV];
delete env[NODE_LAUNCH_CONTEXT_ENV];
// Older launchers used this unauthenticated marker. Never let a project
// dotenv resurrect it as a trusted provenance channel.
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
trustedContext = null;

if (proofArgs.length !== 1 || !raw || raw.length > 2048) return null;
const proof = proofArgs[0]!;
if (!isLaunchProof(proof)) return null;

try {
const parsed = JSON.parse(raw) as {
version?: unknown;
proof?: unknown;
anthropicEnvSlots?: unknown;
};
if (parsed.version !== 1 || parsed.proof !== proof || !Array.isArray(parsed.anthropicEnvSlots)) {
return null;
}
const allowed = new Set<string>(ANTHROPIC_PARENT_ENV_SLOTS);
const slots = parsed.anthropicEnvSlots.filter(
(slot): slot is AnthropicParentEnvSlot => typeof slot === "string" && allowed.has(slot),
);
if (slots.length !== parsed.anthropicEnvSlots.length || new Set(slots).size !== slots.length) {
return null;
}
trustedContext = { anthropicEnvSlots: slots };
return trustedContext;
} catch {
return null;
}
}

export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null {
return trustedContext;
}
7 changes: 6 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "./lib/windows-secret-acl";
import { recordOwnedConfigPath } from "./lib/config-ownership";
import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
import { isLocalAttestationSecret } from "./lib/local-management-attestation";
import { providerDestinationConfigError } from "./lib/destination-policy";
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
import {
Expand Down Expand Up @@ -2150,18 +2151,22 @@ export type RuntimePortState = {
pid: number;
port: number;
hostname?: string;
/** Per-process proof key; protected by the config directory and never served. */
attestationSecret?: string;
};

function isValidRuntimePortState(value: unknown): value is RuntimePortState {
if (!value || typeof value !== "object") return false;
const state = value as Record<string, unknown>;
const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
const attestationOk = state.attestationSecret === undefined || isLocalAttestationSecret(state.attestationSecret);
return Number.isSafeInteger(state.pid)
&& Number(state.pid) > 0
&& Number.isInteger(state.port)
&& Number(state.port) > 0
&& Number(state.port) <= 65535
&& hostnameOk;
&& hostnameOk
&& attestationOk;
}

export function writeRuntimePort(state: RuntimePortState): void {
Expand Down
38 changes: 21 additions & 17 deletions src/lib/bun-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/
import { createRequire } from "node:module";
import { realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { dirname, join } from "node:path";
import { isRealBunBinary } from "./bun-binary-validator.mjs";

export { isRealBunBinary };
Expand Down Expand Up @@ -108,19 +108,23 @@ export function withProcessRuntimeProvenance(
* exact executable, otherwise what this executable actually is.
*/
function currentRuntimeProvenance(env: NodeJS.ProcessEnv): DurableBunRuntime {
const claimed = reportedBunRuntimeSource(env);
const claimedPath = env[BUN_RUNTIME_PATH_ENV]?.trim();
if (claimed && claimedPath && samePath(claimedPath, process.execPath)) {
return { path: process.execPath, source: claimed, overrideEnv: BUN_OVERRIDE_ENV };
}
const recorded = recordedCurrentRuntime(env);
if (recorded) return recorded;
// No marker that describes this binary: report what is running. One resolution
// supplies both halves so the pair can never disagree.
const runtime = durableBunRuntime();
const runtime = unmarkedDurableBunRuntime();
return samePath(runtime.path, process.execPath)
? runtime
: { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV };
}

function recordedCurrentRuntime(env: NodeJS.ProcessEnv): DurableBunRuntime | null {
const source = reportedBunRuntimeSource(env);
const path = env[BUN_RUNTIME_PATH_ENV]?.trim();
if (!source || !path || !samePath(path, process.execPath)) return null;
return { path, source, overrideEnv: BUN_OVERRIDE_ENV };
}

/**
* Same file, allowing for the aliases a path can pick up between launch and relaunch:
* symlinks/junctions, mapped drives, and Windows case differences. Falls back to a
Expand Down Expand Up @@ -154,21 +158,21 @@ export function bundledBunPath(): string | null {
}
}

export function overrideBunPath(): string | null {
const value = process.env[BUN_OVERRIDE_ENV]?.trim();
if (!value) return null;
const resolved = resolve(value);
return isRealBunBinary(resolved) ? resolved : null;
}

export function durableBunRuntime(): DurableBunRuntime {
const override = overrideBunPath();
if (override) return { path: override, source: "override", overrideEnv: BUN_OVERRIDE_ENV };
function unmarkedDurableBunRuntime(): DurableBunRuntime {
const bundled = bundledBunPath();
if (bundled) return { path: bundled, source: "bundled", overrideEnv: BUN_OVERRIDE_ENV };
return { path: process.execPath, source: "process", overrideEnv: BUN_OVERRIDE_ENV };
}

export function durableBunRuntime(): DurableBunRuntime {
// A durable artifact must use the runtime selected BEFORE Bun auto-loaded a
// project dotenv. The Node launcher and owned service/shim launchers stamp the
// selected source/path pair; it is accepted only when it names this exact
// running executable. Re-reading OPENCODEX_BUN_PATH here would let a project
// `.env` persist an arbitrary executable into a shim or service.
return recordedCurrentRuntime(process.env) ?? unmarkedDurableBunRuntime();
}

/**
* 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
Loading
Loading