diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fa2248e0..e94ebee08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/bin/ocx.mjs b/bin/ocx.mjs index c4fd07680..bccc74813 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -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"; @@ -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"); @@ -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, }, diff --git a/src/adapters/google.ts b/src/adapters/google.ts index c9d7aedfc..00ae93c1b 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -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, @@ -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(); diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 02d6751d2..66de3d340 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -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; @@ -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, "env" | "ownTokens"> }; +export type ClaudeEnvDeps = { + authDetect?: Omit, "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, @@ -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(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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 54e3ff822..03d5fc7ad 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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]; @@ -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; + 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. @@ -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). diff --git a/src/cli/launcher-context.ts b/src/cli/launcher-context.ts new file mode 100644 index 000000000..7395a9677 --- /dev/null +++ b/src/cli/launcher-context.ts @@ -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(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; +} diff --git a/src/config.ts b/src/config.ts index 05f854700..c0be48c97 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { @@ -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; 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 { diff --git a/src/lib/bun-runtime.ts b/src/lib/bun-runtime.ts index 8990e660a..b8a09149a 100644 --- a/src/lib/bun-runtime.ts +++ b/src/lib/bun-runtime.ts @@ -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 }; @@ -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 @@ -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 diff --git a/src/lib/local-management-attestation.ts b/src/lib/local-management-attestation.ts new file mode 100644 index 000000000..25eb283d0 --- /dev/null +++ b/src/lib/local-management-attestation.ts @@ -0,0 +1,51 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +export const LOCAL_ATTESTATION_CHALLENGE_HEADER = "x-opencodex-attestation-challenge"; +export const LOCAL_ATTESTATION_PROOF_HEADER = "x-opencodex-attestation-proof"; + +const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/; + +export function isLocalAttestationSecret(value: unknown): value is string { + return typeof value === "string" && BASE64URL_256.test(value); +} + +export function createLocalAttestationSecret(): string { + return randomBytes(32).toString("base64url"); +} + +export function createLocalAttestationChallenge(): string { + return randomBytes(32).toString("base64url"); +} + +function attestationPayload(challenge: string, pid: number, port: number): string | null { + if (!BASE64URL_256.test(challenge)) return null; + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; + return `opencodex-local-management-v1\n${challenge}\n${pid}\n${port}`; +} + +export function createLocalAttestationProof( + secret: string, + challenge: string, + pid: number, + port: number, +): string | null { + if (!isLocalAttestationSecret(secret)) return null; + const payload = attestationPayload(challenge, pid, port); + if (!payload) return null; + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function verifyLocalAttestationProof( + secret: string, + challenge: string, + pid: number, + port: number, + proof: string | null, +): boolean { + const expected = createLocalAttestationProof(secret, challenge, pid, port); + if (!expected || !proof || !BASE64URL_256.test(proof)) return false; + const expectedBytes = Buffer.from(expected); + const actualBytes = Buffer.from(proof); + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes); +} diff --git a/src/oauth/health.ts b/src/oauth/health.ts index aaf5ebd5f..9b7da74e7 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -4,6 +4,13 @@ import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; import { configuredAdminToken } from "../lib/admin-secrets"; +import { readRuntimePort } from "../config"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationChallenge, + verifyLocalAttestationProof, +} from "../lib/local-management-attestation"; import { maskAccountId } from "../lib/privacy"; import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store"; @@ -328,6 +335,7 @@ type LiveProxyCodexHealthResult = { async function fetchCodexHealthFromLiveProxy( fetchImpl: typeof fetch = fetch, findLiveProxyImpl: typeof findLiveProxy = findLiveProxy, + readRuntimePortImpl: typeof readRuntimePort = readRuntimePort, ): Promise { const live = await findLiveProxyImpl(); if (!live) return { source: "unavailable", entries: null }; @@ -335,8 +343,39 @@ async function fetchCodexHealthFromLiveProxy( // interchangeable with the admin credential even on loopback. const token = configuredAdminToken(); const headers: Record = {}; - if (token) headers.Authorization = `Bearer ${token}`; try { + if (token) { + // Public /healthz identity is intentionally forgeable enough for liveness, not + // strong enough to receive a bearer. Prove the listener knows the per-process + // secret stored in the protected runtime record before attaching the admin token. + if (live.source !== "runtime" || live.pid === null) { + return { source: "management-api-unavailable", entries: null }; + } + const attestedPid = live.pid; + const runtime = readRuntimePortImpl(attestedPid); + if (!runtime?.attestationSecret || runtime.port !== live.port) { + return { source: "management-api-unavailable", entries: null }; + } + const challenge = createLocalAttestationChallenge(); + const proofResponse = await fetchImpl( + `http://${probeHostname(live.hostname)}:${live.port}/healthz`, + { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, + signal: AbortSignal.timeout(4000), + }, + ); + const proof = proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER); + if (!proofResponse.ok || !verifyLocalAttestationProof( + runtime.attestationSecret, + challenge, + attestedPid, + live.port, + proof, + )) { + return { source: "management-api-unavailable", entries: null }; + } + headers.Authorization = `Bearer ${token}`; + } const res = await fetchImpl( `http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`, { headers, signal: AbortSignal.timeout(4000) }, @@ -387,10 +426,15 @@ export async function collectOAuthHealthEntriesForCli( deps: { fetchImpl?: typeof fetch; findLiveProxyImpl?: typeof findLiveProxy; + readRuntimePortImpl?: typeof readRuntimePort; } = {}, ): Promise { const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false }); - const remote = await fetchCodexHealthFromLiveProxy(deps.fetchImpl, deps.findLiveProxyImpl); + const remote = await fetchCodexHealthFromLiveProxy( + deps.fetchImpl, + deps.findLiveProxyImpl, + deps.readRuntimePortImpl, + ); if (remote.entries) { for (const entry of remote.entries) entries.push(entry); return { entries, codexHealthSource: "management-api" }; diff --git a/src/providers/google-vertex-location.ts b/src/providers/google-vertex-location.ts new file mode 100644 index 000000000..fb0656f1b --- /dev/null +++ b/src/providers/google-vertex-location.ts @@ -0,0 +1,14 @@ +/** + * Vertex regional hosts are formed as `-aiplatform.googleapis.com`. + * Restricting the location to one lowercase DNS label keeps user configuration + * from changing the request authority while remaining forward-compatible with + * new Google regions and multi-regions. + */ +const GOOGLE_VERTEX_LOCATION_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export function googleVertexLocationConfigError(location: unknown): string | null { + if (typeof location !== "string" || !GOOGLE_VERTEX_LOCATION_LABEL.test(location)) { + return "Vertex AI location must be a single lowercase Google Cloud location label (for example, us-central1 or global)"; + } + return null; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index aedf4a932..1bbc3ed3f 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -12,10 +12,11 @@ import { reasoningSummaryDeliveryRecordConfigError, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; -import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; +import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -415,6 +416,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown): const typed = provider as unknown as OcxProviderConfig; const baseUrlError = providerBaseUrlConfigError(typed.baseUrl); if (baseUrlError) return `provider ${name} ${baseUrlError}`; + if (effectiveGoogleMode(name, typed) === "vertex" && typed.location !== undefined) { + const locationError = googleVertexLocationConfigError(typed.location); + if (locationError) return `provider ${name} ${locationError}`; + } const destinationError = providerDestinationConfigError(name, typed); if (destinationError) return `provider ${name} ${destinationError}`; const headersError = providerHeadersConfigError(typed.headers); diff --git a/src/server/index.ts b/src/server/index.ts index 0b1e323d8..51bb72e0b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -158,6 +158,12 @@ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveS import { handleSearch } from "./search"; import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api"; import { initializeManagementAuthState, issueGuiSession, requireManagementAuth } from "./management-auth"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, + createLocalAttestationSecret, +} from "../lib/local-management-attestation"; const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -271,7 +277,10 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket): void { // trackSseForRequestLog( // export function relaySseWithHeartbeat -export function startServer(port?: number) { +export function startServer( + port?: number, + localAttestationSecret = createLocalAttestationSecret(), +) { const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())); setLiveStateStoreConfig(config); applyProxyEnv(config); @@ -442,7 +451,14 @@ export function startServer(port?: number) { if (url.pathname === "/healthz" && req.method === "GET") { // service/pid/port let CLI liveness reject foreign 200s and verify pid identity. - return jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: listenPort }, 200, req, config); + const healthPort = server.port ?? listenPort; + const response = jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: healthPort }, 200, req, config); + const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + if (challenge) { + const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort); + if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + } + return response; } if (url.pathname.startsWith("/api/")) { diff --git a/structure/00_overview.md b/structure/00_overview.md index 121f39699..bb204211e 100644 --- a/structure/00_overview.md +++ b/structure/00_overview.md @@ -77,7 +77,7 @@ opencodex state root does not undo those writes. Putting native Codex back is th | `~/.opencodex/codex-accounts.json` | opencodex | Hardened main-plus-added credential store used by `openai` in Pool mode. | | `~/.opencodex/catalog-backup.json` | opencodex | One-time pristine Codex catalog backup for restore; per-catalog copies are hashed variants (see [`03_catalog-and-subagents.md`](03_catalog-and-subagents.md)). | | `~/.opencodex/usage.jsonl` | opencodex | Append-only request usage log (0o600); request metadata + token counts only, never prompts or auth. | -| `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. | +| `~/.opencodex/ocx.pid`, `runtime-port.json`, `system-env-port` | opencodex runtime | Live process identity and the port a client should reach; rewritten on start. `runtime-port.json` also carries the protected per-process listener-attestation key used before CLI diagnostics attach a management bearer. | | `~/.opencodex/codex-runtime.json`, `codex-runtime-clamp.json` | opencodex Codex runtime | Selected Codex executable/version state and effort-clamp diagnostics. Not process identity: these persist a resolved choice and a diagnostic, so losing them changes behavior until re-resolved. | | `~/.opencodex/service-state.json`, `service.log`, `service-api-token`, `opencodex-service-launcher.vbs`, `opencodex-service-task.xml`, `opencodex-service.cmd`, `winsw`, `tray-state.json`, `tray-heartbeat.json`, `opencodex-tray.ps1`, `opencodex-tray-*.ico`, `update-job.json` | opencodex operators | Installed-service, Windows tray, and self-update artifacts and bookkeeping. The update record carries its worker PID so a dead worker recovers instead of blocking later runs. | | `~/.opencodex/responses-state.json`, `usage-debug.jsonl`, `crash.log`, `artifacts/` | opencodex diagnostics and artifacts | Bounded caches, diagnostics, and generated image/video artifacts served locally. | diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 07e733b76..1c74fb311 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -4,8 +4,8 @@ | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled Bun binary (`bun` dependency), lazy-runs its `install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | -| `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, `durableBunPath()` (path baked into service/shim artifacts). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. | +| `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | @@ -70,6 +70,14 @@ the client never sees a completed call ahead of `response.failed` / `response.in The server exposes `POST /api/stop` which restores native Codex config, stops any installed service (to prevent respawn), and exits the process. The GUI sidebar stop button calls this endpoint. +[Decision Log] +- 목적과 의도: Prevent repository dotenv data from becoming a durable executable or an OAuth-bearing Claude destination. +- 기존 구현 및 제약 조건: Bun auto-loads project dotenv before OpenCodex TypeScript evaluates, while provider interpolation still depends on that behavior and cannot be disabled globally. +- 검토한 주요 대안: Reject only relative Bun paths; disable Bun dotenv; trust a plain environment marker; capture provenance in the Node launcher and bind it to an argv proof. +- 선택한 방식: The Node launcher selects Bun and snapshots Anthropic credential/destination slots before Bun starts. Durable runtime selection uses only the stamped current executable, while Claude accepts the snapshot only when its random argv proof matches. +- 다른 대안 대신 이 방식을 선택한 이유: Absolute dotenv expansion bypasses a relative-path check, global dotenv removal breaks supported configuration, and an environment-only marker can itself come from dotenv. +- 장점, 단점 및 영향: Normal npm launches preserve genuine shell overrides; direct Bun or legacy launches fail closed for ambient Anthropic auth/destination values and use the running or bundled Bun for durable artifacts. + ## Providers and adapters | Path | Responsibility | diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 3b9e155ad..3213247e6 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -28,6 +28,20 @@ management credential for `/api/codex-auth/accounts`, never the service/data-pla output distinguishes a missing proxy, rejected management authentication, and an unexpected management response so a reachable `401` cannot be reported as "proxy not running." +Before either CLI command attaches the management bearer, it challenges the listener and verifies +an HMAC proof bound to the proxy PID and port. The per-process proof key lives only in the protected +`runtime-port.json`; the public `/healthz` identity marker alone is never sufficient to receive a +management credential. Legacy or configured-port-only listeners still satisfy ordinary liveness, +but their account-health detail remains unavailable until an attested runtime record exists. + +[Decision Log] +- 목적과 의도: Keep a lower-privileged local process from collecting the management bearer by impersonating `/healthz` on an unused port. +- 기존 구현 및 제약 조건: Liveness must remain public and backward-compatible, but its service string and reported PID are assertions made by the listener itself. +- 검토한 주요 대안: Require only a runtime source and non-null PID; stop showing account health; authenticate the listener with a protected per-process challenge secret. +- 선택한 방식: Store a random secret in the mode-protected runtime record and require a challenge/PID/port HMAC before the CLI sends Authorization. +- 다른 대안 대신 이 방식을 선택한 이유: PID and command-line checks are not cryptographic listener identity, while removing live account health would regress diagnostics unnecessarily. +- 장점, 단점 및 영향: The long-lived token never reaches a listener without the runtime secret; an old running proxy remains visible but cannot provide detailed CLI account health until restarted on the new version. + Management authentication never has a loopback bypass. If no management credential is available, or management token creation, validation, or permission hardening fails, every `/api/*` request returns 503 while `/v1/*` and unauthenticated `/healthz` continue to operate. Windows ACL hardening results diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index b357bd0ef..dc39603e6 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -40,7 +40,7 @@ bun run build | Workflow | Trigger | Purpose | | --- | --- | --- | -| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The `test` job (Bun) runs typecheck, `bun test --isolate tests`, the GUI suite (`cd gui && bun test tests`), the privacy scan, release-helper syntax check, GUI lint/build, and `ocx help`; `npm-global-smoke` (Node only, **no setup-bun**) builds package assets, packs the tarball, installs it globally, and runs `ocx help` to prove the bundled-Bun launcher works without a separate Bun install. | +| `.github/workflows/ci.yml` | `pull_request` to `main`/`dev`, `push` to `main`/`preview`/`dev`, or manual dispatch when runtime/package paths change | Cross-platform runtime/package quality gate on Linux, Windows, and macOS. The Bun `test` job keeps pull requests on GitHub-hosted Windows while trusted `push`/manual runs may use the `ocx-home` self-hosted Windows runner when the repository switch is enabled; this preserves the hosted-Windows Bun crash workaround. `npm-global-smoke` always remains GitHub-hosted because it mutates the global package prefix. | | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires the exact `GITHUB_SHA` to have a successful Cross-platform CI run before publish or dry-run. | | `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | @@ -56,6 +56,15 @@ bun run build branch, not from `dev`. Landing a change to one of them on `dev` does not change live behavior until it is promoted, so those files follow the promotion model rather than ordinary integration. +The Windows selector is an operational stability control, not a security boundary. A pull request +controls the `pull_request` workflow body and can rewrite an event-name check, repository variable, +or selector output. Because this is a public user-owned repository and runner groups are unavailable, +the repository setting **Fork pull request workflows from outside collaborators: Require approval +for all outside collaborators** (`all_external_contributors`) must remain enabled before any self- +hosted runner is registered. Maintainers must inspect workflow changes before approving an external +run. If that setting cannot be verified, unset `OCX_SELF_HOSTED_WINDOWS` and deregister the runner; +the workflow then fails back to `windows-latest` rather than exposing a persistent maintainer host. + Docs-only changes intentionally route through the docs workflow instead of the runtime CI gate. If a docs change also edits runtime/package/release files, run the relevant local runtime checks before push and let `ci.yml` provide the Linux/Windows confirmation. Service-related changes @@ -130,9 +139,11 @@ Invariants: lazy-runs `install.js` and execs `src/cli/index.ts` under Bun, propagating exit code and signal. - `package.json` carries `"trustedDependencies": ["bun"]` so `bun install` runs the dependency's postinstall, and `"engines": { "node": ">=18" }` (Bun is no longer a user prerequisite). -- `src/service.ts` and `src/codex/shim.ts` bake `durableBunPath()` (the bundled binary, stable under - the npm global prefix) into launchd/systemd/Task Scheduler and the Codex autostart shim, so those - durable artifacts keep resolving across `ocx update`. +- The plain-Node launcher owns `OPENCODEX_BUN_PATH` selection before Bun can load project dotenv and + stamps the chosen source/path pair. `src/service.ts` and `src/codex/shim.ts` bake that already- + selected executable (normally the bundled binary, stable under the npm global prefix) into + launchd/systemd/Task Scheduler and the Codex autostart shim. Bun-side code never re-selects a + durable executable from the post-dotenv environment. - Public docs (root READMEs + `docs-site` installation pages, all locales) state Node 18+ as the only prerequisite. Do not reintroduce "install Bun first" / "bun must be on PATH" guidance for npm users. diff --git a/tests/bun-runtime.test.ts b/tests/bun-runtime.test.ts index e012fff2f..5cb6b3297 100644 --- a/tests/bun-runtime.test.ts +++ b/tests/bun-runtime.test.ts @@ -1,16 +1,24 @@ -import { describe, it, expect, afterAll } from "bun:test"; +import { describe, it, expect, afterAll, afterEach } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, overrideBunPath, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; +import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../src/lib/bun-runtime"; // realpath the temp root: on macOS /var is a symlink to /private/var, so a path built // from mkdtemp compares unequal to the same path resolved through process.cwd(). const tmp = realpathSync(mkdtempSync(join(tmpdir(), "ocx-bun-runtime-"))); const previousOverride = process.env.OPENCODEX_BUN_PATH; -afterAll(() => { +const previousRuntimeSource = process.env[BUN_RUNTIME_SOURCE_ENV]; +const previousRuntimePath = process.env[BUN_RUNTIME_PATH_ENV]; +afterEach(() => { if (previousOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; else process.env.OPENCODEX_BUN_PATH = previousOverride; + if (previousRuntimeSource === undefined) delete process.env[BUN_RUNTIME_SOURCE_ENV]; + else process.env[BUN_RUNTIME_SOURCE_ENV] = previousRuntimeSource; + if (previousRuntimePath === undefined) delete process.env[BUN_RUNTIME_PATH_ENV]; + else process.env[BUN_RUNTIME_PATH_ENV] = previousRuntimePath; +}); +afterAll(() => { rmSync(tmp, { recursive: true, force: true }); }); @@ -40,29 +48,25 @@ describe("isRealBunBinary (size gate vs placeholder stub)", () => { }); describe("bundledBunPath / durableBunPath", () => { - it("uses OPENCODEX_BUN_PATH only when it points to a real Bun binary", () => { + it("does not reselect a dotenv Bun override after the runtime has started", () => { const real = join(tmp, "override-bun.exe"); const stub = join(tmp, "override-stub.exe"); writeFileSync(real, Buffer.alloc(1_000_000)); writeFileSync(stub, "stub"); process.env.OPENCODEX_BUN_PATH = stub; - expect(overrideBunPath()).toBeNull(); expect(durableBunRuntime().source).not.toBe("override"); process.env.OPENCODEX_BUN_PATH = real; - expect(overrideBunPath()).toBe(real); - expect(durableBunRuntime()).toEqual({ - path: real, - source: "override", - overrideEnv: "OPENCODEX_BUN_PATH", - }); - expect(durableBunPath()).toBe(real); + delete process.env[BUN_RUNTIME_SOURCE_ENV]; + delete process.env[BUN_RUNTIME_PATH_ENV]; + expect(durableBunRuntime().path).not.toBe(real); + expect(durableBunRuntime().source).not.toBe("override"); if (previousOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; else process.env.OPENCODEX_BUN_PATH = previousOverride; }); - it("resolves a relative override against the launcher cwd", () => { + it("preserves a launcher-selected runtime and ignores a later relative override", () => { const launcherCwd = join(tmp, "launcher-cwd"); const real = join(launcherCwd, "relative-bun.exe"); const previousCwd = process.cwd(); @@ -73,15 +77,20 @@ describe("bundledBunPath / durableBunPath", () => { try { process.chdir(launcherCwd); process.env.OPENCODEX_BUN_PATH = " relative-bun.exe "; - expect(overrideBunPath()).toBe(real); + process.env[BUN_RUNTIME_SOURCE_ENV] = "override"; + process.env[BUN_RUNTIME_PATH_ENV] = process.execPath; expect(durableBunRuntime()).toEqual({ - path: real, + path: process.execPath, source: "override", overrideEnv: "OPENCODEX_BUN_PATH", }); - expect(durableBunPath()).toBe(real); + expect(durableBunPath()).toBe(process.execPath); } finally { process.chdir(previousCwd); + if (previousRuntimeSource === undefined) delete process.env[BUN_RUNTIME_SOURCE_ENV]; + else process.env[BUN_RUNTIME_SOURCE_ENV] = previousRuntimeSource; + if (previousRuntimePath === undefined) delete process.env[BUN_RUNTIME_PATH_ENV]; + else process.env[BUN_RUNTIME_PATH_ENV] = previousRuntimePath; if (inheritedOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; else process.env.OPENCODEX_BUN_PATH = inheritedOverride; } @@ -152,8 +161,11 @@ describe("reportedBunRuntimeSource (#848 launch-time provenance)", () => { writeFileSync(real, "x".repeat(2 * 1024 * 1024)); process.env.OPENCODEX_BUN_PATH = real; try { - // durableBunRuntime would say "override" here; the reporter must still say unknown. - expect(durableBunRuntime().source).toBe("override"); + // The durable selector ignores this late value, and the reporter must also + // stay unknown without a source/path pair naming the running executable. + delete process.env[BUN_RUNTIME_SOURCE_ENV]; + delete process.env[BUN_RUNTIME_PATH_ENV]; + expect(durableBunRuntime().source).not.toBe("override"); expect(reportedBunRuntimeSource({})).toBeUndefined(); } finally { if (inherited === undefined) delete process.env.OPENCODEX_BUN_PATH; diff --git a/tests/claude-auth-mode.test.ts b/tests/claude-auth-mode.test.ts index 81829b4b1..0e052425a 100644 --- a/tests/claude-auth-mode.test.ts +++ b/tests/claude-auth-mode.test.ts @@ -95,7 +95,7 @@ test("a stale marker is re-established when the mode still resolves proxy", () = cfg(), 10100, { ANTHROPIC_AUTH_TOKEN: PROXY_MARKER }, {}, - { authDetect: fileAuth("absent") }, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, ); expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER); }); @@ -131,7 +131,7 @@ test("an exported ANTHROPIC_API_KEY keeps the token slot untouched", () => { cfg(), 10100, { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, - { authDetect: fileAuth("absent") }, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, ); expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); @@ -152,17 +152,17 @@ test("manual subscription withholds the marker even when auth is absent", () => // // Bun auto-loads `.env`/`.env.local` before any opencodex code runs, so process.env alone // cannot tell ambient pollution from a real shell export. The Node launcher runs BEFORE -// that and records which slots already existed; these tests drive that marker directly. -// An absent marker means provenance is unknowable, so behavior must not change. +// that and supplies a proof-bound list through launcher-context.ts. Without a trusted +// context the security boundary fails closed. const PRE_BUN = "OCX_PRE_BUN_ANTHROPIC_ENV"; // The reported failure: auto mode, healthy claude.ai login, key only from the dotenv. test("auto mode drops an Anthropic key that only Bun's dotenv introduced", () => { const env = buildClaudeEnv( cfg(), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env[PRE_BUN]).toBeUndefined(); @@ -172,9 +172,9 @@ test("auto mode drops an Anthropic key that only Bun's dotenv introduced", () => test("a shell-exported Anthropic key survives the dotenv strip", () => { const env = buildClaudeEnv( cfg(), 10100, - { ANTHROPIC_API_KEY: "sk-ant-user", [PRE_BUN]: "ANTHROPIC_API_KEY" }, + { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_API_KEY"] }, ); expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); expect(env[PRE_BUN]).toBeUndefined(); @@ -183,9 +183,9 @@ test("a shell-exported Anthropic key survives the dotenv strip", () => { test("explicit subscription mode also drops a dotenv-only credential", () => { const env = buildClaudeEnv( cfg({ authMode: "subscription" }), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", ANTHROPIC_AUTH_TOKEN: "token-from-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv", ANTHROPIC_AUTH_TOKEN: "token-from-dotenv" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); @@ -195,25 +195,55 @@ test("explicit subscription mode also drops a dotenv-only credential", () => { test("the configured admission key survives the dotenv strip", () => { const env = buildClaudeEnv( cfg(undefined, [{ key: "admission-key" }]), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv" }, {}, - { authDetect: fileAuth("present") }, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBe("admission-key"); expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe("1"); }); -// Without the launcher marker (direct `bun src/cli/index.ts`, or an older launcher) -// provenance is unknowable, so an inherited key keeps its current meaning. -test("without the launcher marker an inherited key is left alone", () => { +test("without trusted launcher context an ambient key is removed", () => { const env = buildClaudeEnv( cfg(), 10100, { ANTHROPIC_API_KEY: "sk-ant-user" }, {}, { authDetect: fileAuth("present") }, ); - expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user"); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); +}); + +test("a dotenv-only base URL cannot receive subscription OAuth", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://attacker.example" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: [] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); +}); + +test("a proof-bound parent base URL remains supported", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://trusted-gateway.example" }, + {}, + { authDetect: fileAuth("present"), preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("https://trusted-gateway.example"); +}); + +test("the legacy dotenv marker cannot forge parent provenance", () => { + const env = buildClaudeEnv( + cfg(), 10100, + { ANTHROPIC_BASE_URL: "https://attacker.example", [PRE_BUN]: "ANTHROPIC_BASE_URL" }, + {}, + { authDetect: fileAuth("present") }, + ); + expect(env.ANTHROPIC_BASE_URL).toBe("http://127.0.0.1:10100"); + expect(env[PRE_BUN]).toBeUndefined(); }); // Stripping the key must ALSO flip detection to absent so the proxy marker is injected. @@ -221,9 +251,9 @@ test("without the launcher marker an inherited key is left alone", () => { test("a stripped dotenv key lets detection fall through to the proxy marker", () => { const env = buildClaudeEnv( cfg(), 10100, - { ANTHROPIC_API_KEY: "sk-ant-dotenv", [PRE_BUN]: "" }, + { ANTHROPIC_API_KEY: "sk-ant-dotenv" }, {}, - { authDetect: fileAuth("absent") }, + { authDetect: fileAuth("absent"), preBunAnthropicSlots: [] }, ); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBe(PROXY_MARKER); diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index b34b8e4b1..b46911505 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -220,7 +220,7 @@ describe("ocx claude env assembly", () => { ANTHROPIC_BASE_URL: "http://my-own-gateway:9", ANTHROPIC_MODEL: "my-model", PATH: "/usr/bin", - }); + }, {}, { preBunAnthropicSlots: ["ANTHROPIC_BASE_URL"] }); expect(env.ANTHROPIC_BASE_URL).toBe("http://my-own-gateway:9"); expect(env.ANTHROPIC_MODEL).toBe("my-model"); expect(env.PATH).toBe("/usr/bin"); diff --git a/tests/claude-dotenv-provenance-transport.test.ts b/tests/claude-dotenv-provenance-transport.test.ts index afd9824e7..8373732b6 100644 --- a/tests/claude-dotenv-provenance-transport.test.ts +++ b/tests/claude-dotenv-provenance-transport.test.ts @@ -1,60 +1,70 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; /** - * #701 transport proof. - * - * The dotenv-provenance fix rests on one runtime assumption: an EMPTY-STRING environment - * value survives a spawn and stays distinguishable from an absent variable. The whole - * design hinges on it, because "the launcher ran and saw zero pre-existing Anthropic - * slots" is encoded as `OCX_PRE_BUN_ANTHROPIC_ENV=""` while "no launcher at all, change - * nothing" is encoded as the variable being absent. - * - * The unit tests in claude-auth-mode.test.ts inject that marker directly into a plain - * object, so they would stay green even if a platform collapsed `""` to unset in real - * process spawning — and the production fix would silently become a no-op for exactly - * the case that matters most. This test spawns real processes instead, so the assumption - * is proven by execution on whatever platform CI runs (Linux, Windows, macOS). + * Project dotenv can write environment variables before OpenCodex evaluates, + * but it cannot add the random proof argument emitted by the plain-Node npm + * launcher. Exercise that split through a real Bun child on every CI platform. */ -describe("empty-string env transport across a real spawn (#701)", () => { - const dir = mkdtempSync(join(tmpdir(), "ocx-dotenv-provenance-")); - const probe = join(dir, "probe.mjs"); +describe("Node launcher context transport", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-launch-context-")); + const probe = join(dir, "probe.ts"); + const moduleUrl = pathToFileURL(join(import.meta.dir, "..", "src", "cli", "launcher-context.ts")).href; writeFileSync( probe, - 'const v = process.env.OCX_PRE_BUN_ANTHROPIC_ENV;\n' - + 'process.stdout.write(JSON.stringify({ type: typeof v, value: v ?? null, own: "OCX_PRE_BUN_ANTHROPIC_ENV" in process.env }));\n', + `import { initializeNodeLauncherContext } from ${JSON.stringify(moduleUrl)};\n` + + "const context = initializeNodeLauncherContext();\n" + + "process.stdout.write(JSON.stringify({ context, args: process.argv.slice(2), contextEnv: process.env.OCX_NODE_LAUNCH_CONTEXT ?? null }));\n", ); - function probeWith(env: NodeJS.ProcessEnv | undefined): { type: string; value: string | null; own: boolean } { - const result = spawnSync(process.execPath, [probe], { - encoding: "utf8", - ...(env ? { env } : {}), - }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + const proof = "A".repeat(43); + const context = JSON.stringify({ + version: 1, + proof, + anthropicEnvSlots: ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"], + }); + + function run(args: string[], contextEnv: string | undefined) { + const env = { ...process.env }; + delete env.OCX_PRE_BUN_ANTHROPIC_ENV; + if (contextEnv === undefined) delete env.OCX_NODE_LAUNCH_CONTEXT; + else env.OCX_NODE_LAUNCH_CONTEXT = contextEnv; + const result = spawnSync(process.execPath, [probe, ...args], { encoding: "utf8", env }); expect(result.status).toBe(0); - return JSON.parse(result.stdout) as { type: string; value: string | null; own: boolean }; + return JSON.parse(result.stdout) as { + context: { anthropicEnvSlots: string[] } | null; + args: string[]; + contextEnv: string | null; + }; } - test("an empty marker arrives as an own property whose value is the empty string", () => { - const seen = probeWith({ ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: "" }); - expect(seen.type).toBe("string"); - expect(seen.value).toBe(""); - expect(seen.own).toBe(true); + test("matching argv proof authenticates and consumes the parent snapshot", () => { + const seen = run([`--ocx-internal-launch-proof=${proof}`, "claude"], context); + expect(seen.context?.anthropicEnvSlots).toEqual(["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"]); + expect(seen.args).toEqual(["claude"]); + expect(seen.contextEnv).toBeNull(); }); - test("a populated marker arrives verbatim", () => { - const seen = probeWith({ ...process.env, OCX_PRE_BUN_ANTHROPIC_ENV: "ANTHROPIC_API_KEY" }); - expect(seen.value).toBe("ANTHROPIC_API_KEY"); + test("a dotenv-forged context without the argv proof is rejected", () => { + const seen = run(["claude"], context); + expect(seen.context).toBeNull(); + expect(seen.args).toEqual(["claude"]); + expect(seen.contextEnv).toBeNull(); }); - // The distinction the fix depends on: absent is NOT the same as empty. - test("an absent marker stays absent rather than becoming an empty string", () => { - const inherited = { ...process.env }; - delete inherited.OCX_PRE_BUN_ANTHROPIC_ENV; - const seen = probeWith(inherited); - expect(seen.type).toBe("undefined"); - expect(seen.own).toBe(false); + test("duplicate internal proofs fail closed and are removed from user argv", () => { + const seen = run([ + `--ocx-internal-launch-proof=${proof}`, + `--ocx-internal-launch-proof=${proof}`, + "claude", + ], context); + expect(seen.context).toBeNull(); + expect(seen.args).toEqual(["claude"]); }); }); diff --git a/tests/cli-catalog-prewarm.test.ts b/tests/cli-catalog-prewarm.test.ts index 1043354a4..de740ba31 100644 --- a/tests/cli-catalog-prewarm.test.ts +++ b/tests/cli-catalog-prewarm.test.ts @@ -53,7 +53,7 @@ describe("catalog prewarm on handleStart bind", () => { test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); - const bindIdx = cli.indexOf("server = startServer(port);"); + const bindIdx = cli.indexOf("server = startServer(port, localAttestationSecret);"); const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); const breakIdx = cli.indexOf("\n break;", bindIdx); diff --git a/tests/config.test.ts b/tests/config.test.ts index 30d63fd15..1e3b254c0 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1471,10 +1471,11 @@ describe("opencodex config defaults", () => { }); test("runtime port metadata round-trips and validates expected pid", () => { - writeRuntimePort({ pid: 1234, port: 58195, hostname: "0.0.0.0" }); + const attestationSecret = "A".repeat(43); + writeRuntimePort({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); - expect(readRuntimePort()).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0" }); - expect(readRuntimePort(1234)).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0" }); + expect(readRuntimePort()).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); + expect(readRuntimePort(1234)).toEqual({ pid: 1234, port: 58195, hostname: "0.0.0.0", attestationSecret }); expect(readRuntimePort(9999)).toBeNull(); }); @@ -1492,6 +1493,9 @@ describe("opencodex config defaults", () => { writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 1234, port: 99999 }), "utf-8"); expect(readRuntimePort()).toBeNull(); + + writeFileSync(getRuntimePortPath(), JSON.stringify({ pid: 1234, port: 58195, attestationSecret: "too-short" }), "utf-8"); + expect(readRuntimePort()).toBeNull(); }); }); diff --git a/tests/gcp-adc.test.ts b/tests/gcp-adc.test.ts index 387fa47fc..c23b11df2 100644 --- a/tests/gcp-adc.test.ts +++ b/tests/gcp-adc.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getVertexAccessToken, __resetVertexTokenCache } from "../src/lib/gcp-adc"; import { createGoogleAdapter } from "../src/adapters/google"; +import { providerManagementConfigError } from "../src/server/auth-cors"; import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { STATE_STORE_REGISTRATIONS } from "../src/lib/state-store-registrations"; @@ -149,6 +150,54 @@ describe("google adapter vertex mode", () => { expect(req.url).toBe("https://aiplatform.googleapis.com/v1/projects/proj-1/locations/global/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); }); + test("vertex + ADC rejects a location that can alter the request authority before fetching a token", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const provider = { + adapter: "google", + baseUrl: "https://x", + googleMode: "vertex", + project: "proj-1", + location: "attacker.example:443/capture#", + } as OcxProviderConfig; + await expect(createGoogleAdapter(provider).buildRequest(parsed())).rejects.toThrow( + "Vertex AI location must be a single lowercase Google Cloud location label", + ); + expect(oauthCalls).toBe(0); + }); + + test("provider management rejects unsafe Vertex locations, including registry-backfilled mode", () => { + const explicit = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + googleMode: "vertex", + location: "attacker.example/path", + } as OcxProviderConfig; + expect(providerManagementConfigError("custom-vertex", explicit)).toContain( + "Vertex AI location must be a single lowercase Google Cloud location label", + ); + + const registryBackfilled = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + location: "attacker.example/path", + } as OcxProviderConfig; + expect(providerManagementConfigError("google-vertex", registryBackfilled)).toContain( + "Vertex AI location must be a single lowercase Google Cloud location label", + ); + }); + + test("provider management preserves legitimate Vertex locations", () => { + for (const location of ["global", "us-central1", "europe-west4", "us"]) { + const provider = { + adapter: "google", + baseUrl: "https://aiplatform.googleapis.com", + googleMode: "vertex", + location, + } as OcxProviderConfig; + expect(providerManagementConfigError("custom-vertex", provider)).toBeNull(); + } + }); + test("ai-studio default mode is unchanged (no regression)", async () => { const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "ai-key" } as OcxProviderConfig; const req = await createGoogleAdapter(provider).buildRequest(parsed()); diff --git a/tests/local-management-attestation.test.ts b/tests/local-management-attestation.test.ts new file mode 100644 index 000000000..0df59a860 --- /dev/null +++ b/tests/local-management-attestation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { + createLocalAttestationChallenge, + createLocalAttestationProof, + createLocalAttestationSecret, + verifyLocalAttestationProof, +} from "../src/lib/local-management-attestation"; + +describe("local management listener attestation", () => { + test("a proof authenticates one challenge, pid, and port", () => { + const secret = createLocalAttestationSecret(); + const challenge = createLocalAttestationChallenge(); + const proof = createLocalAttestationProof(secret, challenge, 4242, 19191); + expect(proof).not.toBeNull(); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19191, proof)).toBe(true); + expect(verifyLocalAttestationProof(secret, challenge, 4243, 19191, proof)).toBe(false); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19192, proof)).toBe(false); + expect(verifyLocalAttestationProof(secret, createLocalAttestationChallenge(), 4242, 19191, proof)).toBe(false); + }); + + test("malformed secrets, challenges, and proofs fail closed", () => { + const secret = createLocalAttestationSecret(); + const challenge = createLocalAttestationChallenge(); + expect(createLocalAttestationProof("short", challenge, 4242, 19191)).toBeNull(); + expect(createLocalAttestationProof(secret, "short", 4242, 19191)).toBeNull(); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19191, null)).toBe(false); + expect(verifyLocalAttestationProof(secret, challenge, 4242, 19191, "not-a-proof")).toBe(false); + }); +}); diff --git a/tests/oauth-health.test.ts b/tests/oauth-health.test.ts index 920f3d40b..e8dd3eb24 100644 --- a/tests/oauth-health.test.ts +++ b/tests/oauth-health.test.ts @@ -22,6 +22,11 @@ import { } from "../src/codex/routing"; import type { OcxConfig } from "../src/types"; import { formatOAuthHealthForStatus } from "../src/cli/status-oauth"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} from "../src/lib/local-management-attestation"; const origHome = process.env.HOME; const origOcxHome = process.env.OPENCODEX_HOME; @@ -174,10 +179,17 @@ describe("collectOAuthHealthEntriesForCli", () => { test("uses management API Codex health and does not read CLI process maps", async () => { markCodexAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; + const attestationSecret = "A".repeat(43); let authorization: string | null = null; const report = await collectOAuthHealthEntriesForCli(Date.now(), { - findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: null }), - fetchImpl: async (_input, init) => { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), + fetchImpl: async (input, init) => { + if (String(input).endsWith("/healthz")) { + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; + const proof = createLocalAttestationProof(attestationSecret, challenge, 4242, 19191)!; + return new Response("ok", { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: proof } }); + } authorization = new Headers(init?.headers).get("authorization"); return new Response(JSON.stringify({ accounts: [{ @@ -203,6 +215,39 @@ describe("collectOAuthHealthEntriesForCli", () => { expect(remote?.action).toContain("wait until"); }); + test("never sends the admin token to a configured-port listener without runtime attestation", async () => { + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; + let fetchCalls = 0; + const report = await collectOAuthHealthEntriesForCli(Date.now(), { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "config" }), + readRuntimePortImpl: () => null, + fetchImpl: async (_input, init) => { + fetchCalls += 1; + expect(new Headers(init?.headers).get("authorization")).toBeNull(); + return new Response("fake"); + }, + }); + expect(fetchCalls).toBe(0); + expect(report.codexHealthSource).toBe("management-api-unavailable"); + }); + + test("an invalid listener proof cannot unlock the bearer-bearing request", async () => { + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "ocx-admin-health-test"; + const attestationSecret = "A".repeat(43); + let apiCalls = 0; + const report = await collectOAuthHealthEntriesForCli(Date.now(), { + findLiveProxyImpl: async () => ({ hostname: "127.0.0.1", port: 19191, pid: 4242, source: "runtime" }), + readRuntimePortImpl: () => ({ pid: 4242, port: 19191, attestationSecret }), + fetchImpl: async (input, init) => { + expect(new Headers(init?.headers).get("authorization")).toBeNull(); + if (!String(input).endsWith("/healthz")) apiCalls += 1; + return new Response("fake", { headers: { [LOCAL_ATTESTATION_PROOF_HEADER]: "B".repeat(43) } }); + }, + }); + expect(apiCalls).toBe(0); + expect(report.codexHealthSource).toBe("management-api-unavailable"); + }); + test("labels unavailable fallback and omits process-local Codex maps", async () => { markCodexAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const report = await collectOAuthHealthEntriesForCli(Date.now(), { diff --git a/tests/ocx-launcher-source.test.ts b/tests/ocx-launcher-source.test.ts index 15f5f9a61..f634bf19b 100644 --- a/tests/ocx-launcher-source.test.ts +++ b/tests/ocx-launcher-source.test.ts @@ -55,10 +55,13 @@ describe("ocx.mjs npm launcher (source invariants)", () => { // auto-load `.env` while the Bun child does. Losing this half silently returns the // proxy to billing a subscriber's API key from an ambient file, and the runtime half in // src/cli/claude.ts would keep passing its own unit tests while doing nothing. - test("the Bun child receives the pre-Bun Anthropic provenance marker", () => { - expect(source).toContain("const preBunAnthropicSlots = [\"ANTHROPIC_API_KEY\", \"ANTHROPIC_AUTH_TOKEN\"]"); - expect(source).toContain("OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots.join(\",\")"); - // The marker must be computed from the launcher's OWN env, before Bun's dotenv load. + test("the Bun child receives proof-bound pre-Bun Anthropic provenance", () => { + expect(source).toContain("const preBunAnthropicSlots = [\"ANTHROPIC_API_KEY\", \"ANTHROPIC_AUTH_TOKEN\", \"ANTHROPIC_BASE_URL\"]"); + expect(source).toContain("const launchProof = randomBytes(32).toString(\"base64url\")"); + expect(source).toContain("[NODE_LAUNCH_CONTEXT_ENV]: launchContext"); + expect(source).toContain("`${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`"); + expect(source).not.toContain("OCX_PRE_BUN_ANTHROPIC_ENV: preBunAnthropicSlots"); + // The snapshot must be computed from the launcher's OWN env, before Bun's dotenv load. expect(source).toContain("typeof process.env[name] === \"string\" && process.env[name] !== \"\""); }); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 076e84f0f..54f5c79fa 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -23,6 +23,11 @@ import { timedOutSecretPathCountForTests, hardenSecretDir, } from "../src/lib/windows-secret-acl"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + verifyLocalAttestationProof, +} from "../src/lib/local-management-attestation"; const previousHome = process.env.OPENCODEX_HOME; const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN; @@ -89,6 +94,21 @@ afterEach(() => { }); describe("management and data-plane credential separation", () => { + test("healthz proves the listener owns the protected runtime secret", async () => { + const secret = "A".repeat(43); + const challenge = "B".repeat(43); + const server = startServer(0, secret); + try { + const health = await fetch(new URL("/healthz", server.url), { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, + }); + const proof = health.headers.get(LOCAL_ATTESTATION_PROOF_HEADER); + expect(verifyLocalAttestationProof(secret, challenge, process.pid, server.port, proof)).toBe(true); + } finally { + await server.stop(true); + } + }); + test("management-token temp cleanup forgets successful ACL memos and retains failed removals", () => { const temporary = join(testHome, ".admin-token.tmp"); const previousUsername = process.env.USERNAME; diff --git a/tests/service.test.ts b/tests/service.test.ts index a2ebc5838..aa224a526 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -577,37 +577,47 @@ describe("Windows service task", () => { describe("launchd service plist", () => { test("every durable launcher stamps the Bun provenance paired with the binary it baked (#848)", () => { - const inherited = process.env.OPENCODEX_BUN_PATH; + const inheritedOverride = process.env.OPENCODEX_BUN_PATH; + const inheritedSource = process.env.OCX_BUN_RUNTIME_SOURCE; + const inheritedPath = process.env.OCX_BUN_RUNTIME_PATH; const overrideBun = join(TEST_DIR, "provenance-override-bun.exe"); mkdirSync(TEST_DIR, { recursive: true }); writeFileSync(overrideBun, "x".repeat(2 * 1024 * 1024)); try { - // With a valid override active, every launcher must bake THAT binary and - // label it `override` — a marker that disagreed with the baked path would be - // worse than no marker at all. + // OPENCODEX_BUN_PATH is consumed by the Node launcher before Bun can load a + // project dotenv. Once Bun is running, an unpaired value is untrusted and + // must never be persisted into a durable launcher. + delete process.env.OCX_BUN_RUNTIME_SOURCE; + delete process.env.OCX_BUN_RUNTIME_PATH; process.env.OPENCODEX_BUN_PATH = overrideBun; const plist = buildPlist(); - expect(plist).toContain("OCX_BUN_RUNTIME_SOURCEoverride"); - expectTextToContainPath(plist, overrideBun); + expect(plist).not.toContain("OCX_BUN_RUNTIME_SOURCEoverride"); + expect(plist).not.toContain(overrideBun); const unit = buildUnit(); - expect(unit).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); - expectTextToContainPath(unit, overrideBun); + expect(unit).not.toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); + expect(unit).not.toContain(overrideBun); const script = buildWindowsServiceScript(); - expect(script).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); - expect(script).toContain('echo bun_source="override"'); - - // No override: the same three fall back to the bundled/process runtime and say so. - delete process.env.OPENCODEX_BUN_PATH; - const bundledPlist = buildPlist(); - expect(bundledPlist).toMatch(/OCX_BUN_RUNTIME_SOURCE<\/key>(bundled|process)<\/string>/); - expect(bundledPlist).not.toContain(">override<"); - expect(buildUnit()).toMatch(/Environment="OCX_BUN_RUNTIME_SOURCE=(bundled|process)"/); - expect(buildWindowsServiceScript()).toMatch(/set "OCX_BUN_RUNTIME_SOURCE=(bundled|process)"/); + expect(script).not.toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); + expect(script).not.toContain(overrideBun); + + // A source/path pair stamped by the Node launcher is accepted only when it + // names the Bun executable that is actually running this process. + process.env.OCX_BUN_RUNTIME_SOURCE = "override"; + process.env.OCX_BUN_RUNTIME_PATH = process.execPath; + const trustedPlist = buildPlist(); + expect(trustedPlist).toContain("OCX_BUN_RUNTIME_SOURCEoverride"); + expectTextToContainPath(trustedPlist, process.execPath); + expect(buildUnit()).toContain('Environment="OCX_BUN_RUNTIME_SOURCE=override"'); + expect(buildWindowsServiceScript()).toContain('set "OCX_BUN_RUNTIME_SOURCE=override"'); } finally { - if (inherited === undefined) delete process.env.OPENCODEX_BUN_PATH; - else process.env.OPENCODEX_BUN_PATH = inherited; + if (inheritedOverride === undefined) delete process.env.OPENCODEX_BUN_PATH; + else process.env.OPENCODEX_BUN_PATH = inheritedOverride; + if (inheritedSource === undefined) delete process.env.OCX_BUN_RUNTIME_SOURCE; + else process.env.OCX_BUN_RUNTIME_SOURCE = inheritedSource; + if (inheritedPath === undefined) delete process.env.OCX_BUN_RUNTIME_PATH; + else process.env.OCX_BUN_RUNTIME_PATH = inheritedPath; } }); diff --git a/tests/update-notify.test.ts b/tests/update-notify.test.ts index a6f200cd5..627a41795 100644 --- a/tests/update-notify.test.ts +++ b/tests/update-notify.test.ts @@ -124,7 +124,7 @@ describe("cli wiring", () => { const cli = await readText("src/cli/index.ts"); const promptIndex = cli.indexOf("await maybeShowUpdatePrompt()"); const portIndex = cli.indexOf("let port = await chooseListenPort"); - const serverIndex = cli.indexOf("startServer(port)"); + const serverIndex = cli.indexOf("startServer(port, localAttestationSecret)"); expect(promptIndex).toBeGreaterThan(-1); expect(portIndex).toBeGreaterThan(-1); expect(promptIndex).toBeLessThan(portIndex); diff --git a/tests/update-stop-first.test.ts b/tests/update-stop-first.test.ts index e1c902a08..8fa65b9ad 100644 --- a/tests/update-stop-first.test.ts +++ b/tests/update-stop-first.test.ts @@ -132,6 +132,6 @@ describe("/healthz identity fields", () => { test("healthz advertises service identity, pid, and port", () => { expect(serverSource).toContain('service: "opencodex"'); expect(serverSource).toContain("pid: process.pid"); - expect(serverSource).toContain("port: listenPort"); + expect(serverSource).toContain("port: healthPort"); }); });