diff --git a/.codex/skills/dev-instance/SKILL.md b/.codex/skills/dev-instance/SKILL.md
index 6f1c1b655..3bcf5ba14 100644
--- a/.codex/skills/dev-instance/SKILL.md
+++ b/.codex/skills/dev-instance/SKILL.md
@@ -119,8 +119,14 @@ The dev instance should exercise the real system:
- real LLM: needs a model credential for the harness you run. Core supports several
(`HARNESS=pi|opencode|codex|claude`); the launcher picks one from the credentials it
- finds and honours an explicit `HARNESS`. Set the key your chosen harness expects, or
- pass `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring check
+ finds and honours an explicit `HARNESS`. Set the key your chosen harness expects. For
+ Codex, a ChatGPT OAuth session is also supported: `HARNESS=codex` discovers a valid
+ `$HOME/.codex/auth.json`, or you can set `CODEX_AUTH_FILE` to another auth file. Core
+ refreshes OAuth tokens centrally and hands the Codex child ephemeral material (no
+ refresh token). Pass `DEV_INSTANCE_ALLOW_MOCK=1` for a deliberate no-model wiring
+ check. The auth-file path is for local dev instances; deployed production processes
+ use an API key or a keychain credential (`CODEX_AUTH_CREDENTIAL` /
+ `CLAUDE_AUTH_CREDENTIAL`), whose secret lives encrypted in its owner's keychain.
- real durability: uses `DATABASE_URL` when supplied; otherwise starts/reuses a local
Docker Postgres container and runs core with `SESSION_STORE=postgres` and
`RUN_STORE=postgres`
diff --git a/.env.example b/.env.example
index b5d83f83a..7fb0a747f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,13 @@
HARNESS=pi
+# Subscription-backed harness auth. Production path: point at a keychain
+# credential id (the login lives encrypted in its owner's keychain; core
+# refreshes it centrally and hands harnesses ephemeral derived material).
+#CODEX_AUTH_CREDENTIAL=
+#CLAUDE_AUTH_CREDENTIAL=
+# Local-dev fallback only: a Codex CLI auth.json on this machine
+# (defaults to ~/.codex/auth.json when HARNESS=codex). Not allowed in production.
+CODEX_AUTH_FILE=
HARNESS_SECURITY_POSTURE=auto
#ANTHROPIC_API_KEY=sk-ant-...
diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html
index becbb6f75..12f9a246a 100644
--- a/plugins/admin/public/index.html
+++ b/plugins/admin/public/index.html
@@ -7253,19 +7253,20 @@
Confirm governance change
const baseModel = config.data.baseModel || config.data.baseModelDefault || "";
const baseProvider = onboardingProviderForModel(baseModel);
const baseStatus = onboardingModelStatuses.find((item) => item.provider === baseProvider);
- onboardingBadge(
- "onboarding-model-badge",
- baseStatus?.configured ? "Ready" : "Needs a key",
- Boolean(baseStatus?.configured),
- );
+ const harnessAuth = models.data.harnessAuth;
+ const baseHarnessAuth = Boolean(harnessAuth && harnessAuth.provider === baseProvider);
+ const baseReady = Boolean(baseModel) && (Boolean(baseStatus?.configured) || baseHarnessAuth);
+ onboardingBadge("onboarding-model-badge", baseReady ? "Ready" : "Needs a key", baseReady);
$("onboarding-model-summary").textContent = !baseModel
? "No base model is configured yet — pick a provider and model below."
: baseStatus?.configured
? baseModel + " · " + (baseStatus.source === "admin" ? "admin-managed key" : "deployment key")
- : baseModel +
- " cannot run until its " +
- (MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
- " key is configured.";
+ : baseHarnessAuth
+ ? baseModel + " · authenticated by the " + harnessAuth.harnessId + " harness — no API key needed."
+ : baseModel +
+ " cannot run until its " +
+ (MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
+ " key is configured.";
renderOnboardingProviderOptions(baseProvider);
renderOnboardingModelOptions(baseModel);
diff --git a/plugins/admin/test/onboarding-view.test.ts b/plugins/admin/test/onboarding-view.test.ts
index c7fe9b4fc..406390c21 100644
--- a/plugins/admin/test/onboarding-view.test.ts
+++ b/plugins/admin/test/onboarding-view.test.ts
@@ -28,6 +28,98 @@ function resolveView(pathname: string, search: string): string {
return vm.runInContext(src, context);
}
+interface FakeElement {
+ textContent: string;
+ className: string;
+ value: string;
+ placeholder: string;
+ disabled: boolean;
+ href: string;
+ options: Array<{ value?: string; textContent?: string }>;
+ appendChild(option: { value?: string; textContent?: string }): void;
+}
+
+async function runLoadOnboarding(modelProviders: unknown): Promise> {
+ const src = slice("let onboardingModels = {};", '$("onboarding-model-provider").onchange') + "\nloadOnboarding();";
+ const elements: Record = {};
+ const fixtures: Record = {
+ "/api/model-providers": modelProviders,
+ "/api/slack-installation": { configured: false },
+ "/api/connector-catalog": { catalog: [] },
+ "/api/scopes/org%3Adefault-org": { baseModel: "claude-opus-5" },
+ };
+ const context = vm.createContext({
+ $: (id: string) =>
+ (elements[id] ??= {
+ textContent: "",
+ className: "",
+ value: "",
+ placeholder: "",
+ disabled: false,
+ href: "",
+ options: [],
+ appendChild(option) {
+ this.options.push(option);
+ },
+ }),
+ api: async (_method: string, path: string) => ({ ok: true, data: fixtures[path] ?? {} }),
+ orgScope: () => "org:default-org",
+ encodeURIComponent,
+ setStatus: () => {},
+ connectorName: (id: string) => id,
+ viewLoadedAt: {},
+ Date,
+ document: { createElement: () => ({}) },
+ });
+ await vm.runInContext(src, context);
+ return elements;
+}
+
+const UNCONFIGURED_PROVIDERS = [
+ { provider: "anthropic", configured: false, source: "absent" },
+ { provider: "openai", configured: false, source: "absent" },
+ { provider: "openrouter", configured: false, source: "absent" },
+];
+const ANTHROPIC_MODELS = [{ id: "claude-opus-5", name: "Claude Opus 5", provider: "anthropic" }];
+
+test("harness-carried auth shows the model step as ready without a stored key", async () => {
+ const elements = await runLoadOnboarding({
+ providers: UNCONFIGURED_PROVIDERS,
+ models: ANTHROPIC_MODELS,
+ harnessAuth: { harnessId: "claude", provider: "anthropic" },
+ });
+ assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready");
+ assert.equal(elements["onboarding-model-badge"]!.className, "badge ok");
+ assert.equal(
+ elements["onboarding-model-summary"]!.textContent,
+ "claude-opus-5 · authenticated by the claude harness — no API key needed.",
+ );
+});
+
+test("without harness auth an unconfigured provider still needs a key", async () => {
+ const elements = await runLoadOnboarding({
+ providers: UNCONFIGURED_PROVIDERS,
+ models: ANTHROPIC_MODELS,
+ });
+ assert.equal(elements["onboarding-model-badge"]!.textContent, "Needs a key");
+ assert.equal(elements["onboarding-model-badge"]!.className, "badge warn");
+ assert.match(elements["onboarding-model-summary"]!.textContent, /cannot run until its Anthropic key is configured/);
+});
+
+test("a stored key keeps its summary even when the harness also carries auth", async () => {
+ const elements = await runLoadOnboarding({
+ providers: [
+ { provider: "anthropic", configured: true, source: "admin" },
+ { provider: "openai", configured: false, source: "absent" },
+ { provider: "openrouter", configured: false, source: "absent" },
+ ],
+ models: ANTHROPIC_MODELS,
+ harnessAuth: { harnessId: "claude", provider: "anthropic" },
+ });
+ assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready");
+ assert.equal(elements["onboarding-model-summary"]!.textContent, "claude-opus-5 · admin-managed key");
+});
+
test("onboarding is a navigable view", () => {
assert.match(html, /\{ label: "Admin", views: \["onboarding",/);
});
diff --git a/scripts/dev/lib/envctx.ts b/scripts/dev/lib/envctx.ts
index e22d460ed..e2be0aa51 100644
--- a/scripts/dev/lib/envctx.ts
+++ b/scripts/dev/lib/envctx.ts
@@ -4,11 +4,13 @@ import { dirname, join } from "node:path";
import { liveEnvPath } from "./pool.ts";
import { bestEffort, readEnvFile, sha256Hex } from "./util.ts";
import { run } from "./proc.ts";
+import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "../../../src/harness/codex-auth-file.ts";
export interface AssembledEnv {
env: Record;
anthropicKeySource: string;
openaiKeySource: string;
+ codexAuthSource: string;
harness: "pi" | "mock" | "opencode" | "codex" | "claude";
liveEnvFile: string;
warnings: string[];
@@ -100,6 +102,7 @@ export async function assembleEnv(opts: {
for (const [k, v] of Object.entries(readEnvFile(liveEnvFile))) {
if (!env[k]) env[k] = v;
}
+ const wtEnv = readEnvFile(join(opts.worktree, ".env"));
let anthropicKeySource = "";
if (opts.callerEnv.ANTHROPIC_API_KEY) anthropicKeySource = "your shell export";
@@ -112,7 +115,6 @@ export async function assembleEnv(opts: {
anthropicKeySource = liveEnvFile;
}
}
- const wtEnv = readEnvFile(join(opts.worktree, ".env"));
if (!env.ANTHROPIC_API_KEY && wtEnv.ANTHROPIC_API_KEY) {
env.ANTHROPIC_API_KEY = wtEnv.ANTHROPIC_API_KEY;
anthropicKeySource = "the worktree .env";
@@ -125,13 +127,22 @@ export async function assembleEnv(opts: {
openaiKeySource = "the worktree .env";
}
+ if (!env.CODEX_AUTH_FILE && wtEnv.CODEX_AUTH_FILE) env.CODEX_AUTH_FILE = wtEnv.CODEX_AUTH_FILE;
+ let codexAuthSource = "";
+ const codexAuthCandidate = codexAuthFileForEnv({ ...env, ...opts.callerEnv }, true);
+ const codexOAuthConfigured = Boolean(codexAuthCandidate && readCodexOAuthAuthFile(codexAuthCandidate));
+ if (codexOAuthConfigured && codexAuthCandidate) {
+ env.CODEX_AUTH_FILE = codexAuthCandidate;
+ codexAuthSource = codexAuthCandidate;
+ }
+
let harness: "pi" | "mock" | "opencode" | "codex" | "claude";
if (opts.callerEnv.HARNESS === "codex" || opts.callerEnv.HARNESS === "claude") {
harness = opts.callerEnv.HARNESS;
env.HARNESS = harness;
- if (harness === "codex" && !env.OPENAI_API_KEY) {
+ if (harness === "codex" && !env.OPENAI_API_KEY && !codexOAuthConfigured) {
throw new Error(
- "HARNESS=codex needs OPENAI_API_KEY (its CLI cannot do browser OAuth in a container) -- export it, or add it to the live env file or the worktree .env",
+ "HARNESS=codex needs OPENAI_API_KEY or a readable ChatGPT OAuth auth.json via CODEX_AUTH_FILE (or ~/.codex/auth.json)",
);
}
} else if (env.ANTHROPIC_API_KEY) {
@@ -155,7 +166,7 @@ export async function assembleEnv(opts: {
if (!env[k] && wtEnv[k]) env[k] = wtEnv[k];
}
- return { env, anthropicKeySource, openaiKeySource, harness, liveEnvFile, warnings };
+ return { env, anthropicKeySource, openaiKeySource, codexAuthSource, harness, liveEnvFile, warnings };
}
export function envFileGet(path: string, key: string): string {
diff --git a/scripts/dev/supervisor/main.ts b/scripts/dev/supervisor/main.ts
index cd057da87..56c5d3f61 100644
--- a/scripts/dev/supervisor/main.ts
+++ b/scripts/dev/supervisor/main.ts
@@ -318,7 +318,9 @@ async function assembleAndPrepare(spec: BootSpec): Promise {
let harnessDetail = `live ${assembled.harness} turns (anthropic key from ${assembled.anthropicKeySource})`;
if (assembled.harness === "mock") harnessDetail = "mock turns";
else if (assembled.harness === "codex") {
- harnessDetail = `live codex turns (openai key from ${assembled.openaiKeySource || "the environment"})`;
+ harnessDetail = assembled.codexAuthSource
+ ? "live codex turns (ChatGPT OAuth auth.json)"
+ : `live codex turns (openai key from ${assembled.openaiKeySource || "the environment"})`;
} else if (assembled.harness === "claude") harnessDetail = "live claude turns (native CLI authentication)";
phase("env", "ok", harnessDetail);
diff --git a/scripts/dev/supervisor/specs.ts b/scripts/dev/supervisor/specs.ts
index 33c0b364c..5d8867f52 100644
--- a/scripts/dev/supervisor/specs.ts
+++ b/scripts/dev/supervisor/specs.ts
@@ -21,6 +21,10 @@ export interface SpecInputs {
export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
const watchArgs = i.watch ? ["--watch"] : [];
const base = { ...i.baseEnv, ...i.sandboxEnv };
+ const siblingBase = Object.fromEntries(
+ Object.entries(base).filter(([key]) => key !== "HOME" && key !== "CODEX_HOME"),
+ );
+ siblingBase.CODEX_AUTH_FILE = "";
const orgId = i.baseEnv.DEV_INSTANCE_ORG_ID || "acme";
const signing: Record = i.coreSigningSecret ? { CORE_SIGNING_SECRET: i.coreSigningSecret } : {};
return [
@@ -58,7 +62,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
cwd: join(i.worktree, "plugins/web-ui"),
argv: ["node", "--env-file-if-exists=.env", "server/index.ts"],
env: {
- ...base,
+ ...siblingBase,
...signing,
PORT: String(i.ports.web),
CORE_API_URL: `http://localhost:${i.ports.core}`,
@@ -78,7 +82,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
cwd: join(i.worktree, "plugins/admin"),
argv: ["node", `--env-file-if-exists=${join(i.worktree, ".env")}`, ...watchArgs, "src/index.ts"],
env: {
- ...base,
+ ...siblingBase,
...signing,
PORT: String(i.ports.admin),
CORE_API_URL: `http://localhost:${i.ports.core}`,
@@ -95,7 +99,7 @@ export function buildChildSpecs(i: SpecInputs): ChildSpec[] {
cwd: join(i.worktree, "plugins/portal"),
argv: ["node", ...watchArgs, "src/index.ts"],
env: {
- ...base,
+ ...siblingBase,
...signing,
PORT: String(i.ports.portal),
PORTAL_PUBLIC_URL: `http://localhost:${i.ports.portal}`,
diff --git a/src/api/deps.ts b/src/api/deps.ts
index 7e0b60d0c..2ab61d689 100644
--- a/src/api/deps.ts
+++ b/src/api/deps.ts
@@ -1,4 +1,4 @@
-import type { ModelProviderAvailability } from "../model/pi-models.ts";
+import type { ModelProvider, ModelProviderAvailability } from "../model/pi-models.ts";
import type { ModelCredentialStore } from "../model/model-credential-store.ts";
import type { CustomProviderStore } from "../model/custom-provider-store.ts";
import type { McpServerStore } from "../mcp/mcp-server-store.ts";
@@ -94,6 +94,7 @@ export interface ServerDeps {
refreshCustomProviders?: () => Promise;
brandingDefault?: OrgBranding;
harnessId?: string;
+ harnessCarriedModelAuth?: ModelProvider;
admin?: AdminService;
rateLimiter?: RateLimiter;
sessions?: SessionStore;
diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts
index a43806558..692a66a0c 100644
--- a/src/api/routes/admin/model-providers.ts
+++ b/src/api/routes/admin/model-providers.ts
@@ -61,6 +61,9 @@ export async function getModelProviders(ctx: ApiCtx): Promise {
return sendJson(ctx.res, 200, {
providers: await ctx.deps.modelCredentials.statuses(),
models: await selectableModelCatalog(ctx.deps.modelCredentialFetch),
+ ...(ctx.deps.harnessCarriedModelAuth
+ ? { harnessAuth: { harnessId: ctx.deps.harnessId ?? "pi", provider: ctx.deps.harnessCarriedModelAuth } }
+ : {}),
});
}
diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts
index 39690ed4a..5172511cb 100644
--- a/src/api/routes/surface.ts
+++ b/src/api/routes/surface.ts
@@ -1062,6 +1062,8 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise {
]);
const harnessId = deps.harnessId ?? "pi";
const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : null;
+ const configuredKeys = deps.providerKeys ?? managedKeys;
+ const providerStatus = harnessId === "pi" && managedKeys ? managedKeys : configuredKeys;
const catalog = managedKeys?.openrouter
? await selectableModelCatalog(deps.modelCredentialFetch)
: builtInModelCatalog();
@@ -1079,7 +1081,9 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise {
webuiModels: configuredPicker.length ? configuredPicker : allowed,
baseModel: resolvedBase,
harnessId,
- ...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}),
+ ...(providerStatus && {
+ modelProviderConfigured: Object.values(providerStatus).some(Boolean) || Boolean(deps.harnessCarriedModelAuth),
+ }),
externalSlackParticipants,
...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}),
});
diff --git a/src/config.ts b/src/config.ts
index 4d1fb16e5..f127ca399 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -14,6 +14,7 @@ import { validateCoreSecretEnv } from "./deployment/secret-schema.ts";
import { DEFAULT_CAPTURE_QUIET_MS } from "./memory/strategies/per-turn.ts";
import { parseSecurityPosture, type SecurityPosture } from "./security/security-posture.ts";
import { slackPluginConfigFromEnv, type SlackPluginConfig } from "./slack/config.ts";
+import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "./harness/codex-auth-file.ts";
import {
MODEL_PROVIDERS,
defaultModelForProvider,
@@ -44,6 +45,11 @@ export interface Config {
opencodeModel?: string;
codexModel?: string;
codexBinPath?: string;
+ codexAuthFile?: string;
+ /** Keychain credential id holding the Codex ChatGPT OAuth auth.json (production path). */
+ codexAuthCredential?: string;
+ /** Keychain credential id holding a Claude Code subscription token (production path). */
+ claudeAuthCredential?: string;
codexProcessEnv: NodeJS.ProcessEnv;
claudeModel?: string;
claudeBinPath?: string;
@@ -163,6 +169,9 @@ export function providerKeysPresent(config: Config): ModelProviderAvailability {
anthropic: Boolean(config.anthropicApiKey),
openai: Boolean(config.openaiApiKey),
openrouter: Boolean(config.openrouterApiKey),
+ ...(config.harness === "codex" && (config.codexAuthFile || config.codexAuthCredential)
+ ? { codexOAuth: true }
+ : {}),
};
}
@@ -170,6 +179,19 @@ export function baseModelProviders(config: Config): ModelProviderAvailability |
return config.modelProvider ? onlyProvider(config.modelProvider) : undefined;
}
+export function harnessCarriedModelAuth(config: Config): ModelProvider | undefined {
+ if (
+ config.harness === "claude" &&
+ (config.claudeAuthCredential ||
+ config.claudeProcessEnv.CLAUDE_CODE_OAUTH_TOKEN ||
+ config.claudeProcessEnv.ANTHROPIC_AUTH_TOKEN)
+ )
+ return "anthropic";
+ if (config.harness === "codex" && (config.codexAuthCredential || config.codexProcessEnv.CODEX_ACCESS_TOKEN))
+ return "openai";
+ return undefined;
+}
+
interface AwsSandboxEnv {
region: string;
profile?: string;
@@ -608,10 +630,30 @@ function modelProviderEnvStrict(env: NodeJS.ProcessEnv): ModelProvider | undefin
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
- const missingSecrets = validateCoreSecretEnv(env);
+ const harness = harnessEnvStrict(env.HARNESS);
+ const codexAuthCredential = env.CODEX_AUTH_CREDENTIAL?.trim() || undefined;
+ const claudeAuthCredential = env.CLAUDE_AUTH_CREDENTIAL?.trim() || undefined;
+ const codexAuthCandidate =
+ harness === "codex" && !codexAuthCredential ? codexAuthFileForEnv(env, true) : undefined;
+ const codexOAuthConfigured = Boolean(codexAuthCandidate && readCodexOAuthAuthFile(codexAuthCandidate));
+ const secretEnv =
+ codexOAuthConfigured && codexAuthCandidate
+ ? { ...env, CODEX_AUTH_FILE: codexAuthCandidate }
+ : { ...env, CODEX_AUTH_FILE: undefined };
+ const missingSecrets = validateCoreSecretEnv(secretEnv);
if (missingSecrets.length) {
throw new Error(`missing or insecure required core secrets: ${missingSecrets.join(", ")}`);
}
+ if (harness === "codex" && !env.OPENAI_API_KEY?.trim() && !codexOAuthConfigured && !codexAuthCredential) {
+ throw new Error(
+ "HARNESS=codex needs OPENAI_API_KEY, a keychain credential via CODEX_AUTH_CREDENTIAL, or a readable ChatGPT OAuth auth.json via CODEX_AUTH_FILE (or ~/.codex/auth.json)",
+ );
+ }
+ if (env.NODE_ENV === "production" && codexOAuthConfigured) {
+ throw new Error(
+ "CODEX_AUTH_FILE is supported for local Codex harnesses only; production must use CODEX_AUTH_CREDENTIAL (keychain custody)",
+ );
+ }
const modelProvider = modelProviderEnvStrict(env);
for (const key of ["SESSION_STORE", "RUN_STORE", "ARTIFACT_STORE"] as const) {
if (env[key] === "sqlite") {
@@ -698,6 +740,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
}
let runStore: "memory" | "postgres" = env.SESSION_STORE === "postgres" ? "postgres" : "memory";
if (env.RUN_STORE === "memory" || env.RUN_STORE === "postgres") runStore = env.RUN_STORE;
+ const codexEnv = { ...env };
+ if (codexOAuthConfigured && codexAuthCandidate) codexEnv.CODEX_AUTH_FILE = codexAuthCandidate;
+ else delete codexEnv.CODEX_AUTH_FILE;
const providerBaseUrls = providerBaseUrlsFromEnv(env);
const codexProcessEnv = Object.fromEntries(
[
@@ -716,7 +761,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
"CODEX_ACCESS_TOKEN",
"HOME",
"CODEX_HOME",
- ].flatMap((name) => (env[name] === undefined ? [] : [[name, env[name]]])),
+ "CODEX_AUTH_FILE",
+ ].flatMap((name) => (codexEnv[name] === undefined ? [] : [[name, codexEnv[name]]])),
) as NodeJS.ProcessEnv;
const claudeProcessEnv = Object.fromEntries(
[
@@ -754,7 +800,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
...(env.DATABASE_URL ? { databaseUrl: env.DATABASE_URL } : {}),
...(env.DATABASE_CA_CERT ? { databaseCaCert: env.DATABASE_CA_CERT } : {}),
...(env.DATABASE_CA_CERT_FILE ? { databaseCaCertFile: env.DATABASE_CA_CERT_FILE } : {}),
- harness: harnessEnvStrict(env.HARNESS),
+ harness,
securityPosture: securityPostureEnvStrict(env.HARNESS_SECURITY_POSTURE),
securityScreenBackend,
...(securityScreenBackend === "proxy"
@@ -782,6 +828,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
...(env.OPENCODE_MODEL || env.PI_MODEL ? { opencodeModel: env.OPENCODE_MODEL || env.PI_MODEL } : {}),
...(env.CODEX_MODEL ? { codexModel: env.CODEX_MODEL } : {}),
...(env.CODEX_BIN ? { codexBinPath: env.CODEX_BIN } : {}),
+ ...(codexOAuthConfigured && codexAuthCandidate ? { codexAuthFile: codexAuthCandidate } : {}),
+ ...(codexAuthCredential ? { codexAuthCredential } : {}),
+ ...(claudeAuthCredential ? { claudeAuthCredential } : {}),
codexProcessEnv,
...(env.CLAUDE_MODEL ? { claudeModel: env.CLAUDE_MODEL } : {}),
...(env.CLAUDE_BIN ? { claudeBinPath: env.CLAUDE_BIN } : {}),
diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts
index 4d487c18f..5a6f44aa0 100644
--- a/src/core/orchestrator.ts
+++ b/src/core/orchestrator.ts
@@ -347,8 +347,8 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
actor.id,
scopeLabel,
sessionId
- ? async (rec) => {
- await deps.sessions.recordLlmRequest(sessionId, { ...rec, scopeLabel });
+ ? async (rec, signal) => {
+ await deps.sessions.recordLlmRequest(sessionId, { ...rec, scopeLabel }, signal);
}
: undefined,
{ hook: "user_input", surface: "steer", origin: "ambient" },
@@ -526,13 +526,13 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
);
const screenSession: { id?: string } = {};
const pendingScreenRequests: HarnessLlmRequestRecord[] = [];
- const recordScreenRequest = async (rec: HarnessLlmRequestRecord): Promise => {
+ const recordScreenRequest = async (rec: HarnessLlmRequestRecord, signal?: AbortSignal): Promise => {
if (!screenSession.id) {
pendingScreenRequests.push(rec);
return;
}
try {
- await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId });
+ await deps.sessions.recordLlmRequest(screenSession.id, { ...rec, scopeLabel: scopeId }, signal);
} catch (err) {
console.error("[orchestrator] failed to persist security screen request snapshot:", errMessage(err));
}
@@ -714,7 +714,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
try {
await withManagedRosterVersion(async () => {
await reconcileSessionParticipants(session.id);
- await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest));
+ await Promise.all(pendingScreenRequests.splice(0).map((rec) => recordScreenRequest(rec)));
for (const overheard of screenedOverheard) {
const imported = await deps.sessions.append(lease, {
type: "user",
@@ -1404,7 +1404,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
try {
await withManagedRosterVersion(async () => {
await reconcileSessionParticipants(session.id);
- await Promise.all(pendingScreenRequests.splice(0).map(recordScreenRequest));
+ await Promise.all(pendingScreenRequests.splice(0).map((rec) => recordScreenRequest(rec)));
return true;
});
if (input.approval) {
@@ -2576,9 +2576,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
deps.modelGateway.recordCall({ at: Date.now(), scopeLabel: scopeId, ...rec });
void deps.budget?.record(actor.id, estimateCostUsd(rec.inputTokens));
},
- recordLlmRequest: async (rec) => {
+ recordLlmRequest: async (rec, signal) => {
try {
- await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId });
+ await deps.sessions.recordLlmRequest(session.id, { ...rec, scopeLabel: scopeId }, signal);
} catch (err) {
console.error("[orchestrator] failed to persist LLM request snapshot:", errMessage(err));
}
diff --git a/src/core/orchestrator/security-screen.ts b/src/core/orchestrator/security-screen.ts
index 845d3fbd5..ae0d3437e 100644
--- a/src/core/orchestrator/security-screen.ts
+++ b/src/core/orchestrator/security-screen.ts
@@ -21,7 +21,7 @@ export type SecurityClassifier = (
payload: string,
actorId: string,
scopeLabel: ScopeId,
- recordLlmRequest?: (rec: HarnessLlmRequestRecord) => void | Promise,
+ recordLlmRequest?: (rec: HarnessLlmRequestRecord, signal?: AbortSignal) => void | Promise,
context?: {
hook?: SecurityScreenHook;
surface?: string;
diff --git a/src/credentials/harness-auth-env.ts b/src/credentials/harness-auth-env.ts
new file mode 100644
index 000000000..a475b10d0
--- /dev/null
+++ b/src/credentials/harness-auth-env.ts
@@ -0,0 +1,33 @@
+import type { Keychain } from "./keychain.ts";
+
+/**
+ * Resolve a keychain env credential (e.g. a CLAUDE_CODE_OAUTH_TOKEN saved by
+ * `claude setup-token`) into the env vars a harness child should receive.
+ * Resolved fresh on every call, so a rotated or revoked credential takes
+ * effect on the next session with no restart. Returns {} when the credential
+ * is missing, expired, or not env-shaped, so the harness falls back to
+ * whatever static configuration it has.
+ */
+export function keychainHarnessAuthEnv(
+ keychain: Keychain,
+ credentialId: string,
+ allowedEnvKeys: readonly string[],
+): () => Promise {
+ return async () => {
+ try {
+ const meta = await keychain.getCredential(credentialId);
+ if (!meta || meta.kind !== "env") return {};
+ if (typeof meta.expiresAt === "number" && meta.expiresAt < Date.now()) return {};
+ const creds = await keychain.materializeOwn(meta.ownerId);
+ const cred = creds.find((c) => c.credentialId === credentialId);
+ if (!cred) return {};
+ const env: NodeJS.ProcessEnv = {};
+ for (const { key, value } of cred.env) {
+ if (allowedEnvKeys.includes(key)) env[key] = value;
+ }
+ return env;
+ } catch {
+ return {};
+ }
+ };
+}
diff --git a/src/deployment/secret-schema.ts b/src/deployment/secret-schema.ts
index 265cc4258..7daf82c89 100644
--- a/src/deployment/secret-schema.ts
+++ b/src/deployment/secret-schema.ts
@@ -43,7 +43,8 @@ export const CORE_SECRET_SPECS: readonly RuntimeSecretSpec[] = [
const GATE_PREDICATES: Readonly boolean>> = {
production: (env) => env.NODE_ENV === "production",
- codex: (env) => env.HARNESS?.trim() === "codex",
+ codex: (env) =>
+ env.HARNESS?.trim() === "codex" && !env.CODEX_AUTH_FILE?.trim() && !env.CODEX_AUTH_CREDENTIAL?.trim(),
postgres: (env) => env.SESSION_STORE === "postgres" || env.RUN_STORE === "postgres",
sprites: (env) => env.SANDBOX_BACKEND === "sprites" || env.SANDBOX_SECONDARY_BACKEND === "sprites",
smolmachines: (env) => env.SANDBOX_BACKEND === "smolmachines" || env.SANDBOX_SECONDARY_BACKEND === "smolmachines",
@@ -54,7 +55,9 @@ const GATE_PREDICATES: Readonly b
"dropbox-oauth": (env) => Boolean(env.DROPBOX_OAUTH_CLIENT_ID),
"linear-oauth": (env) => Boolean(env.LINEAR_OAUTH_CLIENT_ID),
"model-anthropic": (env) => env.MODEL_PROVIDER?.trim() === "anthropic",
- "model-openai": (env) => env.MODEL_PROVIDER?.trim() === "openai",
+ "model-openai": (env) =>
+ env.MODEL_PROVIDER?.trim() === "openai" &&
+ !(env.HARNESS?.trim() === "codex" && (env.CODEX_AUTH_FILE?.trim() || env.CODEX_AUTH_CREDENTIAL?.trim())),
"model-openrouter": (env) => env.MODEL_PROVIDER?.trim() === "openrouter",
};
diff --git a/src/harness/claude-harness.ts b/src/harness/claude-harness.ts
index 2edbb26eb..ffa8a3d2e 100644
--- a/src/harness/claude-harness.ts
+++ b/src/harness/claude-harness.ts
@@ -59,6 +59,12 @@ export interface ClaudeHarnessOptions {
execTimeoutCeilingMs?: number;
backgroundJobTtlMs?: number;
backgroundJobTtlMaxMs?: number;
+ /**
+ * Custodian of subscription auth (e.g. a keychain-held CLAUDE_CODE_OAUTH_TOKEN).
+ * Resolved fresh per session start; merged over static env so the secret
+ * never lives in process env or on the core host's disk.
+ */
+ authEnv?: () => Promise;
signals?: RunSignalStore;
tasks?: TaskStore;
}
@@ -433,12 +439,13 @@ export function createClaudeHarness(opts: ClaudeHarnessOptions = {}): Harness {
swallow("claude: tape append", error);
}
};
+ const authEnv = opts.authEnv ? await opts.authEnv() : undefined;
const sdkQuery = query({
prompt: queue,
options: {
abortController: controller,
cwd: jail,
- env: claudeChildEnv(opts.env ?? {}, jail),
+ env: claudeChildEnv(authEnv ? { ...(opts.env ?? {}), ...authEnv } : (opts.env ?? {}), jail),
tools: allowSubagents ? ["Agent"] : [],
skills: [],
settingSources: [],
diff --git a/src/harness/codex-app-server.ts b/src/harness/codex-app-server.ts
index 6eb7607e6..e65f3182e 100644
--- a/src/harness/codex-app-server.ts
+++ b/src/harness/codex-app-server.ts
@@ -18,6 +18,104 @@ type JsonRpcMessage = {
error?: { code?: number; message?: string; data?: unknown };
};
+type JsonRpcResultValidator = (value: unknown) => value is T;
+const MAX_CANCELLED_REQUEST_IDS = 256;
+
+function isJsonRpcId(value: unknown): value is JsonRpcId {
+ return (typeof value === "string" && value.length > 0) || (typeof value === "number" && Number.isFinite(value));
+}
+
+function isJsonRpcMessage(value: unknown): value is JsonRpcMessage {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const message = value as Record;
+ const hasId = "id" in message && message.id !== undefined;
+ const hasMethod = "method" in message;
+ const hasResult = "result" in message;
+ const hasError = "error" in message;
+ if (!hasId && !hasMethod) return false;
+ if (hasId && !isJsonRpcId(message.id)) return false;
+ if (hasMethod && typeof message.method !== "string") return false;
+ if (hasMethod && (hasResult || hasError)) return false;
+ if (!hasMethod && (!hasId || !(hasResult || hasError))) return false;
+ if (hasResult && hasError) return false;
+ if (hasError) {
+ const error = message.error;
+ if (!error || typeof error !== "object" || Array.isArray(error)) return false;
+ const errorRecord = error as Record;
+ if (typeof errorRecord.code !== "number" || typeof errorRecord.message !== "string") return false;
+ }
+ return true;
+}
+
+const CODEX_DIAGNOSTIC_SENSITIVE_KEYS = new Set([
+ "accesstoken",
+ "refreshtoken",
+ "idtoken",
+ "apikey",
+ "clientsecret",
+ "credential",
+ "credentials",
+ "password",
+ "passphrase",
+ "secret",
+ "token",
+ "authorization",
+ "proxyauthorization",
+ "cookie",
+ "setcookie",
+]);
+
+function diagnosticKeyIsSensitive(key: string): boolean {
+ return CODEX_DIAGNOSTIC_SENSITIVE_KEYS.has(key.toLowerCase().replace(/[^a-z]/g, ""));
+}
+
+function redactStructuredDiagnosticsValue(value: unknown, sensitive = false): unknown {
+ if (sensitive) return "[redacted]";
+ if (Array.isArray(value)) return value.map((item) => redactStructuredDiagnosticsValue(item));
+ if (!value || typeof value !== "object") return value;
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [
+ key,
+ redactStructuredDiagnosticsValue(item, diagnosticKeyIsSensitive(key)),
+ ]),
+ );
+}
+
+function redactStructuredDiagnostics(value: string): string {
+ try {
+ const parsed: unknown = JSON.parse(value);
+ if (!parsed || typeof parsed !== "object") return value;
+ return JSON.stringify(redactStructuredDiagnosticsValue(parsed));
+ } catch {
+ return value;
+ }
+}
+
+export function redactCodexDiagnostics(value: string): string {
+ return redactStructuredDiagnostics(value)
+ .replace(
+ /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\[[\s\S]*?(?:\]|$)/gi,
+ "$1[redacted]",
+ )
+ .replace(
+ /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)\{[\s\S]*$/gi,
+ "$1{redacted}",
+ )
+ .replace(
+ /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(["'])(?:(?:\\[\s\S])|(?!\2)[\s\S])*(?:\2|$)/gi,
+ "$1$2[redacted]$2",
+ )
+ .replace(
+ /(["']?(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|client[_-]?secret|credential|credentials|password|passphrase|secret|token|authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(?!(?:["']|\[))[^,\r\n}\]]+/gi,
+ "$1[redacted]",
+ )
+ .replace(/\b(?:Basic|Digest)\s+\S+/gi, "[redacted]")
+ .replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
+ .replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "[redacted]")
+ .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]")
+ .replace(/\b(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}\b/g, "[redacted]");
+}
+
export interface CodexAppServerOptions {
binaryPath: string;
cwd: string;
@@ -30,7 +128,11 @@ export class CodexAppServer {
readonly process: ChildProcess;
private readonly options: CodexAppServerOptions;
private nextId = 1;
- private readonly pending = new Map();
+ private readonly pending = new Map<
+ JsonRpcId,
+ { resolve(value: unknown): void; reject(error: Error): void; validate?: JsonRpcResultValidator }
+ >();
+ private readonly cancelledRequestIds = new Set();
private writeTail = Promise.resolve();
private eventTail = Promise.resolve();
private stderr = "";
@@ -69,8 +171,9 @@ export class CodexAppServer {
});
this.process.once("close", (code, signal) => {
this.closed = true;
+ const stderr = redactCodexDiagnostics(this.stderr.trim());
this.closeError = new Error(
- `Codex app-server exited (${code ?? signal ?? "unknown"})${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`,
+ `Codex app-server exited (${code ?? signal ?? "unknown"})${stderr ? `: ${stderr}` : ""}`,
);
this.failAll(this.closeError);
resolveProcessClosed();
@@ -89,12 +192,47 @@ export class CodexAppServer {
await this.notify("initialized");
}
- request(method: string, params?: unknown): Promise {
+ request(method: string, params?: unknown, signal?: AbortSignal): Promise;
+ request(method: string, params: unknown, validate: JsonRpcResultValidator, signal?: AbortSignal): Promise;
+ request(
+ method: string,
+ params?: unknown,
+ validateOrSignal?: JsonRpcResultValidator | AbortSignal,
+ signal?: AbortSignal,
+ ): Promise {
if (this.closed) return Promise.reject(new Error("Codex app-server is closed"));
+ const validate = typeof validateOrSignal === "function" ? validateOrSignal : undefined;
+ let requestSignal: AbortSignal | undefined;
+ if (validate) requestSignal = signal;
+ else if (typeof validateOrSignal !== "function") requestSignal = validateOrSignal;
+ if (requestSignal?.aborted) return Promise.reject(new Error("Codex app-server request cancelled"));
const id = this.nextId++;
- const result = new Promise((resolve, reject) => {
- this.pending.set(id, { resolve: (value) => resolve(value as T), reject });
+ let rejectResult!: (error: Error) => void;
+ const result = new Promise((resolve, reject) => {
+ rejectResult = reject;
+ this.pending.set(id, {
+ resolve,
+ reject,
+ ...(validate ? { validate: validate as JsonRpcResultValidator } : {}),
+ });
});
+ if (requestSignal) {
+ const onAbort = () => {
+ if (!this.pending.delete(id)) return;
+ if (this.cancelledRequestIds.size >= MAX_CANCELLED_REQUEST_IDS) {
+ this.failAll(new CodexRpcError("Codex app-server exceeded its cancelled request limit"));
+ this.process.kill("SIGTERM");
+ } else {
+ this.cancelledRequestIds.add(id);
+ }
+ rejectResult(new Error("Codex app-server request cancelled"));
+ };
+ requestSignal.addEventListener("abort", onAbort, { once: true });
+ void result.then(
+ () => requestSignal.removeEventListener("abort", onAbort),
+ () => requestSignal.removeEventListener("abort", onAbort),
+ );
+ }
void this.send({ id, method, ...(params === undefined ? {} : { params }) }).catch((error) => {
const waiter = this.pending.get(id);
this.pending.delete(id);
@@ -120,21 +258,38 @@ export class CodexAppServer {
if (!line.trim()) return;
let message: JsonRpcMessage;
try {
- message = JSON.parse(line) as JsonRpcMessage;
+ const parsed: unknown = JSON.parse(line);
+ if (!isJsonRpcMessage(parsed)) throw new Error("Codex app-server emitted an invalid JSON-RPC message");
+ message = parsed;
} catch {
- throw new Error(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`);
+ throw new Error(redactCodexDiagnostics(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`));
}
if (message.id !== undefined && !message.method) {
const waiter = this.pending.get(message.id);
- if (!waiter) return;
+ if (!waiter) {
+ if (this.cancelledRequestIds.delete(message.id)) return;
+ throw new CodexRpcError(`Codex app-server sent an unknown response id ${String(message.id)}`);
+ }
this.pending.delete(message.id);
- if (message.error)
+ if ("error" in message) {
+ if (!message.error || typeof message.error !== "object") {
+ waiter.reject(new CodexRpcError("Codex app-server response has an invalid error"));
+ return;
+ }
waiter.reject(
new CodexRpcError(
- `Codex ${message.error.code ?? "error"}: ${message.error.message ?? JSON.stringify(message.error.data)}`,
+ redactCodexDiagnostics(
+ `Codex ${message.error.code ?? "error"}: ${message.error.message ?? JSON.stringify(message.error.data)}`,
+ ),
),
);
- else waiter.resolve(message.result);
+ } else if ("result" in message) {
+ if (waiter.validate && !waiter.validate(message.result)) {
+ waiter.reject(new CodexRpcError("Codex app-server response has an invalid result"));
+ return;
+ }
+ waiter.resolve(message.result);
+ } else waiter.reject(new CodexRpcError("Codex app-server response is missing result or error"));
return;
}
if (!message.method) return;
@@ -165,5 +320,6 @@ export class CodexAppServer {
private failAll(error: Error): void {
for (const waiter of this.pending.values()) waiter.reject(error);
this.pending.clear();
+ this.cancelledRequestIds.clear();
}
}
diff --git a/src/harness/codex-auth-file.ts b/src/harness/codex-auth-file.ts
new file mode 100644
index 000000000..34a552059
--- /dev/null
+++ b/src/harness/codex-auth-file.ts
@@ -0,0 +1,119 @@
+import { readFileSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { join, resolve } from "node:path";
+
+export type JsonObject = Record;
+
+const CODEX_OAUTH_MODES = new Set(["chatgpt", "chatgptAuthTokens"]);
+export const CODEX_OAUTH_ISSUER = "https://auth.openai.com";
+
+export function asObject(value: unknown): JsonObject | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : null;
+}
+
+export function codexOAuthJwtAccountIdFromToken(value: unknown): string | undefined {
+ if (typeof value !== "string" || value.split(".").length !== 3) return undefined;
+ try {
+ const payload = asObject(JSON.parse(Buffer.from(value.split(".")[1] ?? "", "base64url").toString("utf8")));
+ const claims = payload ? asObject(payload["https://api.openai.com/auth"]) : null;
+ return typeof claims?.chatgpt_account_id === "string" && claims.chatgpt_account_id
+ ? claims.chatgpt_account_id
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+export function isCodexOAuthJwt(value: unknown): boolean {
+ if (typeof value !== "string" || value.split(".").length !== 3) return false;
+ try {
+ const header = asObject(JSON.parse(Buffer.from(value.split(".")[0] ?? "", "base64url").toString("utf8")));
+ const payload = asObject(JSON.parse(Buffer.from(value.split(".")[1] ?? "", "base64url").toString("utf8")));
+ return header?.alg === "RS256" && payload?.iss === CODEX_OAUTH_ISSUER;
+ } catch {
+ return false;
+ }
+}
+
+export function codexOAuthJwtAccountId(value: unknown): string | undefined {
+ const auth = asObject(value);
+ const tokens = auth ? asObject(auth.tokens) : null;
+ return codexOAuthJwtAccountIdFromToken(tokens?.id_token);
+}
+
+export function readJsonFile(path: string): JsonObject | null {
+ try {
+ return asObject(JSON.parse(readFileSync(path, "utf8")));
+ } catch {
+ return null;
+ }
+}
+
+function expandPath(path: string): string {
+ if (path === "~") return homedir();
+ if (path.startsWith("~/")) return join(homedir(), path.slice(2));
+ return resolve(path);
+}
+
+export function codexAuthFileForEnv(env: NodeJS.ProcessEnv, includeDefault = false): string | undefined {
+ const explicit = env.CODEX_AUTH_FILE?.trim();
+ if (explicit) return expandPath(explicit);
+ if (!includeDefault) return undefined;
+ const codexHome = env.CODEX_HOME?.trim();
+ if (codexHome) return join(expandPath(codexHome), "auth.json");
+ const home = env.HOME?.trim();
+ return home ? join(expandPath(home), ".codex", "auth.json") : undefined;
+}
+
+function isCodexOAuthAuth(value: unknown): value is JsonObject {
+ const auth = asObject(value);
+ if (!auth || typeof auth.auth_mode !== "string" || !CODEX_OAUTH_MODES.has(auth.auth_mode)) return false;
+ const tokens = asObject(auth.tokens);
+ return Boolean(
+ tokens &&
+ typeof tokens.access_token === "string" &&
+ tokens.access_token &&
+ typeof tokens.refresh_token === "string" &&
+ tokens.refresh_token &&
+ codexOAuthJwtAccountId(auth),
+ );
+}
+
+export function readCodexOAuthAuthFile(path: string): JsonObject | null {
+ try {
+ if (statSync(path).mode & 0o077) return null;
+ } catch {
+ return null;
+ }
+ const auth = readJsonFile(path);
+ return isCodexOAuthAuth(auth) ? auth : null;
+}
+
+export function sanitizedCodexOAuthAuth(auth: JsonObject): JsonObject {
+ const copy: JsonObject = {};
+ for (const key of ["auth_mode", "last_refresh", "tokens"] as const) {
+ if (key === "tokens") {
+ const tokens = asObject(auth.tokens);
+ if (tokens) {
+ copy.tokens = Object.fromEntries(
+ ["access_token", "refresh_token", "id_token", "account_id"].flatMap((token) =>
+ typeof tokens[token] === "string" ? [[token, tokens[token]]] : [],
+ ),
+ );
+ }
+ } else if (key in auth) copy[key] = auth[key];
+ }
+ return copy;
+}
+
+export function codexOAuthRefreshToken(value: unknown): string | undefined {
+ const auth = asObject(value);
+ const tokens = auth ? asObject(auth.tokens) : null;
+ return typeof tokens?.refresh_token === "string" && tokens.refresh_token ? tokens.refresh_token : undefined;
+}
+
+export function codexOAuthAccessToken(value: unknown): string | undefined {
+ const auth = asObject(value);
+ const tokens = auth ? asObject(auth.tokens) : null;
+ return typeof tokens?.access_token === "string" && tokens.access_token ? tokens.access_token : undefined;
+}
diff --git a/src/harness/codex-auth-store.ts b/src/harness/codex-auth-store.ts
new file mode 100644
index 000000000..b0d1768d2
--- /dev/null
+++ b/src/harness/codex-auth-store.ts
@@ -0,0 +1,269 @@
+import { CODEX_OAUTH_ISSUER, asObject, codexOAuthJwtAccountId, type JsonObject } from "./codex-auth-file.ts";
+import {
+ codexOAuthAccessToken,
+ codexOAuthRefreshToken,
+ readCodexOAuthAuthFile,
+ sanitizedCodexOAuthAuth,
+} from "./codex-auth-file.ts";
+import type { CredentialFile, Keychain } from "../credentials/keychain.ts";
+import { swallow } from "../util/errors.ts";
+import { acquireCodexOAuthAuthLock, writeCodexOAuthAuthFile } from "./codex-auth.ts";
+
+/**
+ * A CodexAuthStore is the custodian of a ChatGPT-subscription Codex login.
+ *
+ * The store — not the harness, and never the child process — owns the
+ * refresh token and the refresh loop. `load()` returns auth that is fresh
+ * enough to hand to a child; the store refreshes centrally (and persists the
+ * rotated tokens back to its backing storage) before returning when the
+ * access token is near expiry. Children receive derived, ephemeral material
+ * only (see `childCodexOAuthAuth`), so nothing a child does can rotate or
+ * leak the long-lived credential.
+ */
+export interface CodexAuthStore {
+ /** Where the credential lives, for logs and errors. Never includes secrets. */
+ readonly description: string;
+ /** Current auth, centrally refreshed when the access token is stale. Null when unavailable. */
+ load(): Promise;
+}
+
+/** The Codex CLI's public OAuth client id (auth.openai.com device/PKCE client). */
+export const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
+
+/** Refresh when the access token has less than this long to live. */
+const REFRESH_SKEW_MS = 5 * 60_000;
+
+const CODEX_AUTH_FILE_PATHS = [".codex/auth.json", "codex/auth.json"];
+
+function jwtExpiryMs(token: unknown): number | undefined {
+ if (typeof token !== "string" || token.split(".").length !== 3) return undefined;
+ try {
+ const payload = asObject(JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")));
+ return typeof payload?.exp === "number" ? payload.exp * 1000 : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Epoch ms when this auth's access token expires, if it carries an exp claim. */
+export function codexOAuthAccessTokenExpiresAt(auth: JsonObject | null): number | undefined {
+ return jwtExpiryMs(asObject(auth?.tokens)?.access_token);
+}
+
+/** Validate an already-parsed auth.json value the same way readCodexOAuthAuthFile validates a file. */
+export function codexOAuthAuthFromValue(value: unknown): JsonObject | null {
+ const auth = asObject(value);
+ if (!auth) return null;
+ const tokens = asObject(auth.tokens);
+ const mode = typeof auth.auth_mode === "string" ? auth.auth_mode : "";
+ if (!["chatgpt", "chatgptAuthTokens"].includes(mode)) return null;
+ if (
+ !tokens ||
+ typeof tokens.access_token !== "string" ||
+ !tokens.access_token ||
+ typeof tokens.refresh_token !== "string" ||
+ !tokens.refresh_token ||
+ !codexOAuthJwtAccountId(auth)
+ )
+ return null;
+ return auth;
+}
+
+/**
+ * The material a Codex child process receives: the sanitized auth WITHOUT the
+ * refresh token. The child can use the access token until it expires; only the
+ * store may refresh. The next turn's `load()` re-materializes fresh tokens.
+ */
+export function childCodexOAuthAuth(auth: JsonObject): JsonObject {
+ const sanitized = sanitizedCodexOAuthAuth(auth);
+ const tokens = asObject(sanitized.tokens);
+ if (tokens) {
+ const { refresh_token: _refresh, ...rest } = tokens;
+ sanitized.tokens = rest;
+ }
+ return sanitized;
+}
+
+async function refreshCodexOAuth(auth: JsonObject, fetchImpl: typeof fetch): Promise {
+ const refreshToken = codexOAuthRefreshToken(auth);
+ if (!refreshToken) return null;
+ const response = await fetchImpl(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ client_id: CODEX_OAUTH_CLIENT_ID,
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ scope: "openid profile email",
+ }),
+ });
+ if (!response.ok) throw new Error(`Codex OAuth refresh failed: HTTP ${response.status}`);
+ const body = asObject(await response.json().catch(() => null));
+ if (!body || typeof body.access_token !== "string" || !body.access_token) {
+ throw new Error("Codex OAuth refresh returned no access token");
+ }
+ const tokens = asObject(auth.tokens) ?? {};
+ const next: JsonObject = {
+ ...auth,
+ last_refresh: new Date().toISOString(),
+ tokens: {
+ ...tokens,
+ access_token: body.access_token,
+ ...(typeof body.id_token === "string" && body.id_token ? { id_token: body.id_token } : {}),
+ ...(typeof body.refresh_token === "string" && body.refresh_token ? { refresh_token: body.refresh_token } : {}),
+ },
+ };
+ // The refreshed identity must stay on the same ChatGPT account.
+ return codexOAuthAuthFromValue(next) && codexOAuthJwtAccountId(next) === codexOAuthJwtAccountId(auth) ? next : null;
+}
+
+function authNeedsRefresh(auth: JsonObject, now: number): boolean {
+ const expiresAt = codexOAuthAccessTokenExpiresAt(auth);
+ return typeof expiresAt === "number" && expiresAt - now < REFRESH_SKEW_MS;
+}
+
+interface KeychainCodexAuthStoreDeps {
+ keychain: Keychain;
+ /** Keychain credential id of the user's Codex ChatGPT login (a file credential holding auth.json). */
+ credentialId: string;
+ fetchImpl?: typeof fetch;
+ now?: () => number;
+}
+
+function codexAuthFromFiles(files: CredentialFile[]): { path: string; auth: JsonObject } | null {
+ for (const file of files) {
+ const normalized = file.path.replace(/^\.\//, "");
+ if (!CODEX_AUTH_FILE_PATHS.includes(normalized) && !normalized.endsWith("/auth.json")) continue;
+ try {
+ const auth = codexOAuthAuthFromValue(JSON.parse(Buffer.from(file.contentBase64, "base64").toString("utf8")));
+ if (auth) return { path: file.path, auth };
+ } catch {
+ // fall through to the next candidate file
+ }
+ }
+ return null;
+}
+
+/**
+ * Keychain-backed Codex subscription auth. The credential (a file bundle
+ * holding the Codex CLI's auth.json) lives encrypted in its owner's keychain;
+ * core is the single writer. Refreshed tokens are persisted back to the
+ * keychain with a compare-and-set against the refresh token they replaced, so
+ * a concurrent rotation loses cleanly instead of clobbering.
+ */
+export function keychainCodexAuthStore(deps: KeychainCodexAuthStoreDeps): CodexAuthStore {
+ const fetchImpl = deps.fetchImpl ?? fetch;
+ const now = deps.now ?? Date.now;
+ let refreshing: Promise | null = null;
+
+ const readCurrent = async (): Promise<{
+ ownerId: string;
+ service: string;
+ path: string;
+ auth: JsonObject;
+ } | null> => {
+ const meta = await deps.keychain.getCredential(deps.credentialId);
+ if (!meta || meta.kind !== "file") return null;
+ const bundles = await deps.keychain.materializeOwnFiles(meta.ownerId);
+ const bundle = bundles.find((b) => b.credentialId === deps.credentialId);
+ if (!bundle) return null;
+ const found = codexAuthFromFiles(bundle.files);
+ return found ? { ownerId: meta.ownerId, service: meta.service, ...found } : null;
+ };
+
+ const persist = async (
+ current: { ownerId: string; service: string; path: string },
+ replacedRefreshToken: string | undefined,
+ next: JsonObject,
+ ): Promise => {
+ // Compare-and-set: re-read and refuse if someone else rotated first.
+ const latest = await readCurrent();
+ if (!latest || codexOAuthRefreshToken(latest.auth) !== replacedRefreshToken) return false;
+ await deps.keychain.save({
+ ownerId: current.ownerId,
+ service: current.service,
+ files: [{ path: current.path, contentBase64: Buffer.from(JSON.stringify(next), "utf8").toString("base64") }],
+ ...(codexOAuthAccessTokenExpiresAt(next) !== undefined
+ ? { expiresAt: codexOAuthAccessTokenExpiresAt(next) }
+ : {}),
+ });
+ return true;
+ };
+
+ return {
+ description: `keychain credential ${deps.credentialId}`,
+ async load(): Promise {
+ const current = await readCurrent();
+ if (!current) return null;
+ if (!authNeedsRefresh(current.auth, now())) return current.auth;
+ // Single refresh in flight per store; concurrent loads share it.
+ refreshing ??= (async () => {
+ try {
+ const next = await refreshCodexOAuth(current.auth, fetchImpl);
+ if (!next) return null;
+ await persist(current, codexOAuthRefreshToken(current.auth), next);
+ return next;
+ } finally {
+ refreshing = null;
+ }
+ })();
+ try {
+ const refreshed = await refreshing;
+ if (refreshed) return refreshed;
+ } catch (error) {
+ swallow("codex: central oauth refresh", error);
+ }
+ // A stale access token is still worth handing out: the provider decides.
+ return (await readCurrent())?.auth ?? current.auth;
+ },
+ };
+}
+
+/**
+ * File-backed store for local development: the operator's own
+ * ~/.codex/auth.json (or CODEX_AUTH_FILE). Core refreshes centrally and writes
+ * the rotated tokens back atomically under the file lock; children never see
+ * the refresh token, so no child state ever syncs back.
+ */
+export function fileCodexAuthStore(
+ path: string,
+ fetchImpl: typeof fetch = fetch,
+ now: () => number = Date.now,
+): CodexAuthStore {
+ let refreshing: Promise | null = null;
+ return {
+ description: `auth file ${path}`,
+ async load(): Promise {
+ const current = readCodexOAuthAuthFile(path);
+ if (!current) return null;
+ if (!authNeedsRefresh(current, now())) return current;
+ refreshing ??= (async () => {
+ try {
+ const next = await refreshCodexOAuth(current, fetchImpl);
+ if (!next) return null;
+ const lock = await acquireCodexOAuthAuthLock(path, undefined, 10_000, 25);
+ try {
+ const latest = readCodexOAuthAuthFile(path);
+ // Compare-and-set: refuse if another process rotated first.
+ if (!latest || codexOAuthRefreshToken(latest) !== codexOAuthRefreshToken(current)) return latest;
+ writeCodexOAuthAuthFile(path, next);
+ } finally {
+ await lock.release();
+ }
+ return next;
+ } finally {
+ refreshing = null;
+ }
+ })();
+ try {
+ const refreshed = await refreshing;
+ if (refreshed) return refreshed;
+ } catch (error) {
+ swallow("codex: file oauth refresh", error);
+ }
+ return readCodexOAuthAuthFile(path) ?? current;
+ },
+ };
+}
+
+export { codexOAuthAccessToken, codexOAuthRefreshToken };
diff --git a/src/harness/codex-auth.ts b/src/harness/codex-auth.ts
new file mode 100644
index 000000000..d0ce7f137
--- /dev/null
+++ b/src/harness/codex-auth.ts
@@ -0,0 +1,182 @@
+import { randomBytes } from "node:crypto";
+import { open as openFile } from "node:fs/promises";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ unlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, join } from "node:path";
+import { swallow } from "../util/errors.ts";
+import type { JsonObject } from "./codex-auth-file.ts";
+
+export {
+ codexAuthFileForEnv,
+ codexOAuthAccessToken,
+ codexOAuthRefreshToken,
+ readCodexOAuthAuthFile,
+ sanitizedCodexOAuthAuth,
+} from "./codex-auth-file.ts";
+
+const heldOAuthLockPaths = new Set();
+
+function writeJsonAtomically(path: string, value: JsonObject): void {
+ const directory = dirname(path);
+ mkdirSync(directory, { recursive: true });
+ const temporary = join(directory, `.qm-codex-auth-${process.pid}-${randomBytes(8).toString("hex")}.tmp`);
+ try {
+ writeFileSync(temporary, JSON.stringify(value), { mode: 0o600 });
+ chmodSync(temporary, 0o600);
+ renameSync(temporary, path);
+ } finally {
+ rmSync(temporary, { force: true });
+ }
+}
+
+/** Atomically replace a Codex auth.json with 0600 permissions. */
+export function writeCodexOAuthAuthFile(path: string, auth: JsonObject): void {
+ writeJsonAtomically(path, auth);
+}
+
+function lockPath(sourcePath: string): string {
+ return `${sourcePath}.lock`;
+}
+
+export interface CodexOAuthAuthLock {
+ path: string;
+ isHeld(): boolean;
+ release(): Promise;
+}
+
+function processAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return Boolean(error && typeof error === "object" && "code" in error && error.code !== "ESRCH");
+ }
+}
+
+function removeStaleLock(path: string): boolean {
+ let contents: string;
+ try {
+ contents = readFileSync(path, "utf8");
+ const owner = Number(contents.trim().split(":", 1)[0]);
+ if (Number.isInteger(owner) && owner > 0) {
+ if (owner === process.pid && heldOAuthLockPaths.has(path)) return false;
+ if (owner === process.pid) {
+ if (Date.now() - statSync(path).mtimeMs <= 60_000) return false;
+ } else if (processAlive(owner)) return false;
+ } else if (Date.now() - statSync(path).mtimeMs <= 60_000) return false;
+ } catch {
+ return true;
+ }
+ const detached = `${path}.stale-${process.pid}-${randomBytes(6).toString("hex")}`;
+ try {
+ renameSync(path, detached);
+ } catch {
+ return true;
+ }
+ try {
+ if (readFileSync(detached, "utf8") !== contents) {
+ if (!existsSync(path)) renameSync(detached, path);
+ return false;
+ }
+ unlinkSync(detached);
+ return true;
+ } catch {
+ if (!existsSync(path)) {
+ try {
+ renameSync(detached, path);
+ } catch (error) {
+ swallow("codex: stale lock restore", error);
+ }
+ }
+ return true;
+ }
+}
+
+export async function acquireCodexOAuthAuthLock(
+ sourcePath: string,
+ signal?: AbortSignal,
+ timeoutMs = 120_000,
+ pollMs = 100,
+): Promise {
+ const path = lockPath(sourcePath);
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (signal?.aborted) throw new Error("Codex OAuth auth lock acquisition cancelled");
+ try {
+ const handle = await openFile(path, "wx", 0o600);
+ const owner = `${process.pid}:${randomBytes(8).toString("hex")}`;
+ try {
+ await handle.writeFile(owner);
+ heldOAuthLockPaths.add(path);
+ } catch (error) {
+ await handle.close().catch(() => undefined);
+ try {
+ unlinkSync(path);
+ } catch (cleanupError) {
+ swallow("codex: oauth lock creation cleanup", cleanupError);
+ }
+ throw error;
+ }
+ let released = false;
+ return {
+ path,
+ isHeld() {
+ if (released) return false;
+ try {
+ return readFileSync(path, "utf8") === owner;
+ } catch {
+ return false;
+ }
+ },
+ async release() {
+ if (released) return;
+ released = true;
+ await handle.close().catch(() => undefined);
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ if (readFileSync(path, "utf8") !== owner) {
+ heldOAuthLockPaths.delete(path);
+ return;
+ }
+ unlinkSync(path);
+ heldOAuthLockPaths.delete(path);
+ return;
+ } catch (error) {
+ if (attempt === 2) {
+ heldOAuthLockPaths.delete(path);
+ throw error;
+ } else await new Promise((resolveWait) => setTimeout(resolveWait, 10));
+ }
+ }
+ },
+ };
+ } catch (error) {
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
+ if (code !== "EEXIST") throw error;
+ removeStaleLock(path);
+ await new Promise((resolveWait) => {
+ const onAbort = () => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ resolveWait();
+ };
+ const timer = setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolveWait();
+ }, pollMs);
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+ }
+ }
+ throw new Error("timed out acquiring the Codex OAuth auth lock");
+}
+
diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts
index 46d79a12b..752673d8d 100644
--- a/src/harness/codex-harness.ts
+++ b/src/harness/codex-harness.ts
@@ -13,7 +13,9 @@ import type { ScopeId, SessionEntry } from "../types.ts";
import { swallow } from "../util/errors.ts";
import { countTokens } from "../util/tokens.ts";
import { parseSecurityScreenVerdict, SECURITY_SCREEN_SYSTEM_PROMPT } from "../security/security-posture.ts";
-import { CodexAppServer, CodexRpcError } from "./codex-app-server.ts";
+import { CodexAppServer, CodexRpcError, redactCodexDiagnostics } from "./codex-app-server.ts";
+import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "./codex-auth.ts";
+import { childCodexOAuthAuth, fileCodexAuthStore, type CodexAuthStore } from "./codex-auth-store.ts";
import { defineHarness, type Harness, type HarnessTurnInput, type HarnessTurnResult } from "./harness.ts";
import { coreToolOptions, createPiTools, type PiToolsOptions, type ToolContextRef } from "./pi-tools.ts";
import type { McpToolDescriptor } from "../mcp/mcp-tool-service.ts";
@@ -36,6 +38,8 @@ export interface CodexHarnessOptions {
backgroundJobTtlMs?: number;
backgroundJobTtlMaxMs?: number;
appServerStartTimeoutMs?: number;
+ /** Custodian of the ChatGPT-subscription Codex login (keychain-backed in production). */
+ authStore?: CodexAuthStore;
signals?: RunSignalStore;
tasks?: TaskStore;
}
@@ -80,7 +84,59 @@ type BridgedTool = {
type CodexItem = Record & { type: string };
type CodexTurn = { id: string; status: string; error?: { message?: string } | null; items?: CodexItem[] };
+const CODEX_TERMINAL_TURN_STATUSES = new Set(["completed", "failed", "interrupted", "cancelled", "canceled"]);
+
+function isCodexThreadStart(value: unknown): value is { thread: { id: string }; model?: string } {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const response = value as Record;
+ const thread = response.thread;
+ return Boolean(
+ thread &&
+ typeof thread === "object" &&
+ !Array.isArray(thread) &&
+ typeof (thread as Record).id === "string" &&
+ (!("model" in response) || typeof response.model === "string"),
+ );
+}
+
+function isCodexTurnStart(value: unknown): value is { turn: CodexTurn } {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const turn = (value as Record).turn;
+ if (!turn || typeof turn !== "object" || Array.isArray(turn)) return false;
+ const response = turn as Record;
+ return typeof response.id === "string" && typeof response.status === "string";
+}
+
+function isCodexTurn(value: unknown): value is CodexTurn {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const turn = value as Record;
+ if (typeof turn.id !== "string" || typeof turn.status !== "string") return false;
+ if (!CODEX_TERMINAL_TURN_STATUSES.has(turn.status)) return false;
+ if (
+ "items" in turn &&
+ (!Array.isArray(turn.items) ||
+ turn.items.some(
+ (item) =>
+ !item ||
+ typeof item !== "object" ||
+ Array.isArray(item) ||
+ typeof (item as Record).type !== "string" ||
+ !(item as Record).type,
+ ))
+ )
+ return false;
+ const error = turn.error;
+ return (
+ error === undefined ||
+ error === null ||
+ (typeof error === "object" &&
+ !Array.isArray(error) &&
+ (!("message" in error) || typeof (error as Record).message === "string"))
+ );
+}
+
type ActiveTurn = {
+ server: CodexAppServer;
threadId: string;
turn: HarnessTurnInput;
tools: Map;
@@ -102,7 +158,15 @@ type ActiveTurn = {
stopped: boolean;
};
-type Runtime = { server: CodexAppServer; jail: string };
+type Runtime = {
+ server: CodexAppServer;
+ jail: string;
+};
+type StartingRuntime = {
+ promise: Promise;
+ abort: AbortController;
+ waiters: number;
+};
const CODEX_START_TIMEOUT_MS = 30_000;
const CODEX_NON_RETRYABLE_PATTERN =
@@ -113,7 +177,8 @@ export function codexNonRetryable(message: string): boolean {
}
export function codexProviderFailure(message: string): Error {
- return codexNonRetryable(message) ? new NonRetryableTurnError(message) : new Error(message);
+ const safe = redactCodexDiagnostics(message);
+ return codexNonRetryable(safe) ? new NonRetryableTurnError(safe) : new Error(safe);
}
const CODEX_CHILD_TOOL_NAMES = new Set(["execute", "read", "write", "publish", "memory", "history", "background"]);
@@ -187,20 +252,41 @@ const CODEX_ENV_PASSTHROUGH = [
"CODEX_ACCESS_TOKEN",
] as const;
-export function codexChildEnv(source: NodeJS.ProcessEnv, jail: string): NodeJS.ProcessEnv {
+export function codexChildEnv(
+ source: NodeJS.ProcessEnv,
+ jail: string,
+ auth?: Record | null,
+): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
HOME: jail,
CODEX_HOME: join(jail, "codex-home"),
};
+ const authPath = codexAuthFileForEnv(source, true);
+ const fileAuth = () => (authPath ? readCodexOAuthAuthFile(authPath) : null);
+ const oauthAuth = auth !== undefined ? auth : fileAuth();
for (const name of CODEX_ENV_PASSTHROUGH) {
+ if (oauthAuth && (name === "OPENAI_API_KEY" || name === "OPENAI_BASE_URL" || name === "CODEX_ACCESS_TOKEN"))
+ continue;
if (source[name] !== undefined) env[name] = source[name];
}
return env;
}
-export function prepareCodexHome(source: NodeJS.ProcessEnv, jail: string): string {
+export function prepareCodexHome(
+ source: NodeJS.ProcessEnv,
+ jail: string,
+ auth?: Record | null,
+): string {
const target = join(jail, "codex-home");
mkdirSync(target, { recursive: true });
+ const authPath = codexAuthFileForEnv(source, true);
+ const fileAuth = () => (authPath ? readCodexOAuthAuthFile(authPath) : null);
+ const oauthAuth = auth !== undefined ? auth : fileAuth();
+ if (oauthAuth) {
+ // The child receives derived, ephemeral material only: no refresh token.
+ writeFileSync(join(target, "auth.json"), JSON.stringify(childCodexOAuthAuth(oauthAuth)), { mode: 0o600 });
+ return target;
+ }
if (source.OPENAI_API_KEY) {
writeFileSync(
join(target, "auth.json"),
@@ -356,9 +442,17 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
DEFAULT_CODEX_MODEL_ID,
].find((id): id is string => modelSupportedByHarness(id, "codex"))!;
const defaultTurnWallClockMs = opts.turnWallClockMs ?? CONFIG_DEFAULTS.turnWallClockSec * 1000;
+ const sourceEnv = opts.env ?? {};
+ const authPath = codexAuthFileForEnv(sourceEnv, true);
+ const authStore: CodexAuthStore | undefined =
+ opts.authStore ?? (authPath && readCodexOAuthAuthFile(authPath) ? fileCodexAuthStore(authPath) : undefined);
+ const oauthConfigured = Boolean(authStore);
+ const closeAbort = new AbortController();
let runtime: Runtime | null = null;
- let starting: Promise | null = null;
+ let starting: StartingRuntime | null = null;
let startingServer: CodexAppServer | null = null;
+ let setupUsers = 0;
+ let runtimeCleanupRequested = false;
const processCollabItem = async (state: ActiveTurn, item: CodexItem): Promise => {
if (item.type !== "collabAgentToolCall") return;
@@ -421,231 +515,411 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
}
};
- const ensureRuntime = async (): Promise => {
+ const ensureRuntime = async (
+ registerCancel?: (release: () => void) => void,
+ startupDeadline = 0,
+ ): Promise => {
if (runtime && runtime.server.process.exitCode === null) return runtime;
- if (starting) return await starting;
- starting = (async () => {
- const jail = mkdtempSync(join(tmpdir(), "qm-codex-"));
- const sourceEnv = opts.env ?? {};
- prepareCodexHome(sourceEnv, jail);
- const binaryPath = opts.binaryPath ?? resolve("node_modules/.bin/codex");
- const server = new CodexAppServer({
- binaryPath,
- cwd: jail,
- env: codexChildEnv(sourceEnv, jail),
- onNotification: async (method, params) => {
- const p = (params ?? {}) as Record;
- const threadId = typeof p.threadId === "string" ? p.threadId : "";
- const state = active.get(threadId);
- if (!state) return;
- if (method === "thread/tokenUsage/updated") {
- const totals = codexUsageTotals(p);
- if (totals) state.usageByThread.set(threadId, totals);
- const usage = codexTokenUsageUpdate(p, state.usageInputTotals.get(threadId));
- if (!usage) return;
- state.usageInputTotals.set(threadId, usage.totalInputTokens);
- state.modelCalls++;
- state.turn.recordModelCall({
- model: state.model,
- inputTokens: usage.inputTokens,
- entryCount: state.turn.history.length,
- });
- }
- if (method === "item/agentMessage/delta" && threadId === state.threadId && typeof p.delta === "string") {
- state.firstOutputAt ??= Date.now();
- state.turn.onDelta?.(p.delta);
- }
- if ((method === "item/started" || method === "item/completed") && p.item && typeof p.item === "object") {
- const item = p.item as CodexItem;
- if (method === "item/completed") {
- state.completedItems.push(item);
- if (state.turn.tape) {
- try {
- await state.turn.tape({
- kind: "message",
- harness: "codex",
- scopeLabel: state.turn.scopeLabel,
- payload: item,
- });
- } catch (error) {
- state.tapeWriteFailed = true;
- swallow("codex: tape append", error);
+ if (runtime) {
+ const stale = runtime;
+ runtime = null;
+ runtimeCleanupRequested = false;
+ const staleError = stale.server.error() ?? new Error("Codex app-server exited during a turn");
+ for (const [threadId, state] of active) {
+ if (state.server !== stale.server) continue;
+ state.reject(staleError);
+ active.delete(threadId);
+ }
+ await stale.server.close().catch(() => undefined);
+ rmSync(stale.jail, { recursive: true, force: true });
+ }
+ let startup = starting;
+ if (startup?.abort.signal.aborted) {
+ if (starting === startup) starting = null;
+ startup = null;
+ }
+ if (!startup) {
+ const startupAbort = new AbortController();
+ const promise = (async () => {
+ const jail = mkdtempSync(join(tmpdir(), "qm-codex-"));
+ const sourceAuth = authStore ? await authStore.load() : null;
+ let server!: CodexAppServer;
+ try {
+ if (oauthConfigured && !sourceAuth)
+ throw new Error(`Codex OAuth auth is unavailable (${authStore!.description})`);
+ prepareCodexHome(sourceEnv, jail, oauthConfigured ? sourceAuth : undefined);
+ if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled");
+ const binaryPath = opts.binaryPath ?? resolve("node_modules/.bin/codex");
+ server = new CodexAppServer({
+ binaryPath,
+ cwd: jail,
+ env: codexChildEnv(sourceEnv, jail, oauthConfigured ? sourceAuth : undefined),
+ onNotification: async (method, params) => {
+ const p = (params ?? {}) as Record;
+ const threadId = typeof p.threadId === "string" ? p.threadId : "";
+ const state = active.get(threadId);
+ if (!state || state.server !== server) return;
+ if (method === "thread/tokenUsage/updated") {
+ const totals = codexUsageTotals(p);
+ if (totals) state.usageByThread.set(threadId, totals);
+ const usage = codexTokenUsageUpdate(p, state.usageInputTotals.get(threadId));
+ if (!usage) return;
+ state.usageInputTotals.set(threadId, usage.totalInputTokens);
+ state.modelCalls++;
+ state.turn.recordModelCall({
+ model: state.model,
+ inputTokens: usage.inputTokens,
+ entryCount: state.turn.history.length,
+ });
+ }
+ if (method === "item/agentMessage/delta" && threadId === state.threadId && typeof p.delta === "string") {
+ state.firstOutputAt ??= Date.now();
+ state.turn.onDelta?.(p.delta);
+ }
+ if ((method === "item/started" || method === "item/completed") && p.item && typeof p.item === "object") {
+ const item = p.item as CodexItem;
+ if (method === "item/completed") {
+ state.completedItems.push(item);
+ if (state.turn.tape) {
+ try {
+ await state.turn.tape({
+ kind: "message",
+ harness: "codex",
+ scopeLabel: state.turn.scopeLabel,
+ payload: item,
+ });
+ } catch (error) {
+ state.tapeWriteFailed = true;
+ swallow("codex: tape append", error);
+ }
+ }
}
+ await processCollabItem(state, item);
}
- }
- await processCollabItem(state, item);
- }
- if (method === "turn/completed" && threadId === state.threadId) {
- const completed = p.turn as CodexTurn | undefined;
- if (completed)
- state.resolve(completed.items?.length ? completed : { ...completed, items: state.completedItems });
- }
- },
- onRequest: async (method, params) => {
- if (method !== "item/tool/call") throw new Error(`unsupported Codex request ${method}`);
- const p = (params ?? {}) as Record;
- const threadId = String(p.threadId ?? "");
- const state = active.get(threadId);
- if (!state) throw new Error("inactive Codex thread");
- const name = String(p.tool ?? "");
- const callId = String(p.callId ?? "");
- if (threadId !== state.threadId && !codexChildToolAllowed(name))
- throw new Error(`Codex child requested unavailable tool ${name}`);
- const tool = state.tools.get(name);
- if (!tool) throw new Error(`Codex requested unavailable tool ${name}`);
- state.responseItems.push({
- type: "function_call",
- call_id: callId,
- name,
- arguments: JSON.stringify(p.arguments ?? {}),
- });
- try {
- const result = await tool.execute(callId, p.arguments ?? {});
- const output = toolText(result);
- state.responseItems.push({ type: "function_call_output", call_id: callId, output });
- if (result.terminate || state.turn.cancel?.aborted)
- setImmediate(() => {
- const requestingTurnId = String(p.turnId ?? "");
- if (threadId !== state.threadId && requestingTurnId) {
- void server.request("turn/interrupt", { threadId, turnId: requestingTurnId }).catch(() => undefined);
+ if (method === "turn/completed" && threadId === state.threadId) {
+ const completed = p.turn as CodexTurn | undefined;
+ if (!isCodexTurn(completed)) {
+ state.reject(new CodexRpcError("Codex app-server sent an invalid turn/completed payload"));
+ return;
}
- void state.interrupt?.();
+ state.resolve(completed.items?.length ? completed : { ...completed, items: state.completedItems });
+ }
+ },
+ onRequest: async (method, params) => {
+ if (method !== "item/tool/call") throw new Error(`unsupported Codex request ${method}`);
+ const p = (params ?? {}) as Record;
+ const threadId = String(p.threadId ?? "");
+ const state = active.get(threadId);
+ if (!state || state.server !== server) throw new Error("inactive Codex thread");
+ const name = String(p.tool ?? "");
+ const callId = String(p.callId ?? "");
+ if (threadId !== state.threadId && !codexChildToolAllowed(name))
+ throw new Error(`Codex child requested unavailable tool ${name}`);
+ const tool = state.tools.get(name);
+ if (!tool) throw new Error(`Codex requested unavailable tool ${name}`);
+ state.responseItems.push({
+ type: "function_call",
+ call_id: callId,
+ name,
+ arguments: JSON.stringify(p.arguments ?? {}),
});
- return { contentItems: [{ type: "inputText", text: output }], success: true };
- } catch (error) {
- const output = error instanceof Error ? error.message : String(error);
- state.responseItems.push({ type: "function_call_output", call_id: callId, output });
- return { contentItems: [{ type: "inputText", text: output }], success: false };
- }
+ try {
+ const result = await tool.execute(callId, p.arguments ?? {});
+ const output = toolText(result);
+ state.responseItems.push({ type: "function_call_output", call_id: callId, output });
+ if (result.terminate || state.turn.cancel?.aborted)
+ setImmediate(() => {
+ const requestingTurnId = String(p.turnId ?? "");
+ if (threadId !== state.threadId && requestingTurnId) {
+ void server
+ .request("turn/interrupt", { threadId, turnId: requestingTurnId })
+ .catch(() => undefined);
+ }
+ void state.interrupt?.();
+ });
+ return { contentItems: [{ type: "inputText", text: output }], success: true };
+ } catch (error) {
+ const output = error instanceof Error ? error.message : String(error);
+ state.responseItems.push({ type: "function_call_output", call_id: callId, output });
+ return { contentItems: [{ type: "inputText", text: output }], success: false };
+ }
+ },
+ });
+ startingServer = server;
+ if (startupAbort.signal.aborted) throw new Error("Codex app-server startup cancelled");
+ } catch (error) {
+ await server?.close().catch(() => undefined);
+ rmSync(jail, { recursive: true, force: true });
+ throw error;
+ }
+ let startTimer: NodeJS.Timeout | undefined;
+ try {
+ const initializationTimeout = startupDeadline
+ ? Math.min(
+ opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS,
+ Math.max(1, startupDeadline - Date.now()),
+ )
+ : (opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS);
+ await Promise.race([
+ server.initialize(),
+ new Promise((_, reject) => {
+ startTimer = setTimeout(
+ () => reject(new Error("Codex app-server initialization timed out")),
+ initializationTimeout,
+ );
+ }),
+ ]);
+ } catch (error) {
+ await server.close().catch(() => undefined);
+ rmSync(jail, { recursive: true, force: true });
+ throw error;
+ } finally {
+ if (startTimer) clearTimeout(startTimer);
+ if (startingServer === server) startingServer = null;
+ }
+ runtime = { server, jail };
+ runtimeCleanupRequested = false;
+ server.process.once("close", () => {
+ void (async () => {
+ const currentRuntime = runtime?.server === server;
+ const closeError = server.error() ?? new Error("Codex app-server exited during a turn");
+ for (const [threadId, state] of active) {
+ if (state.server !== server) continue;
+ state.reject(closeError);
+ active.delete(threadId);
+ }
+ if (!currentRuntime) {
+ rmSync(jail, { recursive: true, force: true });
+ return;
+ }
+ runtime = null;
+ runtimeCleanupRequested = false;
+ if (!closeAbort.signal.aborted) rmSync(jail, { recursive: true, force: true });
+ })().catch((error) => {
+ swallow("codex: provider close cleanup", error);
+ try {
+ rmSync(jail, { recursive: true, force: true });
+ } catch (cleanupError) {
+ swallow("codex: provider close jail cleanup", cleanupError);
+ }
+ });
+ });
+ return runtime;
+ })();
+ startup = { promise, abort: startupAbort, waiters: 0 };
+ starting = startup;
+ const current = startup;
+ void promise.then(
+ () => {
+ if (starting === current) starting = null;
},
- });
- startingServer = server;
- let startTimer: NodeJS.Timeout | undefined;
- try {
- await Promise.race([
- server.initialize(),
- new Promise((_, reject) => {
- startTimer = setTimeout(
- () => reject(new Error("Codex app-server initialization timed out")),
- opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS,
- );
- }),
- ]);
- } catch (error) {
- await server.close().catch(() => undefined);
- rmSync(jail, { recursive: true, force: true });
- throw error;
- } finally {
- if (startTimer) clearTimeout(startTimer);
- if (startingServer === server) startingServer = null;
+ () => {
+ if (starting === current) starting = null;
+ },
+ );
+ }
+ const current = startup;
+ current.waiters += 1;
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ current.waiters -= 1;
+ if (current.waiters === 0 && starting === current) {
+ current.abort.abort();
+ void startingServer?.close().catch(() => undefined);
}
- runtime = { server, jail };
- server.process.once("close", () => {
- if (runtime?.server !== server) return;
- for (const state of active.values())
- state.reject(server.error() ?? new Error("Codex app-server exited during a turn"));
- active.clear();
- runtime = null;
- rmSync(jail, { recursive: true, force: true });
- });
- return runtime;
- })();
+ };
+ registerCancel?.(release);
try {
- return await starting;
+ return await current.promise;
} finally {
- starting = null;
+ release();
}
};
+ const closeIdleRuntime = async (): Promise => {
+ const current = runtime;
+ if (!current) return;
+ if (active.size || setupUsers) {
+ runtimeCleanupRequested = true;
+ return;
+ }
+ runtimeCleanupRequested = false;
+ if (runtime === current) runtime = null;
+ await current.server.close().catch(() => undefined);
+ rmSync(current.jail, { recursive: true, force: true });
+ };
+
const runPrompt = async (turn: HarnessTurnInput, toolsEnabled = true): Promise => {
if (turn.cancel?.aborted) return { reply: "", stopped: true };
+ setupUsers += 1;
+ let setupUserReleased = false;
+ const releaseSetupUser = () => {
+ if (setupUserReleased) return;
+ setupUserReleased = true;
+ setupUsers -= 1;
+ };
const wallMs = turn.turnWallClockMs ?? defaultTurnWallClockMs;
const deadline = wallMs > 0 ? Date.now() + wallMs : 0;
+ const runtimeRecoveryDeadline = Date.now() + Math.max(wallMs, CODEX_START_TIMEOUT_MS);
const setupCancelled = new Error("Codex setup cancelled");
const setupTimedOut = new NonRetryableTurnError(`Codex turn exceeded ${Math.round(wallMs / 1000)}s wall clock`);
let rejectSetup!: (error: Error) => void;
let setupSettled = false;
+ let releaseStartupWaiter: () => void = () => {};
+ const authAcquireAbort = new AbortController();
const setupStop = new Promise((_, reject) => {
rejectSetup = reject;
});
const stopSetup = (error: Error) => {
if (setupSettled) return;
setupSettled = true;
+ releaseStartupWaiter();
+ authAcquireAbort.abort();
rejectSetup(error);
};
const onSetupCancel = () => stopSetup(setupCancelled);
turn.cancel?.addEventListener("abort", onSetupCancel, { once: true });
const setupTimer = wallMs > 0 ? setTimeout(() => stopSetup(setupTimedOut), wallMs) : undefined;
+ const finishSetup = () => {
+ setupSettled = true;
+ if (setupTimer) clearTimeout(setupTimer);
+ turn.cancel?.removeEventListener("abort", onSetupCancel);
+ releaseSetupUser();
+ };
const awaitSetup = (operation: Promise): Promise => Promise.race([operation, setupStop]);
let rt: Runtime;
try {
- rt = await awaitSetup(ensureRuntime());
+ rt = await awaitSetup(
+ ensureRuntime((release) => {
+ releaseStartupWaiter = release;
+ }),
+ );
} catch (error) {
- setupSettled = true;
- if (setupTimer) clearTimeout(setupTimer);
- turn.cancel?.removeEventListener("abort", onSetupCancel);
+ finishSetup();
+ await closeIdleRuntime();
if (error === setupCancelled) return { reply: "", stopped: true };
throw error;
}
- const ref = codexToolContext(turn);
- const toolAbort = new AbortController();
- ref.abortSignal = toolAbort.signal;
- const tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : [];
- const dynamicTools = tools.map((tool) => ({
- type: "function",
- name: tool.name,
- description: tool.description,
- inputSchema: tool.parameters,
- }));
- const model = modelSupportedByHarness(turn.model, "codex") ? turn.model! : resolveModelId(turn.scopeLabel);
- const threadStartRequest = {
- ...(model ? { model } : {}),
- cwd: rt.jail,
- approvalPolicy: "never",
- sandbox: "read-only",
- ephemeral: true,
- baseInstructions: turn.systemPrompt,
- developerInstructions:
- "Use the supplied dynamic tools for all workspace, execution, memory, history, and surface operations. The built-in working directory is an empty read-only control jail, not the user's workspace.",
- dynamicTools,
- experimentalRawEvents: true,
- environments: [],
- config: {
- web_search: "disabled",
- ...(codexReasoningEffort(turn.thinkingLevel)
- ? { model_reasoning_effort: codexReasoningEffort(turn.thinkingLevel) }
- : {}),
- features: {
- shell_tool: false,
- unified_exec: false,
- shell_snapshot: false,
- apps: false,
- plugins: false,
- browser_use: false,
- browser_use_external: false,
- computer_use: false,
- image_generation: false,
- in_app_browser: false,
- multi_agent: !turn.readOnly,
- request_permissions_tool: false,
- tool_suggest: false,
- },
- },
+ const failSetup = async (error: unknown): Promise => {
+ finishSetup();
+ await closeIdleRuntime();
+ if (error === setupCancelled) return { reply: "", stopped: true };
+ throw error;
};
+ try {
+ if (oauthConfigured) {
+ // Re-materialize fresh, centrally refreshed tokens for this turn. The
+ // store owns the refresh token; the child jail only ever holds
+ // short-lived derived material.
+ const sourceAuth = await awaitSetup(authStore!.load());
+ if (!sourceAuth) {
+ rmSync(join(rt.jail, "codex-home", "auth.json"), { force: true });
+ throw new NonRetryableTurnError(`Codex OAuth auth is unavailable (${authStore!.description})`);
+ }
+ if (runtime !== rt || rt.server.process.exitCode !== null) {
+ if (Date.now() >= runtimeRecoveryDeadline)
+ throw new NonRetryableTurnError("Codex OAuth runtime recovery timed out");
+ rt = await awaitSetup(
+ ensureRuntime((release) => {
+ releaseStartupWaiter = release;
+ }, runtimeRecoveryDeadline),
+ );
+ } else {
+ prepareCodexHome(sourceEnv, rt.jail, sourceAuth);
+ }
+ }
+ } catch (error) {
+ return failSetup(error);
+ }
+ let ref!: ToolContextRef;
+ let toolAbort!: AbortController;
+ let tools!: BridgedTool[];
+ let dynamicTools!: Array>;
+ let model: string | undefined;
+ let threadStartRequest!: Record;
+ try {
+ ref = codexToolContext(turn);
+ toolAbort = new AbortController();
+ ref.abortSignal = toolAbort.signal;
+ tools = toolsEnabled ? asTools(ref, toolOptions(opts, turn)) : [];
+ dynamicTools = tools.map((tool) => ({
+ type: "function",
+ name: tool.name,
+ description: tool.description,
+ inputSchema: tool.parameters,
+ }));
+ model = modelSupportedByHarness(turn.model, "codex") ? turn.model! : resolveModelId(turn.scopeLabel);
+ threadStartRequest = {
+ ...(model ? { model } : {}),
+ cwd: rt.jail,
+ approvalPolicy: "never",
+ sandbox: "read-only",
+ ephemeral: true,
+ baseInstructions: turn.systemPrompt,
+ developerInstructions:
+ "Use the supplied dynamic tools for all workspace, execution, memory, history, and surface operations. The built-in working directory is an empty read-only control jail, not the user's workspace.",
+ dynamicTools,
+ experimentalRawEvents: true,
+ environments: [],
+ config: {
+ web_search: "disabled",
+ ...(codexReasoningEffort(turn.thinkingLevel)
+ ? { model_reasoning_effort: codexReasoningEffort(turn.thinkingLevel) }
+ : {}),
+ features: {
+ shell_tool: false,
+ unified_exec: false,
+ shell_snapshot: false,
+ apps: false,
+ plugins: false,
+ browser_use: false,
+ browser_use_external: false,
+ computer_use: false,
+ image_generation: false,
+ in_app_browser: false,
+ multi_agent: !turn.readOnly,
+ request_permissions_tool: false,
+ tool_suggest: false,
+ },
+ },
+ };
+ } catch (error) {
+ return failSetup(error);
+ }
let started: { thread: { id: string }; model?: string };
try {
- started = await awaitSetup(rt.server.request("thread/start", threadStartRequest));
+ const requestTimeoutMs = deadline ? Math.max(1, deadline - Date.now()) : CODEX_START_TIMEOUT_MS;
+ let requestTimer: NodeJS.Timeout | undefined;
+ const requestAbort = new AbortController();
+ started = await awaitSetup(
+ Promise.race([
+ rt.server.request(
+ "thread/start",
+ threadStartRequest,
+ isCodexThreadStart,
+ AbortSignal.any([authAcquireAbort.signal, closeAbort.signal, requestAbort.signal]),
+ ),
+ new Promise((_, reject) => {
+ requestTimer = setTimeout(() => {
+ requestAbort.abort();
+ reject(new NonRetryableTurnError("Codex thread/start request timed out"));
+ }, requestTimeoutMs);
+ }),
+ ]).finally(() => {
+ if (requestTimer) clearTimeout(requestTimer);
+ }),
+ );
} catch (error) {
- setupSettled = true;
- if (setupTimer) clearTimeout(setupTimer);
- turn.cancel?.removeEventListener("abort", onSetupCancel);
- if (error === setupCancelled) return { reply: "", stopped: true };
- throw error;
+ return failSetup(error);
}
- const threadId = started.thread.id;
- const replay = replayItems(reconstructMessagesFromHistory(turn.history));
- let userEntry: SessionEntry;
+ let threadId!: string;
+ let replay!: ReturnType;
+ let userEntry!: SessionEntry;
try {
+ threadId = started.thread.id;
+ replay = replayItems(reconstructMessagesFromHistory(turn.history));
if (replay.length) await awaitSetup(rt.server.request("thread/inject_items", { threadId, items: replay }));
userEntry = await awaitSetup(
turn.emit({
@@ -659,51 +933,56 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
}),
);
} catch (error) {
- setupSettled = true;
- if (setupTimer) clearTimeout(setupTimer);
- turn.cancel?.removeEventListener("abort", onSetupCancel);
- if (error === setupCancelled) return { reply: "", stopped: true };
- throw error;
+ return failSetup(error);
}
- setupSettled = true;
- if (setupTimer) clearTimeout(setupTimer);
- turn.cancel?.removeEventListener("abort", onSetupCancel);
let resolveCompleted!: (value: CodexTurn) => void;
let rejectCompleted!: (error: Error) => void;
- const completed = new Promise((resolveTurn, rejectTurn) => {
- resolveCompleted = resolveTurn;
- rejectCompleted = rejectTurn;
- });
- const inputText = codexTurnInputText(turn);
- const input = [
- userInput(inputText),
- ...(turn.images ?? []).map((image) => ({
- type: "image",
- url: `data:${image.mimeType};base64,${image.dataBase64}`,
- })),
- ];
- const selectedModel = model ?? started.model ?? "codex-default";
- const state: ActiveTurn = {
- threadId,
- turn,
- tools: new Map(tools.map((tool) => [tool.name, tool])),
- resolve: resolveCompleted,
- reject: rejectCompleted,
- responseItems: [],
- completedItems: [],
- taskIds: new Map(),
- taskStatuses: new Map(),
- taskResults: new Set(),
- model: selectedModel,
- modelCalls: 0,
- usageInputTotals: new Map(),
- usageByThread: new Map(),
- firstOutputAt: null,
- fallbackInputTokens: countTokens(JSON.stringify({ replay, input })),
- tapeWriteFailed: false,
- stopped: false,
- };
+ let completed!: Promise;
+ let inputText!: string;
+ let input!: Array>;
+ let selectedModel!: string;
+ let state!: ActiveTurn;
+ try {
+ completed = new Promise((resolveTurn, rejectTurn) => {
+ resolveCompleted = resolveTurn;
+ rejectCompleted = rejectTurn;
+ });
+ void completed.catch(() => undefined);
+ inputText = codexTurnInputText(turn);
+ input = [
+ userInput(inputText),
+ ...(turn.images ?? []).map((image) => ({
+ type: "image",
+ url: `data:${image.mimeType};base64,${image.dataBase64}`,
+ })),
+ ];
+ selectedModel = model ?? started.model ?? "codex-default";
+ state = {
+ server: rt.server,
+ threadId,
+ turn,
+ tools: new Map(tools.map((tool) => [tool.name, tool])),
+ resolve: resolveCompleted,
+ reject: rejectCompleted,
+ responseItems: [],
+ completedItems: [],
+ taskIds: new Map(),
+ taskStatuses: new Map(),
+ taskResults: new Set(),
+ model: selectedModel,
+ modelCalls: 0,
+ usageInputTotals: new Map(),
+ usageByThread: new Map(),
+ firstOutputAt: null,
+ fallbackInputTokens: countTokens(JSON.stringify({ replay, input })),
+ tapeWriteFailed: false,
+ stopped: false,
+ };
+ } catch (error) {
+ return failSetup(error);
+ }
active.set(threadId, state);
+ finishSetup();
const promptEnvelope = {
threadStart: {
...threadStartRequest,
@@ -713,20 +992,35 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
const startedAt = Date.now();
const recordRequest = async (): Promise => {
if (!turn.recordLlmRequest) return;
+ const recordAbort = new AbortController();
+ let recordTimer: NodeJS.Timeout | undefined;
try {
- await turn.recordLlmRequest({
- turnSeq: userEntry.seq,
- step: 0,
- model: selectedModel,
- promptEnvelope,
- truncated: Boolean(turn.images?.length),
- transport: { modelId: selectedModel },
- ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null,
- durationMs: Date.now() - startedAt,
- usage: sumUsage(state.usageByThread),
- });
+ await Promise.race([
+ turn.recordLlmRequest(
+ {
+ turnSeq: userEntry.seq,
+ step: 0,
+ model: selectedModel,
+ promptEnvelope,
+ truncated: Boolean(turn.images?.length),
+ transport: { modelId: selectedModel },
+ ttftMs: state.firstOutputAt ? state.firstOutputAt - startedAt : null,
+ durationMs: Date.now() - startedAt,
+ usage: sumUsage(state.usageByThread),
+ },
+ recordAbort.signal,
+ ),
+ new Promise((_, reject) => {
+ recordTimer = setTimeout(() => {
+ recordAbort.abort();
+ reject(new Error("Codex llm request recording timed out"));
+ }, 5_000);
+ }),
+ ]);
} catch (error) {
swallow("codex: llm request record", error);
+ } finally {
+ if (recordTimer) clearTimeout(recordTimer);
}
};
if (turn.tape) {
@@ -766,7 +1060,8 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
};
state.interrupt = () => interrupt(false);
const onCancel = () => {
- void interrupt(false);
+ runtimeCleanupRequested = true;
+ void interrupt(true);
};
if (turn.cancel) {
if (turn.cancel.aborted) onCancel();
@@ -792,10 +1087,40 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
)
: null;
let timer: NodeJS.Timeout | undefined;
+ const turnStartAbort = new AbortController();
+ let turnStartTimedOut = false;
+ const cleanupErrors: unknown[] = [];
+ let turnResult: HarnessTurnResult | undefined;
try {
- const response = await rt.server
- .request<{ turn: CodexTurn }>("turn/start", { threadId, input, ...(model ? { model } : {}) })
+ const turnStartSignals = [closeAbort.signal, turnStartAbort.signal];
+ if (turn.cancel) turnStartSignals.push(turn.cancel);
+ const turnStartTimeoutMs = deadline ? Math.max(1, deadline - Date.now()) : CODEX_START_TIMEOUT_MS;
+ let turnStartTimer: NodeJS.Timeout | undefined;
+ const response = await Promise.race([
+ rt.server.request<{ turn: CodexTurn }>(
+ "turn/start",
+ { threadId, input, ...(model ? { model } : {}) },
+ isCodexTurnStart,
+ AbortSignal.any(turnStartSignals),
+ ),
+ new Promise((_, reject) => {
+ turnStartTimer = setTimeout(() => {
+ turnStartTimedOut = true;
+ runtimeCleanupRequested = true;
+ turnStartAbort.abort();
+ reject(new NonRetryableTurnError("Codex turn/start request timed out"));
+ }, turnStartTimeoutMs);
+ }),
+ ])
+ .finally(() => {
+ if (turnStartTimer) clearTimeout(turnStartTimer);
+ })
.catch((error: unknown) => {
+ if (turnStartTimedOut) {
+ const timeoutError = new NonRetryableTurnError("Codex turn/start request timed out");
+ timeoutError.cause = error;
+ throw timeoutError;
+ }
throw error instanceof CodexRpcError ? codexProviderFailure(error.message) : error;
});
turnId = response.turn.id;
@@ -807,6 +1132,7 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
completed,
new Promise((_, reject) => {
timer = setTimeout(() => {
+ runtimeCleanupRequested = true;
void interrupt(false);
reject(setupTimedOut);
}, remainingWallMs);
@@ -822,39 +1148,63 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
});
}
if (result.status === "failed") throw codexProviderFailure(result.error?.message ?? "Codex turn failed");
- const terminal = ref.silentRequested || ref.pausedOnApproval;
- const reply = terminal ? "" : textFromTurn(result);
- for (const thinking of reasoningFromTurn(result))
- await turn.emit({ type: "thinking", payload: { thinking }, scopeLabel: turn.scopeLabel });
- if (reply && !terminal)
- await turn.emit({
- type: "assistant",
- payload: { text: reply, stopped: state.stopped || undefined },
- scopeLabel: turn.scopeLabel,
- });
- return {
- reply,
- ...(state.stopped ? { stopped: true as const } : {}),
- ...(ref.silentRequested ? { silent: true } : {}),
- ...(ref.pendingApprovals?.length ? { pendingApprovals: ref.pendingApprovals } : {}),
- ...(ref.pausedOnApproval ? { pausedOnApproval: true } : {}),
- modelCalls: state.modelCalls,
- ...(state.tapeWriteFailed ? { tapeWriteFailed: true } : {}),
- };
+ if (turn.cancel?.aborted) {
+ runtimeCleanupRequested = true;
+ turnResult = { reply: "", stopped: true };
+ } else {
+ const terminal = ref.silentRequested || ref.pausedOnApproval;
+ const reply = terminal ? "" : textFromTurn(result);
+ for (const thinking of reasoningFromTurn(result))
+ await turn.emit({ type: "thinking", payload: { thinking }, scopeLabel: turn.scopeLabel });
+ if (reply && !terminal)
+ await turn.emit({
+ type: "assistant",
+ payload: { text: reply, stopped: state.stopped || undefined },
+ scopeLabel: turn.scopeLabel,
+ });
+ turnResult = {
+ reply,
+ ...(state.stopped ? { stopped: true as const } : {}),
+ ...(ref.silentRequested ? { silent: true } : {}),
+ ...(ref.pendingApprovals?.length ? { pendingApprovals: ref.pendingApprovals } : {}),
+ ...(ref.pausedOnApproval ? { pausedOnApproval: true } : {}),
+ modelCalls: state.modelCalls,
+ ...(state.tapeWriteFailed ? { tapeWriteFailed: true } : {}),
+ };
+ }
+ } catch (error) {
+ if (turn.cancel?.aborted) {
+ runtimeCleanupRequested = true;
+ turnResult = { reply: "", stopped: true };
+ } else {
+ throw error;
+ }
} finally {
if (timer) clearTimeout(timer);
- await stopSignals?.();
- await recordRequest();
+ try {
+ await stopSignals?.();
+ } catch (error) {
+ cleanupErrors.push(error);
+ }
turn.cancel?.removeEventListener("abort", onCancel);
for (const [taskId, status] of state.taskStatuses) {
if (status === "pending" || status === "in_progress") {
- await transitionTask(opts.tasks, taskId, status, "failed", turn.runId ?? turn.session.id);
+ try {
+ await transitionTask(opts.tasks, taskId, status, "failed", turn.runId ?? turn.session.id);
+ } catch (error) {
+ cleanupErrors.push(error);
+ }
}
}
for (const [activeThreadId, activeState] of active) {
if (activeState === state) active.delete(activeThreadId);
}
+ if (runtimeCleanupRequested) await closeIdleRuntime();
+ await recordRequest();
}
+ if (cleanupErrors.length) throw cleanupErrors[0];
+ if (!turnResult) throw new Error("Codex turn did not produce a result");
+ return turnResult;
};
const single = async (
@@ -908,8 +1258,9 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness {
{
runTurn: runPrompt,
close: async () => {
+ closeAbort.abort();
await startingServer?.close().catch(() => undefined);
- await starting?.catch(() => undefined);
+ await starting?.promise.catch(() => undefined);
const current = runtime;
if (current) {
for (const state of active.values()) state.reject(new Error("Codex harness closed during a turn"));
diff --git a/src/harness/harness.ts b/src/harness/harness.ts
index 77940e80a..19f211c89 100644
--- a/src/harness/harness.ts
+++ b/src/harness/harness.ts
@@ -43,7 +43,7 @@ interface HarnessSecurityScreenInput {
payload: string;
signal: AbortSignal;
recordModelCall(rec: { model: string; inputTokens: number; entryCount: number }): void;
- recordLlmRequest?(rec: HarnessLlmRequestRecord): void | Promise;
+ recordLlmRequest?(rec: HarnessLlmRequestRecord, signal?: AbortSignal): void | Promise;
}
export interface HarnessTurnInput {
@@ -86,7 +86,7 @@ export interface HarnessTurnInput {
scopeLabel: ScopeId;
orgScopeId: ScopeId;
recordModelCall(rec: { model: string; inputTokens: number; entryCount: number }): void;
- recordLlmRequest?(rec: HarnessLlmRequestRecord): void | Promise;
+ recordLlmRequest?(rec: HarnessLlmRequestRecord, signal?: AbortSignal): void | Promise;
onProgress?(p: { toolCalls: number; tokens?: number }): void;
onGapWork?(sink: (work: GapWork) => void): void;
onDelta?(chunk: string): void;
diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts
index b23d86c01..40ee057e0 100644
--- a/src/model/pi-models.ts
+++ b/src/model/pi-models.ts
@@ -234,6 +234,11 @@ export interface ModelProviderAvailability {
anthropic: boolean;
openai: boolean;
openrouter: boolean;
+ codexOAuth?: boolean;
+}
+
+function providerFlags(value: ModelProviderAvailability): ModelProviderAvailability {
+ return { anthropic: value.anthropic, openai: value.openai, openrouter: value.openrouter };
}
export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean {
@@ -257,9 +262,10 @@ export function modelProviderAvailabilityFor(
configKeys: ModelProviderAvailability,
managedKeys: ModelProviderAvailability = configKeys,
): ModelProviderAvailability {
- if (harness === "pi") return managedKeys;
- if (harness === "opencode") return { ...configKeys, openrouter: false };
- if (harness === "codex") return configKeys;
+ if (harness === "pi") return providerFlags(managedKeys);
+ if (harness === "opencode") return { ...providerFlags(configKeys), openrouter: false };
+ if (harness === "codex")
+ return { ...providerFlags(configKeys), openai: configKeys.openai || Boolean(configKeys.codexOAuth) };
return ALL_PROVIDERS_AVAILABLE;
}
diff --git a/src/persistence/pg-pool.ts b/src/persistence/pg-pool.ts
index 8c5ef21b1..5ad4f3d7e 100644
--- a/src/persistence/pg-pool.ts
+++ b/src/persistence/pg-pool.ts
@@ -7,10 +7,14 @@ export type { Pool, PoolClient };
export type Rows = Record[];
+export interface PgQueryOptions {
+ signal?: AbortSignal;
+}
+
export interface PgPool {
pool(): Promise;
- q(text: string, params?: unknown[]): Promise;
- query(text: string, params?: unknown[]): Promise<{ rows: Rows; rowCount: number }>;
+ q(text: string, params?: unknown[], options?: PgQueryOptions): Promise;
+ query(text: string, params?: unknown[], options?: PgQueryOptions): Promise<{ rows: Rows; rowCount: number }>;
schema?(schemaSql: string): Promise;
close(): Promise;
}
@@ -127,12 +131,59 @@ export function createPgPool(connectionString: string, statements: string[]): Pg
}
return poolP;
}
- async function query(text: string, params: unknown[] = []): Promise<{ rows: Rows; rowCount: number }> {
- const res = await (await pool()).query(text, params);
- return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 };
+ async function query(
+ text: string,
+ params: unknown[] = [],
+ options: PgQueryOptions = {},
+ ): Promise<{ rows: Rows; rowCount: number }> {
+ const p = await pool();
+ if (!options.signal) {
+ const res = await p.query(text, params);
+ return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 };
+ }
+ if (options.signal.aborted) throw new DOMException("Postgres query cancelled", "AbortError");
+ const connectPromise = p.connect();
+ let connectAbort: (() => void) | undefined;
+ const connectAbortPromise = new Promise((_, reject) => {
+ connectAbort = () => reject(new DOMException("Postgres query cancelled", "AbortError"));
+ options.signal!.addEventListener("abort", connectAbort, { once: true });
+ });
+ let client: PoolClient;
+ try {
+ client = await Promise.race([connectPromise, connectAbortPromise]);
+ } catch (error) {
+ void connectPromise
+ .then(
+ (lateClient) => lateClient.release(error instanceof Error ? error : new Error(String(error))),
+ () => undefined,
+ )
+ .catch(() => undefined);
+ throw error;
+ } finally {
+ if (connectAbort) options.signal.removeEventListener("abort", connectAbort);
+ }
+ let queryError: Error | undefined;
+ let released = false;
+ const cancel = () => {
+ if (released) return;
+ released = true;
+ client.release(new Error("Postgres query cancelled"));
+ };
+ options.signal.addEventListener("abort", cancel, { once: true });
+ try {
+ if (released) throw new Error("Postgres query cancelled");
+ const res = await client.query({ text, values: params });
+ return { rows: res.rows as Rows, rowCount: res.rowCount ?? 0 };
+ } catch (error) {
+ queryError = error instanceof Error ? error : new Error(String(error));
+ throw error;
+ } finally {
+ options.signal?.removeEventListener("abort", cancel);
+ if (!released) client.release(queryError);
+ }
}
- async function q(text: string, params: unknown[] = []): Promise {
- return (await query(text, params)).rows;
+ async function q(text: string, params: unknown[] = [], options?: PgQueryOptions): Promise {
+ return (await query(text, params, options)).rows;
}
async function close(): Promise {
if (poolP) await (await poolP).end();
diff --git a/src/sessions/postgres-session-store.ts b/src/sessions/postgres-session-store.ts
index 7c1abafbb..1c0cdc2bb 100644
--- a/src/sessions/postgres-session-store.ts
+++ b/src/sessions/postgres-session-store.ts
@@ -544,14 +544,14 @@ export function createPostgresSessionStore(connectionString: string, opts: Store
return rows.map(rowToEntry);
},
- async recordLlmRequest(sessionId, rec: NewLlmRequest): Promise {
+ async recordLlmRequest(sessionId, rec: NewLlmRequest, signal?: AbortSignal): Promise {
const envelope = promptEnvelopeBody(rec.promptEnvelope);
if (envelope) {
- await q("INSERT INTO llm_prompt_envelopes(hash, body, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING", [
- envelope.hash,
- envelope.body,
- now(),
- ]);
+ await q(
+ "INSERT INTO llm_prompt_envelopes(hash, body, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING",
+ [envelope.hash, envelope.body, now()],
+ { signal },
+ );
}
const full: LlmRequestRecord = {
id: randomUUID(),
@@ -593,6 +593,7 @@ export function createPostgresSessionStore(connectionString: string, opts: Store
full.transport ? JSON.stringify(full.transport) : null,
full.gapPhases ? JSON.stringify(full.gapPhases) : null,
],
+ { signal },
);
return full;
},
diff --git a/src/sessions/session-store.ts b/src/sessions/session-store.ts
index 9724fe26a..36675e995 100644
--- a/src/sessions/session-store.ts
+++ b/src/sessions/session-store.ts
@@ -457,7 +457,7 @@ export interface SessionStore {
getTape(sessionId: string, opts?: GetTapeOptions): Promise;
tapeCoverage(sessionId: string): Promise;
- recordLlmRequest(sessionId: string, rec: NewLlmRequest): Promise;
+ recordLlmRequest(sessionId: string, rec: NewLlmRequest, signal?: AbortSignal): Promise;
listLlmRequests(sessionId: string, opts?: ListLlmRequestsOptions): Promise;
addParticipant(sessionId: string, principalId: string, title?: string, opts?: AddParticipantOptions): Promise;
diff --git a/src/wiring.ts b/src/wiring.ts
index 3499722b3..760109c77 100644
--- a/src/wiring.ts
+++ b/src/wiring.ts
@@ -1,7 +1,13 @@
import { mkdirSync } from "node:fs";
import { randomBytes, randomUUID } from "node:crypto";
import { join, resolve } from "node:path";
-import { baseModelProviders, configuredModelForHarness, providerKeysPresent, type Config } from "./config.ts";
+import {
+ baseModelProviders,
+ configuredModelForHarness,
+ harnessCarriedModelAuth,
+ providerKeysPresent,
+ type Config,
+} from "./config.ts";
import type { ServerDeps } from "./api/deps.ts";
import { createIdentityService, type DeactivationRecord, type IdentityService } from "./identity/identity-service.ts";
import {
@@ -179,6 +185,8 @@ import type { SessionStore } from "./sessions/session-store.ts";
import { createMockHarness } from "./harness/mock-harness.ts";
import { createOpenCodeHarness, openCodeHarnessConfigOptions } from "./harness/opencode-harness.ts";
import { createCodexHarness, codexHarnessConfigOptions } from "./harness/codex-harness.ts";
+import { keychainCodexAuthStore } from "./harness/codex-auth-store.ts";
+import { keychainHarnessAuthEnv } from "./credentials/harness-auth-env.ts";
import { createClaudeHarness, claudeHarnessConfigOptions } from "./harness/claude-harness.ts";
import { createPiHarness, piHarnessConfigOptions } from "./harness/pi-harness.ts";
import { createHarnessRouter, resolveRuntimeChoiceDurable } from "./harness/harness-router.ts";
@@ -804,8 +812,39 @@ export function buildApp(
},
}),
],
- ["codex", createCodexHarness({ ...codexHarnessConfigOptions(config), signals: runSignals, tasks, mcpTools })],
- ["claude", createClaudeHarness({ ...claudeHarnessConfigOptions(config), signals: runSignals, tasks, mcpTools })],
+ [
+ "codex",
+ createCodexHarness({
+ ...codexHarnessConfigOptions(config),
+ // Keychain custody: the subscription login lives encrypted in its
+ // owner's keychain; core refreshes it centrally and hands the harness
+ // ephemeral derived material. The credential can be (re)registered at
+ // runtime — resolution happens on every load.
+ ...(config.codexAuthCredential && keychain
+ ? { authStore: keychainCodexAuthStore({ keychain, credentialId: config.codexAuthCredential }) }
+ : {}),
+ signals: runSignals,
+ tasks,
+ mcpTools,
+ }),
+ ],
+ [
+ "claude",
+ createClaudeHarness({
+ ...claudeHarnessConfigOptions(config),
+ ...(config.claudeAuthCredential && keychain
+ ? {
+ authEnv: keychainHarnessAuthEnv(keychain, config.claudeAuthCredential, [
+ "CLAUDE_CODE_OAUTH_TOKEN",
+ "ANTHROPIC_AUTH_TOKEN",
+ ]),
+ }
+ : {}),
+ signals: runSignals,
+ tasks,
+ mcpTools,
+ }),
+ ],
["mock", createMockHarness()],
]);
const fallbackHarness = config.harness as HarnessId;
@@ -1567,6 +1606,7 @@ export function serverDeps(
slackEnvBotToken?: string,
): Omit {
const configuredModel = configuredModelForHarness(config, config.harness);
+ const carriedModelAuth = harnessCarriedModelAuth(config);
return {
production: config.production,
allowUnauthenticatedCore: config.allowUnauthenticatedCore,
@@ -1577,6 +1617,7 @@ export function serverDeps(
...(built.replayDedupe ? { replayDedupe: built.replayDedupe } : {}),
config: built.config,
...(configuredModel ? { baseModelDefault: configuredModel } : {}),
+ ...(carriedModelAuth ? { harnessCarriedModelAuth: carriedModelAuth } : {}),
modelProviders: modelProviderAvailabilityFor(config.harness, providerKeysPresent(config)),
providerKeys: providerKeysPresent(config),
modelCredentials: built.modelCredentials,
diff --git a/test/base-model-serviceability.test.ts b/test/base-model-serviceability.test.ts
index 8b7c766e6..386d9350a 100644
--- a/test/base-model-serviceability.test.ts
+++ b/test/base-model-serviceability.test.ts
@@ -9,7 +9,7 @@ import type { AddressInfo } from "node:net";
import { createInsecureTestServer } from "../src/api/server.ts";
import { buildApp } from "../src/wiring.ts";
import { baseModelProviders, configuredModelForHarness, providerKeysPresent } from "../src/config.ts";
-import { defaultModelForHarness } from "../src/model/pi-models.ts";
+import { defaultModelForHarness, modelProviderAvailabilityFor } from "../src/model/pi-models.ts";
import { testConfig } from "./support/test-config.ts";
const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alice@default-org" };
@@ -60,6 +60,23 @@ test("base-model set rejects a model whose provider key is absent (would fail pr
}
});
+test("ChatGPT OAuth is serviceable for Codex without advertising OpenAI to Pi", () => {
+ const config = testConfig({ harness: "codex", codexAuthFile: "/tmp/codex-auth.json" });
+ const configured = providerKeysPresent(config);
+ assert.equal(configured.openai, false);
+ assert.equal(
+ modelProviderAvailabilityFor("codex", configured).openai,
+ true,
+ "Codex can use its harness OAuth session",
+ );
+ assert.equal(modelProviderAvailabilityFor("opencode", configured).openai, false);
+ assert.equal(
+ modelProviderAvailabilityFor("pi", configured, { anthropic: false, openai: false, openrouter: false }).openai,
+ false,
+ "Pi still requires an API-key credential",
+ );
+});
+
test("a deployment that declares a provider runs that provider's base model", async () => {
for (const [modelProvider, key, expected] of [
["anthropic", "anthropicApiKey", "claude-opus-5"],
diff --git a/test/codex-auth-store.test.ts b/test/codex-auth-store.test.ts
new file mode 100644
index 000000000..83b7cf658
--- /dev/null
+++ b/test/codex-auth-store.test.ts
@@ -0,0 +1,232 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ childCodexOAuthAuth,
+ codexOAuthAccessTokenExpiresAt,
+ codexOAuthAuthFromValue,
+ fileCodexAuthStore,
+ keychainCodexAuthStore,
+} from "../src/harness/codex-auth-store.ts";
+import type { CredentialFile, Keychain, KeychainCredentialMeta } from "../src/credentials/keychain.ts";
+
+function jwt(payload: Record, header: Record = { alg: "RS256" }): string {
+ const enc = (v: unknown) => Buffer.from(JSON.stringify(v)).toString("base64url");
+ return `${enc(header)}.${enc(payload)}.sig`;
+}
+
+function idToken(accountId: string): string {
+ return jwt({
+ iss: "https://auth.openai.com",
+ "https://api.openai.com/auth": { chatgpt_account_id: accountId },
+ });
+}
+
+function accessToken(accountId: string, expSec: number, marker = "a"): string {
+ return jwt({
+ iss: "https://auth.openai.com",
+ exp: expSec,
+ marker,
+ "https://api.openai.com/auth": { chatgpt_account_id: accountId },
+ });
+}
+
+function authJson(
+ accountId: string,
+ expSec: number,
+ refreshToken = "refresh-1",
+ marker = "a",
+): Record {
+ return {
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: accessToken(accountId, expSec, marker),
+ refresh_token: refreshToken,
+ id_token: idToken(accountId),
+ account_id: accountId,
+ },
+ };
+}
+
+const NOW = 1_900_000_000_000;
+const FRESH_EXP = Math.floor(NOW / 1000) + 3600;
+const STALE_EXP = Math.floor(NOW / 1000) + 60;
+
+interface FakeKeychainState {
+ meta: KeychainCredentialMeta;
+ files: CredentialFile[];
+ saves: Array<{ ownerId: string; service: string; files?: CredentialFile[] }>;
+}
+
+function fakeKeychain(state: FakeKeychainState): Keychain {
+ return {
+ async getCredential(id: string) {
+ return id === state.meta.id ? state.meta : null;
+ },
+ async materializeOwnFiles(ownerId: string) {
+ if (ownerId !== state.meta.ownerId) return [];
+ return [{ credentialId: state.meta.id, ownerId, service: state.meta.service, files: state.files }];
+ },
+ async save(input: { ownerId: string; service: string; files?: CredentialFile[] }) {
+ state.saves.push(input);
+ if (input.files) state.files = input.files;
+ return state.meta;
+ },
+ } as unknown as Keychain;
+}
+
+function credFiles(auth: Record): CredentialFile[] {
+ return [{ path: ".codex/auth.json", contentBase64: Buffer.from(JSON.stringify(auth)).toString("base64") }];
+}
+
+const META = {
+ id: "cred-1",
+ ownerId: "owner@example.com",
+ service: "codex-chatgpt",
+ kind: "file",
+ fingerprint: "f",
+ createdAt: 0,
+ updatedAt: 0,
+} as KeychainCredentialMeta;
+
+test("child auth material never includes the refresh token", () => {
+ const auth = authJson("acct", FRESH_EXP);
+ const child = childCodexOAuthAuth(auth);
+ const tokens = child.tokens as Record;
+ assert.equal(tokens.refresh_token, undefined);
+ assert.equal(tokens.access_token, (auth.tokens as Record).access_token);
+ assert.equal(tokens.id_token, (auth.tokens as Record).id_token);
+ assert.equal(child.auth_mode, "chatgpt");
+});
+
+test("codexOAuthAuthFromValue validates shape and account binding", () => {
+ assert.ok(codexOAuthAuthFromValue(authJson("acct", FRESH_EXP)));
+ assert.equal(codexOAuthAuthFromValue({ auth_mode: "apikey" }), null);
+ const missingRefresh = authJson("acct", FRESH_EXP);
+ delete (missingRefresh.tokens as Record).refresh_token;
+ assert.equal(codexOAuthAuthFromValue(missingRefresh), null);
+});
+
+test("keychain store returns fresh auth without refreshing", async () => {
+ const state: FakeKeychainState = { meta: META, files: credFiles(authJson("acct", FRESH_EXP)), saves: [] };
+ const store = keychainCodexAuthStore({
+ keychain: fakeKeychain(state),
+ credentialId: "cred-1",
+ now: () => NOW,
+ fetchImpl: () => {
+ throw new Error("must not refresh");
+ },
+ });
+ const auth = await store.load();
+ assert.ok(auth);
+ assert.equal(state.saves.length, 0);
+});
+
+test("keychain store refreshes a stale access token centrally and persists rotation", async () => {
+ const state: FakeKeychainState = {
+ meta: META,
+ files: credFiles(authJson("acct", STALE_EXP, "refresh-1")),
+ saves: [],
+ };
+ const calls: Array> = [];
+ const store = keychainCodexAuthStore({
+ keychain: fakeKeychain(state),
+ credentialId: "cred-1",
+ now: () => NOW,
+ fetchImpl: (async (_url: string, init: { body: string }) => {
+ calls.push(JSON.parse(init.body) as Record);
+ return {
+ ok: true,
+ json: async () => ({
+ access_token: accessToken("acct", FRESH_EXP, "refreshed"),
+ id_token: idToken("acct"),
+ refresh_token: "refresh-2",
+ }),
+ };
+ }) as unknown as typeof fetch,
+ });
+ const auth = await store.load();
+ assert.ok(auth);
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0]?.grant_type, "refresh_token");
+ assert.equal(calls[0]?.refresh_token, "refresh-1");
+ const tokens = auth!.tokens as Record;
+ assert.equal(tokens.refresh_token, "refresh-2");
+ assert.equal(state.saves.length, 1);
+ const persisted = JSON.parse(
+ Buffer.from(state.saves[0]!.files![0]!.contentBase64, "base64").toString("utf8"),
+ ) as Record;
+ assert.equal((persisted.tokens as Record).refresh_token, "refresh-2");
+});
+
+test("keychain store refuses a refresh that switches accounts", async () => {
+ const state: FakeKeychainState = { meta: META, files: credFiles(authJson("acct", STALE_EXP)), saves: [] };
+ const store = keychainCodexAuthStore({
+ keychain: fakeKeychain(state),
+ credentialId: "cred-1",
+ now: () => NOW,
+ fetchImpl: (async () => ({
+ ok: true,
+ json: async () => ({
+ access_token: accessToken("other-acct", FRESH_EXP),
+ id_token: idToken("other-acct"),
+ refresh_token: "refresh-2",
+ }),
+ })) as unknown as typeof fetch,
+ });
+ const auth = await store.load();
+ // Falls back to the (stale) stored auth rather than adopting a different account.
+ assert.ok(auth);
+ assert.equal((auth!.tokens as Record).refresh_token, "refresh-1");
+ assert.equal(state.saves.length, 0);
+});
+
+test("keychain store surfaces null when the credential is missing", async () => {
+ const state: FakeKeychainState = { meta: META, files: credFiles(authJson("acct", FRESH_EXP)), saves: [] };
+ const store = keychainCodexAuthStore({ keychain: fakeKeychain(state), credentialId: "other", now: () => NOW });
+ assert.equal(await store.load(), null);
+});
+
+test("file store refreshes a stale token and writes back under the lock with compare-and-set", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-store-file-"));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ const path = join(dir, "auth.json");
+ writeFileSync(path, JSON.stringify(authJson("acct", STALE_EXP, "refresh-1")));
+ chmodSync(path, 0o600);
+ const store = fileCodexAuthStore(
+ path,
+ (async () => ({
+ ok: true,
+ json: async () => ({
+ access_token: accessToken("acct", FRESH_EXP, "refreshed"),
+ id_token: idToken("acct"),
+ refresh_token: "refresh-2",
+ }),
+ })) as unknown as typeof fetch,
+ () => NOW,
+ );
+ const auth = await store.load();
+ assert.ok(auth);
+ assert.equal((auth!.tokens as Record).refresh_token, "refresh-2");
+ const persisted = JSON.parse(readFileSync(path, "utf8")) as Record;
+ assert.equal((persisted.tokens as Record).refresh_token, "refresh-2");
+ assert.equal(codexOAuthAccessTokenExpiresAt(persisted), FRESH_EXP * 1000);
+});
+
+test("file store keeps serving current auth when the refresh endpoint fails", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-store-file-fail-"));
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ const path = join(dir, "auth.json");
+ writeFileSync(path, JSON.stringify(authJson("acct", STALE_EXP, "refresh-1")));
+ chmodSync(path, 0o600);
+ const store = fileCodexAuthStore(
+ path,
+ (async () => ({ ok: false, status: 503 })) as unknown as typeof fetch,
+ () => NOW,
+ );
+ const auth = await store.load();
+ assert.ok(auth);
+ assert.equal((auth!.tokens as Record).refresh_token, "refresh-1");
+});
diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts
index 9b9cf957e..b075b0213 100644
--- a/test/codex-harness.test.ts
+++ b/test/codex-harness.test.ts
@@ -1,7 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
-import { homedir, tmpdir } from "node:os";
+import {
+ chmodSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ utimesSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import {
@@ -23,8 +32,10 @@ import type { HarnessLlmRequestRecord, HarnessTurnInput } from "../src/harness/h
import { NonRetryableTurnError } from "../src/core/turn-error.ts";
import type { ScopeId, Session, SessionEntry } from "../src/types.ts";
import { createMemoryTaskStore } from "../src/tasks/memory-task-store.ts";
-import { CodexAppServer } from "../src/harness/codex-app-server.ts";
+import { CodexAppServer, redactCodexDiagnostics } from "../src/harness/codex-app-server.ts";
import { DEFAULT_CODEX_MODEL_ID } from "../src/model/pi-models.ts";
+import { readCodexOAuthAuthFile } from "../src/harness/codex-auth.ts";
+import { acquireCodexOAuthAuthLock } from "../src/harness/codex-auth.ts";
const replaySmokeItems = [
{ type: "message", role: "user", content: [{ type: "input_text", text: "earlier question" }] },
@@ -33,6 +44,21 @@ const replaySmokeItems = [
{ type: "function_call_output", call_id: "call-1", output: "[exit 0]" },
];
+function testHarnessEnv(home: string): NodeJS.ProcessEnv {
+ return { ...process.env, HOME: home, CODEX_HOME: join(home, "codex-home") };
+}
+
+function oauthIdToken(accountId: string, marker = ""): string {
+ const payload = Buffer.from(
+ JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId }, marker }),
+ ).toString("base64url");
+ return `header.${payload}.signature`;
+}
+
+function oauthAccessToken(accountId: string, marker = "access"): string {
+ return oauthIdToken(accountId, marker);
+}
+
test("Codex replay keeps paired tool ids within the provider's 64-character limit", () => {
const longId = "tool-call-".repeat(9);
const normalized = codexReplayCallId(longId);
@@ -164,6 +190,229 @@ process.stdin.resume();
return path;
}
+function startupCancellationCodexBinary(dir: string): string {
+ const path = join(dir, "startup-cancellation-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+const fs = require("node:fs");
+fs.appendFileSync(${JSON.stringify(join(dir, "starts"))}, "start\\n");
+process.on("SIGTERM", () => {
+ fs.writeFileSync(${JSON.stringify(join(dir, "closed"))}, "closed");
+ process.exit(0);
+});
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function pendingTurnStartCodexBinary(dir: string): string {
+ const path = join(dir, "pending-turn-start-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+const fs = require("node:fs");
+const readline = require("node:readline");
+const rl = readline.createInterface({ input: process.stdin });
+const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
+rl.on("line", (line) => {
+ const msg = JSON.parse(line);
+ if (msg.method === "initialize") return send({ id: msg.id, result: {} });
+ if (msg.method === "initialized") return;
+ if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-pending" } } });
+ if (msg.method === "turn/start") fs.writeFileSync(${JSON.stringify(join(dir, "turn-started"))}, "started");
+});
+process.on("SIGTERM", () => {
+ fs.writeFileSync(${JSON.stringify(join(dir, "closed"))}, "closed");
+ process.exit(0);
+});
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function refreshThenNonresponsiveCodexBinary(dir: string): string {
+ const path = join(dir, "refresh-then-nonresponsive-codex");
+ const accessToken = oauthAccessToken("startup-account", "startup-after");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+const fs = require("node:fs");
+const path = require("node:path");
+const authPath = path.join(process.env.CODEX_HOME, "auth.json");
+const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
+auth.tokens.access_token = ${JSON.stringify(accessToken)};
+fs.writeFileSync(authPath, JSON.stringify(auth));
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function malformedCodexBinary(dir: string): string {
+ const path = join(dir, "malformed-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+process.stdout.write('{"access_token":"oauth-secret-123456789"\\n');
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function incompleteResponseCodexBinary(dir: string): string {
+ const path = join(dir, "incomplete-response-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+process.stdout.write('{"id":1}\\n');
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function arrayMessageCodexBinary(dir: string): string {
+ const path = join(dir, "array-message-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+process.stdout.write('[]\\n');
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function invalidJsonRpcBinary(dir: string): string {
+ const path = join(dir, "invalid-json-rpc-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+process.stdout.write('{"id":true,"result":{}}\\n');
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function noIdResponseCodexBinary(dir: string): string {
+ const path = join(dir, "no-id-response-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+process.stdout.write('{"result":{}}\\n');
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function unknownResponseCodexBinary(dir: string): string {
+ const path = join(dir, "unknown-response-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+process.stdout.write('{"id":999,"result":{}}\\n');
+process.stdin.resume();
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function malformedTurnCompletedCodexBinary(dir: string): string {
+ const path = join(dir, "malformed-turn-completed-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+const readline = require("node:readline");
+const rl = readline.createInterface({ input: process.stdin });
+const send = value => process.stdout.write(JSON.stringify(value) + "\\n");
+rl.on("line", line => {
+ const msg = JSON.parse(line);
+ if (msg.method === "initialize") return send({ id: msg.id, result: {} });
+ if (msg.method === "initialized") return;
+ if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "malformed-thread" } } });
+ if (msg.method === "turn/start") {
+ send({ id: msg.id, result: { turn: { id: "malformed-turn", status: "inProgress", items: [] } } });
+ return send({ method: "turn/completed", params: { threadId: "malformed-thread", turn: {} } });
+ }
+});
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function oauthTurnBinary(dir: string, token: string, delayMs: number): string {
+ const path = join(dir, `oauth-${token}`);
+ const events = join(dir, "oauth-events");
+ const accessToken = oauthAccessToken("shared-account", token);
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+const fs = require("node:fs");
+const path = require("node:path");
+const readline = require("node:readline");
+const authPath = path.join(process.env.CODEX_HOME, "auth.json");
+const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
+const rl = readline.createInterface({ input: process.stdin });
+rl.on("line", (line) => {
+ const msg = JSON.parse(line);
+ if (msg.method === "initialize") return send({ id: msg.id, result: {} });
+ if (msg.method === "initialized") return;
+ if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-${token}" } } });
+ if (msg.method === "turn/start") {
+ const auth = JSON.parse(fs.readFileSync(authPath, "utf8"));
+ auth.tokens.access_token = ${JSON.stringify(accessToken)};
+ fs.writeFileSync(authPath, JSON.stringify(auth));
+ fs.appendFileSync(${JSON.stringify(events)}, ${JSON.stringify(`${token}\n`)});
+ send({ id: msg.id, result: { turn: { id: "turn-${token}", status: "inProgress", items: [] } } });
+ return setTimeout(() => send({ method: "turn/completed", params: { threadId: "thread-${token}", turn: { id: "turn-${token}", status: "completed", items: [{ type: "agentMessage", text: ${JSON.stringify(token)}, phase: "final_answer" }] } } }), ${delayMs});
+ }
+ if (msg.method === "turn/interrupt") return send({ id: msg.id, result: {} });
+});
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
+function exitingCodexBinary(dir: string): string {
+ const path = join(dir, "exiting-codex");
+ writeFileSync(
+ path,
+ `#!${process.execPath}
+const readline = require("node:readline");
+const rl = readline.createInterface({ input: process.stdin });
+const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
+rl.on("line", (line) => {
+ const msg = JSON.parse(line);
+ if (msg.method === "initialize") return send({ id: msg.id, result: {} });
+ if (msg.method === "initialized") return;
+ if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-exit" } } });
+ if (msg.method === "turn/start") {
+ send({ id: msg.id, result: { turn: { id: "turn-exit", status: "inProgress", items: [] } } });
+ setTimeout(() => process.exit(17), 50);
+ }
+});
+`,
+ );
+ chmodSync(path, 0o755);
+ return path;
+}
+
test("Codex forwards external-content screening into its native tool bridge", () => {
const screenExternalContent: NonNullable = async () => ({
decision: "auto",
@@ -175,7 +424,7 @@ test("Codex forwards external-content screening into its native tool bridge", ()
test("Codex harness drives app-server JSON-RPC with a read-only jail", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "qm-codex-test-"));
const tasks = createMemoryTaskStore();
- const harness = createCodexHarness({ binaryPath: fakeCodexBinary(dir), env: process.env, tasks });
+ const harness = createCodexHarness({ binaryPath: fakeCodexBinary(dir), env: testHarnessEnv(dir), tasks });
t.after(async () => {
await harness.turns.close?.();
rmSync(dir, { recursive: true, force: true });
@@ -292,7 +541,7 @@ test("Codex child environment excludes core credentials and user homes", () => {
test("Codex materializes API-key auth into its isolated home, and never an ambient login", (t) => {
const jail = mkdtempSync(join(tmpdir(), "qm-codex-auth-test-"));
t.after(() => rmSync(jail, { recursive: true, force: true }));
- const home = prepareCodexHome({ OPENAI_API_KEY: "sk-test" }, jail);
+ const home = prepareCodexHome({ CODEX_HOME: join(jail, "empty-source"), OPENAI_API_KEY: "sk-test" }, jail);
assert.deepEqual(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")), {
auth_mode: "apikey",
OPENAI_API_KEY: "sk-test",
@@ -300,7 +549,341 @@ test("Codex materializes API-key auth into its isolated home, and never an ambie
const bare = mkdtempSync(join(tmpdir(), "qm-codex-auth-bare-"));
t.after(() => rmSync(bare, { recursive: true, force: true }));
- assert.equal(existsSync(join(prepareCodexHome({ HOME: homedir() }, bare), "auth.json")), false);
+ assert.equal(
+ existsSync(join(prepareCodexHome({ CODEX_HOME: join(bare, "empty-source") }, bare), "auth.json")),
+ false,
+ );
+});
+
+test("Codex materializes ChatGPT OAuth auth as ephemeral child material without the refresh token", async (t) => {
+ const source = mkdtempSync(join(tmpdir(), "qm-codex-oauth-source-"));
+ const jail = mkdtempSync(join(tmpdir(), "qm-codex-oauth-jail-"));
+ t.after(() => {
+ rmSync(source, { recursive: true, force: true });
+ rmSync(jail, { recursive: true, force: true });
+ });
+ const authFile = join(source, "auth.json");
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ OPENAI_API_KEY: "ambient-api-key",
+ tokens: {
+ access_token: oauthAccessToken("account-before", "before"),
+ refresh_token: "refresh-before",
+ account_id: "account-before",
+ id_token: oauthIdToken("account-before"),
+ },
+ }),
+ );
+ chmodSync(authFile, 0o600);
+ const sourceEnv = {
+ CODEX_AUTH_FILE: authFile,
+ OPENAI_API_KEY: "ambient-api-key",
+ OPENAI_BASE_URL: "https://untrusted.example/v1",
+ CODEX_ACCESS_TOKEN: "ambient-codex-token",
+ };
+ assert.deepEqual(codexChildEnv(sourceEnv, jail), {
+ HOME: jail,
+ CODEX_HOME: join(jail, "codex-home"),
+ });
+ const home = prepareCodexHome(sourceEnv, jail);
+ const childAuthFile = join(home, "auth.json");
+ const childAuth = JSON.parse(readFileSync(childAuthFile, "utf8")) as Record;
+ assert.equal(childAuth.OPENAI_API_KEY, undefined);
+ assert.equal(
+ (childAuth.tokens as Record).access_token,
+ oauthAccessToken("account-before", "before"),
+ );
+ assert.equal((childAuth.tokens as Record).account_id, "account-before");
+ // The child never receives the long-lived credential: only the store refreshes.
+ assert.equal((childAuth.tokens as Record).refresh_token, undefined);
+ // Nothing a child writes ever flows back to the source of truth.
+ writeFileSync(
+ childAuthFile,
+ JSON.stringify({
+ ...childAuth,
+ tokens: {
+ access_token: oauthAccessToken("account-before", "after"),
+ refresh_token: "refresh-forged",
+ account_id: "account-before",
+ id_token: oauthIdToken("account-before"),
+ },
+ }),
+ );
+ const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record;
+ assert.equal((persisted.tokens as Record).refresh_token, "refresh-before");
+ assert.equal(
+ (persisted.tokens as Record).access_token,
+ oauthAccessToken("account-before", "before"),
+ );
+ // A stale lock left behind by a dead process is recovered, not honored forever.
+ const liveLock = `${authFile}.lock`;
+ writeFileSync(liveLock, String(process.pid));
+ utimesSync(liveLock, new Date(0), new Date(0));
+ const recoveredLock = await acquireCodexOAuthAuthLock(authFile, undefined, 1_000);
+ assert.equal(recoveredLock.isHeld(), true);
+ await recoveredLock.release();
+ assert.equal(existsSync(liveLock), false);
+
+ const defaultSource = mkdtempSync(join(tmpdir(), "qm-codex-oauth-default-source-"));
+ const defaultJail = mkdtempSync(join(tmpdir(), "qm-codex-oauth-default-jail-"));
+ t.after(() => {
+ rmSync(defaultSource, { recursive: true, force: true });
+ rmSync(defaultJail, { recursive: true, force: true });
+ });
+ mkdirSync(join(defaultSource, ".codex"), { recursive: true });
+ writeFileSync(
+ join(defaultSource, ".codex", "auth.json"),
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "default-access",
+ refresh_token: "default-refresh",
+ account_id: "default-account",
+ id_token: oauthIdToken("default-account"),
+ },
+ }),
+ );
+ chmodSync(join(defaultSource, ".codex", "auth.json"), 0o600);
+ const defaultEnv = { HOME: defaultSource, OPENAI_API_KEY: "ambient-default-api-key" };
+ assert.equal(codexChildEnv(defaultEnv, defaultJail).OPENAI_API_KEY, undefined);
+ assert.equal(existsSync(join(prepareCodexHome(defaultEnv, defaultJail), "auth.json")), true);
+});
+
+
+test("Codex diagnostics redact credential-shaped stderr", () => {
+ assert.equal(
+ redactCodexDiagnostics(
+ '{"access_token":"access-secret","refresh_token":"refresh-secret"} Bearer bearer-secret-123456789 sk-secret-value',
+ ),
+ '{"access_token":"[redacted]","refresh_token":"[redacted]"} Bearer [redacted] [redacted]',
+ );
+ const diagnostics = redactCodexDiagnostics(
+ "Authorization: Basic basic-secret-123456 Cookie: session-cookie-secret; Set-Cookie: refresh-cookie-secret; X-Api-Key: api-secret-123456 accessToken=camel-secret-123456 token=generic-secret-123456",
+ );
+ for (const secret of [
+ "basic-secret-123456",
+ "session-cookie-secret",
+ "refresh-cookie-secret",
+ "api-secret-123456",
+ "camel-secret-123456",
+ "generic-secret-123456",
+ ])
+ assert.equal(diagnostics.includes(secret), false, secret);
+ const structured = redactCodexDiagnostics('authorization=["Bearer array-secret"] access_token="unterminated-secret');
+ assert.equal(structured.includes("array-secret"), false);
+ assert.equal(structured.includes("unterminated-secret"), false);
+ const arrayDiagnostics = redactCodexDiagnostics('access_token=["first-array-secret","second-array-secret"]');
+ assert.equal(arrayDiagnostics.includes("first-array-secret"), false);
+ assert.equal(arrayDiagnostics.includes("second-array-secret"), false);
+ const malformedArray = redactCodexDiagnostics('access_token=["first-array-secret",\n"second-array-secret"');
+ assert.equal(malformedArray.includes("first-array-secret"), false);
+ assert.equal(malformedArray.includes("second-array-secret"), false);
+ const malformedObject = redactCodexDiagnostics('access_token={"a":"first-object-secret","b":"second-object-secret"}');
+ assert.equal(malformedObject.includes("first-object-secret"), false);
+ assert.equal(malformedObject.includes("second-object-secret"), false);
+ const nested = redactCodexDiagnostics(
+ JSON.stringify({
+ nested: { authorization: { header: "Bearer nested-secret" } },
+ tokens: { access_token: ["one-secret"] },
+ }),
+ );
+ assert.equal(nested.includes("nested-secret"), false);
+ assert.equal(nested.includes("one-secret"), false);
+ assert.equal(redactCodexDiagnostics("id_token=header.payload.signature").includes("header.payload.signature"), false);
+ const generic = redactCodexDiagnostics(
+ JSON.stringify({
+ secret: "generic-secret",
+ password: "generic-password",
+ opaque: "opaque-secret-value-123456789012345678901234",
+ }),
+ );
+ assert.equal(generic.includes("generic-secret"), false);
+ assert.equal(generic.includes("generic-password"), false);
+ assert.equal(generic.includes("opaque-secret-value-123456789012345678901234"), false);
+});
+
+test("Codex ignores OAuth auth files that are readable by other users", (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-mode-test-"));
+ const authFile = join(dir, "auth.json");
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "mode-access",
+ refresh_token: "mode-refresh",
+ account_id: "mode-account",
+ id_token: oauthIdToken("mode-account"),
+ },
+ }),
+ { mode: 0o600 },
+ );
+ assert.deepEqual(readCodexOAuthAuthFile(authFile), {
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "mode-access",
+ refresh_token: "mode-refresh",
+ account_id: "mode-account",
+ id_token: oauthIdToken("mode-account"),
+ },
+ });
+ chmodSync(authFile, 0o644);
+ assert.equal(readCodexOAuthAuthFile(authFile), null);
+});
+
+test("Codex rejects OAuth auth files without a trusted account claim", (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-optional-account-test-"));
+ const authFile = join(dir, "auth.json");
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: { access_token: "optional-access", refresh_token: "optional-refresh" },
+ }),
+ { mode: 0o600 },
+ );
+ assert.equal(readCodexOAuthAuthFile(authFile), null);
+});
+
+
+
+
+
+
+
+
+
+test("Codex diagnostics redact malformed app-server output at the protocol boundary", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-malformed-test-"));
+ const server = new CodexAppServer({
+ binaryPath: malformedCodexBinary(dir),
+ cwd: dir,
+ env: { PATH: process.env.PATH },
+ onNotification: () => {},
+ onRequest: async () => ({}),
+ });
+ t.after(async () => {
+ await server.close().catch(() => undefined);
+ rmSync(dir, { recursive: true, force: true });
+ });
+ await assert.rejects(server.initialize(), (error: unknown) => {
+ const message = error instanceof Error ? error.message : String(error);
+ assert.equal(message.includes("oauth-secret-123456789"), false);
+ assert.equal(message.includes("[redacted]"), true);
+ return true;
+ });
+});
+
+test("Codex rejects incomplete JSON-RPC responses", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-incomplete-response-test-"));
+ const server = new CodexAppServer({
+ binaryPath: incompleteResponseCodexBinary(dir),
+ cwd: dir,
+ env: { PATH: process.env.PATH },
+ onNotification: () => {},
+ onRequest: async () => ({}),
+ });
+ t.after(async () => {
+ await server.close().catch(() => undefined);
+ rmSync(dir, { recursive: true, force: true });
+ });
+ await assert.rejects(server.initialize(), /invalid JSON/);
+});
+
+test("Codex rejects response messages without ids", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-no-id-response-test-"));
+ const server = new CodexAppServer({
+ binaryPath: noIdResponseCodexBinary(dir),
+ cwd: dir,
+ env: { PATH: process.env.PATH },
+ onNotification: () => {},
+ onRequest: async () => ({}),
+ });
+ t.after(async () => {
+ await server.close().catch(() => undefined);
+ rmSync(dir, { recursive: true, force: true });
+ });
+ await assert.rejects(server.initialize(), /invalid JSON/);
+});
+
+test("Codex rejects JSON arrays at the JSON-RPC boundary", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-array-message-test-"));
+ const server = new CodexAppServer({
+ binaryPath: arrayMessageCodexBinary(dir),
+ cwd: dir,
+ env: { PATH: process.env.PATH },
+ onNotification: () => {},
+ onRequest: async () => ({}),
+ });
+ t.after(async () => {
+ await server.close().catch(() => undefined);
+ rmSync(dir, { recursive: true, force: true });
+ });
+ await assert.rejects(server.initialize(), /invalid JSON/);
+});
+
+test("Codex rejects malformed JSON-RPC field types", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-invalid-json-rpc-test-"));
+ const server = new CodexAppServer({
+ binaryPath: invalidJsonRpcBinary(dir),
+ cwd: dir,
+ env: { PATH: process.env.PATH },
+ onNotification: () => {},
+ onRequest: async () => ({}),
+ });
+ t.after(async () => {
+ await server.close().catch(() => undefined);
+ rmSync(dir, { recursive: true, force: true });
+ });
+ await assert.rejects(server.initialize(), /invalid JSON/);
+});
+
+test("Codex rejects unknown JSON-RPC response ids", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-unknown-response-test-"));
+ const server = new CodexAppServer({
+ binaryPath: unknownResponseCodexBinary(dir),
+ cwd: dir,
+ env: { PATH: process.env.PATH },
+ onNotification: () => {},
+ onRequest: async () => ({}),
+ });
+ t.after(async () => {
+ await server.close().catch(() => undefined);
+ rmSync(dir, { recursive: true, force: true });
+ });
+ await assert.rejects(server.initialize(), /unknown response id/);
+});
+
+test("Codex rejects malformed turn completion payloads", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-malformed-turn-test-"));
+ const harness = createCodexHarness({
+ binaryPath: malformedTurnCompletedCodexBinary(dir),
+ env: testHarnessEnv(dir),
+ turnWallClockMs: 2_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ await assert.rejects(
+ harness.turns.runTurn({
+ session: { id: "malformed-turn" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ emit: async (entry) => ({ ...entry, sessionId: "malformed-turn", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ }),
+ /invalid turn\/completed payload/,
+ );
});
test("Codex children cannot use parent surface, control, or terminal tools", () => {
@@ -315,7 +898,7 @@ test("Codex interrupts the provider after a terminal QM tool", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "qm-codex-stop-test-"));
const harness = createCodexHarness({
binaryPath: terminatingCodexBinary(dir),
- env: process.env,
+ env: testHarnessEnv(dir),
turnWallClockMs: 2_000,
});
t.after(async () => {
@@ -382,7 +965,7 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async (
const dir = mkdtempSync(join(tmpdir(), "qm-codex-startup-test-"));
const harness = createCodexHarness({
binaryPath: nonresponsiveCodexBinary(dir),
- env: process.env,
+ env: testHarnessEnv(dir),
appServerStartTimeoutMs: 1_000,
turnWallClockMs: 6_000,
});
@@ -409,11 +992,224 @@ test("Codex discards a nonresponsive startup so a later turn can retry", async (
assert.equal(readFileSync(join(dir, "starts"), "utf8"), "start\nstart\n");
});
+test("Codex preserves OAuth auth before discarding a failed startup", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-startup-oauth-test-"));
+ const authFile = join(dir, "auth.json");
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "startup-access-before",
+ refresh_token: "startup-refresh-before",
+ account_id: "startup-account",
+ id_token: oauthIdToken("startup-account"),
+ },
+ }),
+ );
+ chmodSync(authFile, 0o600);
+ const harness = createCodexHarness({
+ binaryPath: refreshThenNonresponsiveCodexBinary(dir),
+ env: { CODEX_AUTH_FILE: authFile },
+ appServerStartTimeoutMs: 1_000,
+ turnWallClockMs: 3_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ await assert.rejects(
+ harness.turns.runTurn({
+ session: { id: "startup-oauth" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ emit: async (entry) => ({ ...entry, sessionId: "startup-oauth", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ }),
+ (error: unknown) => /timed out|exited|closed/i.test(error instanceof Error ? error.message : String(error)),
+ );
+ const persisted = JSON.parse(readFileSync(authFile, "utf8")) as Record;
+ assert.equal((persisted.tokens as Record).access_token, "startup-access-before");
+});
+
+
+
+test("cancelling an OAuth startup after spawn closes the provider", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-cancel-startup-child-test-"));
+ const authFile = join(dir, "auth.json");
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "cancel-child-access",
+ refresh_token: "cancel-child-refresh",
+ account_id: "cancel-child-account",
+ id_token: oauthIdToken("cancel-child-account"),
+ },
+ }),
+ { mode: 0o600 },
+ );
+ const harness = createCodexHarness({
+ binaryPath: startupCancellationCodexBinary(dir),
+ env: { CODEX_AUTH_FILE: authFile },
+ appServerStartTimeoutMs: 1_000,
+ turnWallClockMs: 3_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ const cancel = new AbortController();
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ const turn = harness.turns.runTurn({
+ session: { id: "cancel-startup-child" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ cancel: cancel.signal,
+ emit: async (entry) =>
+ ({ ...entry, sessionId: "cancel-startup-child", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ });
+ for (let attempt = 0; attempt < 50 && !existsSync(join(dir, "starts")); attempt += 1)
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(existsSync(join(dir, "starts")), true);
+ cancel.abort();
+ assert.deepEqual(await turn, { reply: "", stopped: true });
+ for (let attempt = 0; attempt < 100 && !existsSync(join(dir, "closed")); attempt += 1)
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(readFileSync(join(dir, "closed"), "utf8"), "closed");
+});
+
+test("cancelling a pending Codex turn/start stops and closes the runtime", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-cancel-turn-start-test-"));
+ const harness = createCodexHarness({
+ binaryPath: pendingTurnStartCodexBinary(dir),
+ env: testHarnessEnv(dir),
+ turnWallClockMs: 3_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ const cancel = new AbortController();
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ const turn = harness.turns.runTurn({
+ session: { id: "cancel-turn-start" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ cancel: cancel.signal,
+ emit: async (entry) =>
+ ({ ...entry, sessionId: "cancel-turn-start", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ });
+ for (let attempt = 0; attempt < 100 && !existsSync(join(dir, "turn-started")); attempt += 1)
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(existsSync(join(dir, "turn-started")), true);
+ cancel.abort();
+ assert.deepEqual(await turn, { reply: "", stopped: true });
+ for (let attempt = 0; attempt < 100 && !existsSync(join(dir, "closed")); attempt += 1)
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ assert.equal(readFileSync(join(dir, "closed"), "utf8"), "closed");
+});
+
+
+
+test("Codex fails closed when OAuth auth is removed after startup", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-oauth-delete-test-"));
+ const authFile = join(dir, "auth.json");
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "delete-access",
+ refresh_token: "delete-refresh",
+ account_id: "delete-account",
+ id_token: oauthIdToken("delete-account"),
+ },
+ }),
+ { mode: 0o600 },
+ );
+ const harness = createCodexHarness({
+ binaryPath: oauthTurnBinary(dir, "delete", 1),
+ env: { CODEX_AUTH_FILE: authFile },
+ turnWallClockMs: 3_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ const run = (id: string) =>
+ harness.turns.runTurn({
+ session: { id } as Session,
+ input: id,
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ emit: async (entry) => ({ ...entry, sessionId: id, seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ });
+ assert.equal((await run("before-delete")).reply, "delete");
+ rmSync(authFile);
+ await assert.rejects(run("after-delete"), /OAuth auth is unavailable/);
+});
+
+test("Codex app-server exits reject turns without unhandled rejections", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-exit-test-"));
+ const harness = createCodexHarness({
+ binaryPath: exitingCodexBinary(dir),
+ env: testHarnessEnv(dir),
+ turnWallClockMs: 3_000,
+ });
+ const unhandled: unknown[] = [];
+ const onUnhandled = (error: unknown) => unhandled.push(error);
+ process.on("unhandledRejection", onUnhandled);
+ t.after(async () => {
+ process.off("unhandledRejection", onUnhandled);
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ await assert.rejects(
+ harness.turns.runTurn({
+ session: { id: "exit-turn" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ emit: async (entry) => ({ ...entry, sessionId: "exit-turn", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ }),
+ /exited \(17\)/,
+ );
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ assert.deepEqual(unhandled, []);
+});
+
test("cancelling one Codex setup does not kill another active turn", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "qm-codex-concurrent-test-"));
const harness = createCodexHarness({
binaryPath: concurrentCodexBinary(dir),
- env: process.env,
+ env: testHarnessEnv(dir),
turnWallClockMs: 2_000,
});
t.after(async () => {
@@ -496,6 +1292,10 @@ test("Codex never classifies its own infrastructure failures as terminal", () =>
}
assert.equal(codexProviderFailure("Codex turn failed").message, "Codex turn failed");
assert.ok(!(codexProviderFailure("socket hang up") instanceof NonRetryableTurnError));
+ assert.equal(
+ codexProviderFailure("401 access_token=provider-secret-123456").message.includes("provider-secret"),
+ false,
+ );
});
test("Codex reads cumulative usage totals off the app-server's token notification", () => {
@@ -543,7 +1343,7 @@ for (const mode of ["turnFailed", "startRejected"] as const) {
const dir = mkdtempSync(join(tmpdir(), "qm-codex-fail-test-"));
const harness = createCodexHarness({
binaryPath: failingProviderCodexBinary(dir, mode),
- env: process.env,
+ env: testHarnessEnv(dir),
turnWallClockMs: 5_000,
});
t.after(async () => {
@@ -573,7 +1373,7 @@ test("Codex records one llm row per turn carrying real timings and usage, even w
const records: HarnessLlmRequestRecord[] = [];
const scope = { kind: "org", id: "test" } as unknown as ScopeId;
const runWith = async (binaryPath: string, id: string) => {
- const harness = createCodexHarness({ binaryPath, env: process.env, turnWallClockMs: 5_000 });
+ const harness = createCodexHarness({ binaryPath, env: testHarnessEnv(dir), turnWallClockMs: 5_000 });
t.after(async () => await harness.turns.close?.());
return await harness.turns.runTurn({
session: { id } as Session,
@@ -605,6 +1405,87 @@ test("Codex records one llm row per turn carrying real timings and usage, even w
assert.ok(typeof records[1]!.durationMs === "number");
});
+test("Codex waits for the bounded durable llm record before completing a turn", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-telemetry-order-test-"));
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ const harness = createCodexHarness({
+ binaryPath: fakeCodexBinary(dir),
+ env: testHarnessEnv(dir),
+ turnWallClockMs: 5_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ let recorded = false;
+ const startedAt = Date.now();
+ const result = await harness.turns.runTurn({
+ session: { id: "telemetry-order" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ emit: async (entry) => ({ ...entry, sessionId: "telemetry-order", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ recordLlmRequest: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ recorded = true;
+ },
+ });
+ assert.equal(result.reply, "hello");
+ assert.equal(recorded, true);
+ assert.ok(Date.now() - startedAt >= 45);
+});
+
+test("Codex aborts a durable llm record that exceeds its bound", async (t) => {
+ const dir = mkdtempSync(join(tmpdir(), "qm-codex-telemetry-timeout-test-"));
+ const scope = { kind: "org", id: "test" } as unknown as ScopeId;
+ const harness = createCodexHarness({
+ binaryPath: fakeCodexBinary(dir),
+ env: testHarnessEnv(dir),
+ turnWallClockMs: 12_000,
+ });
+ t.after(async () => {
+ await harness.turns.close?.();
+ rmSync(dir, { recursive: true, force: true });
+ });
+ let aborted = false;
+ const result = await harness.turns.runTurn({
+ session: { id: "telemetry-timeout" } as Session,
+ input: "hi",
+ systemPrompt: "be concise",
+ history: [],
+ tools: {} as HarnessTurnInput["tools"],
+ scopeLabel: scope,
+ orgScopeId: scope,
+ emit: async (entry) =>
+ ({ ...entry, sessionId: "telemetry-timeout", seq: 1, createdAt: Date.now() }) as SessionEntry,
+ recordModelCall: () => {},
+ recordLlmRequest: async (_record, signal) => {
+ if (!signal) throw new Error("missing record cancellation signal");
+ await new Promise((resolve) => {
+ if (signal.aborted) {
+ aborted = true;
+ resolve();
+ return;
+ }
+ signal.addEventListener(
+ "abort",
+ () => {
+ aborted = true;
+ resolve();
+ },
+ { once: true },
+ );
+ });
+ },
+ });
+ assert.equal(result.reply, "hello");
+ assert.equal(aborted, true);
+});
+
const realCodexBinary = (() => {
try {
return join(dirname(createRequire(import.meta.url).resolve("@openai/codex/package.json")), "bin/codex.js");
@@ -623,7 +1504,7 @@ test(
const server = new CodexAppServer({
binaryPath: realCodexBinary!,
cwd: jail,
- env: codexChildEnv({ PATH: process.env.PATH }, jail),
+ env: codexChildEnv({ PATH: process.env.PATH, CODEX_HOME: join(jail, "empty-source") }, jail),
onNotification: () => {},
onRequest: async (method) => {
requests.push(method);
@@ -636,43 +1517,56 @@ test(
});
await server.initialize();
- const started = await server.request<{ thread: { id: string } }>("thread/start", {
- model: DEFAULT_CODEX_MODEL_ID,
- cwd: jail,
- approvalPolicy: "never",
- sandbox: "read-only",
- ephemeral: true,
- baseInstructions: "be concise",
- developerInstructions: "use the supplied dynamic tools",
- dynamicTools: [
- {
- type: "function",
- name: "execute",
- description: "run a command",
- inputSchema: { type: "object", properties: {} },
- },
- ],
- experimentalRawEvents: true,
- environments: [],
- config: {
- web_search: "disabled",
- features: {
- shell_tool: false,
- unified_exec: false,
- shell_snapshot: false,
- apps: false,
- plugins: false,
- browser_use: false,
- browser_use_external: false,
- computer_use: false,
- image_generation: false,
- in_app_browser: false,
- multi_agent: true,
- request_permissions_tool: false,
- tool_suggest: false,
+ const started = await server.request(
+ "thread/start",
+ {
+ model: DEFAULT_CODEX_MODEL_ID,
+ cwd: jail,
+ approvalPolicy: "never",
+ sandbox: "read-only",
+ ephemeral: true,
+ baseInstructions: "be concise",
+ developerInstructions: "use the supplied dynamic tools",
+ dynamicTools: [
+ {
+ type: "function",
+ name: "execute",
+ description: "run a command",
+ inputSchema: { type: "object", properties: {} },
+ },
+ ],
+ experimentalRawEvents: true,
+ environments: [],
+ config: {
+ web_search: "disabled",
+ features: {
+ shell_tool: false,
+ unified_exec: false,
+ shell_snapshot: false,
+ apps: false,
+ plugins: false,
+ browser_use: false,
+ browser_use_external: false,
+ computer_use: false,
+ image_generation: false,
+ in_app_browser: false,
+ multi_agent: true,
+ request_permissions_tool: false,
+ tool_suggest: false,
+ },
},
},
- });
+ (value: unknown): value is { thread: { id: string } } => {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
+ const thread = (value as Record).thread;
+ return Boolean(
+ thread &&
+ typeof thread === "object" &&
+ !Array.isArray(thread) &&
+ typeof (thread as Record).id === "string",
+ );
+ },
+ );
assert.ok(started.thread.id, "the real app-server returned a thread id for our start shape");
await server.request("thread/inject_items", {
threadId: started.thread.id,
diff --git a/test/dev-cli-lib.test.ts b/test/dev-cli-lib.test.ts
index 3b0dd58b1..8b5271c49 100644
--- a/test/dev-cli-lib.test.ts
+++ b/test/dev-cli-lib.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { envSha, formatAge, readEnvFile } from "../scripts/dev/lib/util.ts";
@@ -30,7 +30,7 @@ import {
} from "../scripts/dev/lib/lease.ts";
import { assembleEnv, completeDevSecuritySecrets } from "../scripts/dev/lib/envctx.ts";
import { buildChildSpecs, type SpecInputs } from "../scripts/dev/supervisor/specs.ts";
-import { loadConfig, OPENCODE_RUNTIME_VERSION } from "../src/config.ts";
+import { loadConfig, OPENCODE_RUNTIME_VERSION, providerKeysPresent } from "../src/config.ts";
import type { LeaseInfo } from "../scripts/dev/lib/types.ts";
function tmpStore(): string {
@@ -39,6 +39,13 @@ function tmpStore(): string {
return store;
}
+function oauthIdToken(accountId: string): string {
+ const payload = Buffer.from(
+ JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }),
+ ).toString("base64url");
+ return `header.${payload}.signature`;
+}
+
function addSlot(store: string, n: number, extra = ""): void {
writeFileSync(
join(store, `pool${n}.env`),
@@ -214,9 +221,39 @@ test("env assembly precedence: caller > login shell > dev.env > worktree .env; h
assert.equal(openCode.env.PI_CAPTURE_REQUESTS, undefined);
await assert.rejects(
- assembleEnv({ worktree, callerEnv: { HARNESS: "codex" }, allowMock: false, log, probeLoginShell: async () => "" }),
+ assembleEnv({
+ worktree,
+ callerEnv: { HARNESS: "codex", CODEX_HOME: join(worktree, "empty-codex") },
+ allowMock: false,
+ log,
+ probeLoginShell: async () => "",
+ }),
/HARNESS=codex needs OPENAI_API_KEY/,
);
+ const oauthAuthFile = join(worktree, "codex-auth.json");
+ writeFileSync(
+ oauthAuthFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "access",
+ refresh_token: "refresh",
+ account_id: "account",
+ id_token: oauthIdToken("account"),
+ },
+ }),
+ );
+ chmodSync(oauthAuthFile, 0o600);
+ const codexOAuth = await assembleEnv({
+ worktree,
+ callerEnv: { HARNESS: "codex", CODEX_AUTH_FILE: oauthAuthFile },
+ allowMock: false,
+ log,
+ probeLoginShell: async () => "",
+ });
+ assert.equal(codexOAuth.harness, "codex");
+ assert.equal(codexOAuth.env.CODEX_AUTH_FILE, oauthAuthFile);
+ assert.equal(codexOAuth.codexAuthSource, oauthAuthFile);
const codex = await assembleEnv({
worktree,
callerEnv: { HARNESS: "codex", OPENAI_API_KEY: "sk-openai" },
@@ -315,6 +352,31 @@ test("OpenCode config is strict, pinned, and inherits the Pi model", () => {
"claude-opus-4-8",
);
assert.equal(loadConfig({ HARNESS: "claude", CLAUDE_BIN: "/bin/claude" }).claudeBinPath, "/bin/claude");
+ const source = mkdtempSync(join(tmpdir(), "qm-codex-config-"));
+ const authFile = join(source, "auth.json");
+ writeFileSync(
+ authFile,
+ JSON.stringify({
+ auth_mode: "chatgpt",
+ tokens: {
+ access_token: "access",
+ refresh_token: "refresh",
+ account_id: "account",
+ id_token: oauthIdToken("account"),
+ },
+ }),
+ );
+ chmodSync(authFile, 0o600);
+ const oauthConfig = loadConfig({ HARNESS: "codex", CODEX_AUTH_FILE: authFile });
+ assert.equal(oauthConfig.codexAuthFile, authFile);
+ assert.equal(providerKeysPresent(oauthConfig).openai, false);
+ assert.equal(providerKeysPresent(oauthConfig).codexOAuth, true);
+ assert.throws(
+ () =>
+ loadConfig({ HARNESS: "codex", CODEX_AUTH_FILE: join(source, "missing.json"), OPENAI_API_KEY: "placeholder" }),
+ /OPENAI_API_KEY/,
+ );
+ rmSync(source, { recursive: true, force: true });
assert.throws(() => loadConfig({ HARNESS: "bogus" }), /use mock, pi, opencode, codex, or claude/);
assert.throws(() => loadConfig({ HARNESS: "PI" }), /use mock, pi, opencode, codex, or claude/);
});
@@ -349,7 +411,12 @@ test("supervised children share the selected dev org", () => {
const inputs: SpecInputs = {
worktree: "/tmp/worktree",
ports: slotPorts("pool1"),
- baseEnv: { DEV_INSTANCE_ORG_ID: "beta" },
+ baseEnv: {
+ DEV_INSTANCE_ORG_ID: "beta",
+ CODEX_AUTH_FILE: "/tmp/codex-auth.json",
+ HOME: "/tmp/home",
+ CODEX_HOME: "/tmp/home/.codex",
+ },
watch: false,
webUiBasePath: "/",
slack: { botToken: "xoxb-test", appToken: "xapp-test" },
@@ -364,6 +431,12 @@ test("supervised children share the selected dev org", () => {
};
const specs = buildChildSpecs(inputs);
assert.equal(specs.find((spec) => spec.name === "core")!.env.ORG_ID, "beta");
+ assert.equal(specs.find((spec) => spec.name === "core")!.env.CODEX_AUTH_FILE, "/tmp/codex-auth.json");
+ for (const spec of specs.filter((spec) => spec.name !== "core")) {
+ assert.equal(spec.env.CODEX_AUTH_FILE, "");
+ assert.equal(spec.env.HOME, undefined);
+ assert.equal(spec.env.CODEX_HOME, undefined);
+ }
for (const spec of specs) assert.equal(spec.env.CORE_ORG_ID, "beta");
inputs.baseEnv = {};
assert.equal(buildChildSpecs(inputs).find((spec) => spec.name === "core")!.env.ORG_ID, "acme");
diff --git a/test/model-credential-route.test.ts b/test/model-credential-route.test.ts
index a23343525..483f844b1 100644
--- a/test/model-credential-route.test.ts
+++ b/test/model-credential-route.test.ts
@@ -8,6 +8,7 @@ import { join } from "node:path";
import { test } from "node:test";
import { createInsecureTestServer } from "../src/api/server.ts";
import { buildApp, type BuiltApp } from "../src/wiring.ts";
+import { providerKeysPresent, harnessCarriedModelAuth } from "../src/config.ts";
import { testConfig } from "./support/test-config.ts";
import { createModelCredentialStore, type StoredModelCredential } from "../src/model/model-credential-store.ts";
import { createMemoryMap } from "../src/persistence/durable-map.ts";
@@ -23,23 +24,18 @@ function start(
built: BuiltApp;
close: () => Promise;
} {
- const built = buildApp(
- testConfig({
- dataDir: mkdtempSync(join(tmpdir(), "model-credential-route-")),
- ...config,
- }),
- { modelCredentialFetch },
- );
+ const appConfig = testConfig({
+ dataDir: mkdtempSync(join(tmpdir(), "model-credential-route-")),
+ ...config,
+ });
+ const built = buildApp(appConfig, { modelCredentialFetch });
const server = createInsecureTestServer(built.app, {
config: built.config,
modelCredentials: built.modelCredentials,
modelCredentialFetch,
harnessId: config.harness ?? "pi",
- providerKeys: {
- anthropic: Boolean(config.anthropicApiKey),
- openai: Boolean(config.openaiApiKey),
- openrouter: Boolean(config.openrouterApiKey),
- },
+ ...(harnessCarriedModelAuth(appConfig) ? { harnessCarriedModelAuth: harnessCarriedModelAuth(appConfig) } : {}),
+ providerKeys: providerKeysPresent(appConfig),
admin: built.admin,
auditLog: built.auditLog,
});
@@ -421,6 +417,75 @@ test("surface-config reports whether any model provider is configured", async ()
}
});
+test("surface-config respects an admin-disabled environment provider", async () => {
+ const srv = start({ anthropicApiKey: "deployment-anthropic-key" });
+ try {
+ const before = await fetch(`${srv.base}/v1/surface-config`);
+ assert.equal(((await before.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true);
+ const disabled = await fetch(`${srv.base}/v1/admin/model-providers/anthropic`, {
+ method: "DELETE",
+ headers: ADMIN,
+ });
+ assert.equal(disabled.status, 200);
+ const after = await fetch(`${srv.base}/v1/surface-config`);
+ assert.equal(((await after.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, false);
+ } finally {
+ await srv.close();
+ }
+});
+
+test("surface-config reports Codex ChatGPT OAuth without making it a Pi credential", async () => {
+ const srv = start({ harness: "codex", codexAuthFile: "/tmp/codex-auth.json" });
+ try {
+ const surface = await fetch(`${srv.base}/v1/surface-config`);
+ assert.equal(surface.status, 200);
+ assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true);
+ } finally {
+ await srv.close();
+ }
+});
+
+test("a claude harness with an OAuth token counts as configured without corrupting store statuses", async () => {
+ const srv = start({
+ harness: "claude",
+ claudeProcessEnv: { CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-test" } as NodeJS.ProcessEnv,
+ });
+ try {
+ const surface = await fetch(`${srv.base}/v1/surface-config`);
+ assert.equal(surface.status, 200);
+ assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true);
+
+ const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN });
+ assert.equal(providers.status, 200);
+ const body = (await providers.json()) as {
+ providers: Array<{ provider: string; configured: boolean; source: string }>;
+ harnessAuth?: { harnessId: string; provider: string };
+ };
+ assert.deepEqual(body.harnessAuth, { harnessId: "claude", provider: "anthropic" });
+ assert.deepEqual(
+ body.providers.find((item) => item.provider === "anthropic"),
+ { provider: "anthropic", configured: false, source: "absent" },
+ );
+ } finally {
+ await srv.close();
+ }
+});
+
+test("a claude harness without any token stays unconfigured", async () => {
+ const srv = start({ harness: "claude" });
+ try {
+ const surface = await fetch(`${srv.base}/v1/surface-config`);
+ assert.equal(surface.status, 200);
+ assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, false);
+
+ const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN });
+ assert.equal(providers.status, 200);
+ assert.equal("harnessAuth" in ((await providers.json()) as Record), false);
+ } finally {
+ await srv.close();
+ }
+});
+
test("admin model credentials survive a second app instance on the same durable store", async () => {
const backing = createMemoryMap();
const first = createModelCredentialStore({ backing, keyMaterial: "shared-model-key" });