@claudexor/harness-codex 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,641 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { codexTranscriptModel, codexTranscriptRateLimits } from "./transcript.js";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ConformanceReport as ConformanceReportSchema, HarnessManifest as HarnessManifestSchema } from "@claudexor/schema";
6
+ import { HarnessUnavailableError, normalizeEffort, playwrightMcpArgs, providerScrubEnv, resolveNpxBin, runCapture, runCliHarness } from "@claudexor/core";
7
+ import { resolveSecret } from "@claudexor/secrets";
8
+ import { CLAUDEXOR_VERSION, nowIso, redactSecrets } from "@claudexor/util";
9
+ import { parseCodexEvent } from "./parse.js";
10
+ import { estimateCodexCostUsd } from "./pricing.js";
11
+ const BIN = process.env.CLAUDEXOR_CODEX_BIN || "codex";
12
+ /**
13
+ * Ordered (weakest→strongest) reasoning-effort levels codex's
14
+ * `model_reasoning_effort` config accepts. SINGLE source: the manifest's
15
+ * `effort_levels` and the run-time normalizer both read this. The cross-harness
16
+ * `max` hint clamps to `xhigh` (the ceiling) via the shared normalizer.
17
+ */
18
+ const CODEX_EFFORT_LEVELS = ["low", "medium", "high", "xhigh"];
19
+ /**
20
+ * Resolve an OpenAI API key for codex from the environment. Claudexor-managed
21
+ * `api_key` auth mirrors the harness's own variable (`OPENAI_API_KEY`); a
22
+ * dedicated `CLAUDEXOR_CODEX_API_KEY` can override it for multi-key setups.
23
+ */
24
+ function codexApiKey() {
25
+ // The hermetic kill switch is honored inside resolveSecret (single owner).
26
+ const stored = resolveSecret("openai");
27
+ return process.env.CLAUDEXOR_CODEX_API_KEY || process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY || stored || undefined;
28
+ }
29
+ /**
30
+ * Seed `api_key` auth into an isolated CODEX_HOME. Codex does not read
31
+ * `OPENAI_API_KEY` from the environment when run against an empty config dir
32
+ * (it requires `auth.json`), so an envelope-scoped CODEX_HOME would otherwise
33
+ * fail with 401 even though a key is available. We write the same file
34
+ * `codex login --with-api-key` produces. No-op when not isolated (use codex's
35
+ * native auth), when no key is available, or when auth already exists.
36
+ */
37
+ export function ensureCodexApiAuth(env, allowApiKey = true) {
38
+ if (!allowApiKey)
39
+ return;
40
+ const home = env?.["CODEX_HOME"];
41
+ if (!home)
42
+ return;
43
+ const apiKey = codexApiKey();
44
+ if (!apiKey)
45
+ return;
46
+ const authPath = join(home, "auth.json");
47
+ if (existsSync(authPath))
48
+ return;
49
+ try {
50
+ mkdirSync(home, { recursive: true });
51
+ writeFileSync(authPath, JSON.stringify({ auth_mode: "apikey", OPENAI_API_KEY: apiKey }) + "\n", { mode: 0o600 });
52
+ }
53
+ catch {
54
+ /* best-effort: codex will surface an auth error if this did not take */
55
+ }
56
+ }
57
+ /** The user's real codex home (native ChatGPT/subscription session lives here). */
58
+ export function defaultNativeCodexHome() {
59
+ const override = process.env.CLAUDEXOR_CODEX_NATIVE_HOME;
60
+ if (override && override.trim())
61
+ return override;
62
+ return join(homedir(), ".codex");
63
+ }
64
+ /**
65
+ * Seed the user's NATIVE codex session (`auth.json`, ChatGPT/subscription mode)
66
+ * into an isolated CODEX_HOME so a Max/Pro subscriber with NO API key can run
67
+ * inside a Claudexor envelope. This is the "subscription-first must actually
68
+ * work" fix: previously the scoped empty CODEX_HOME hid the native session and
69
+ * the run failed demanding an API key.
70
+ *
71
+ * Copies ONLY if the scoped auth is absent and a native `auth.json` exists; never
72
+ * overwrites (codex refreshes the token in place). Returns true when scoped auth
73
+ * is present afterwards. No-op when not isolated or no native session exists.
74
+ */
75
+ export function ensureCodexNativeAuth(env, nativeHome = defaultNativeCodexHome()) {
76
+ const home = env?.["CODEX_HOME"];
77
+ if (!home)
78
+ return false;
79
+ const dest = join(home, "auth.json");
80
+ if (existsSync(dest))
81
+ return true; // already seeded (api or native)
82
+ const src = join(nativeHome, "auth.json");
83
+ if (!existsSync(src))
84
+ return false;
85
+ try {
86
+ mkdirSync(home, { recursive: true });
87
+ copyFileSync(src, dest);
88
+ try {
89
+ chmodSync(dest, 0o600);
90
+ }
91
+ catch {
92
+ /* best-effort: perms */
93
+ }
94
+ return existsSync(dest);
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /** True when a native codex session exists and can be seeded into an envelope. */
101
+ function nativeCodexSeedable() {
102
+ return existsSync(join(defaultNativeCodexHome(), "auth.json"));
103
+ }
104
+ function sandboxArgs(access) {
105
+ switch (access) {
106
+ case "readonly":
107
+ return ["--sandbox", "read-only"];
108
+ case "workspace_write":
109
+ return ["--sandbox", "workspace-write"];
110
+ case "full":
111
+ case "external_sandbox_full":
112
+ return ["--sandbox", "danger-full-access"];
113
+ case "inherit_native":
114
+ return [];
115
+ }
116
+ }
117
+ async function detectVersion() {
118
+ try {
119
+ const r = await runCapture(BIN, ["--version"], { timeoutMs: 10_000 });
120
+ return r.stdout.trim() || `${BIN} (version unknown)`;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ async function loggedIn() {
127
+ try {
128
+ const r = await runCapture(BIN, ["login", "status"], { timeoutMs: 10_000 });
129
+ return r.code === 0;
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ function hasApiKey() {
136
+ return Boolean(codexApiKey());
137
+ }
138
+ function hasScopedCodexAuth(env) {
139
+ const home = env?.["CODEX_HOME"];
140
+ return Boolean(home && existsSync(join(home, "auth.json")));
141
+ }
142
+ async function smokeIsolatedApiKey() {
143
+ if (!codexApiKey())
144
+ return { ok: false, detail: "no API key fallback available" };
145
+ const dir = mkdtempSync(join(tmpdir(), "claudexor-codex-smoke-"));
146
+ const codexHome = join(dir, ".codex");
147
+ try {
148
+ ensureCodexApiAuth({ CODEX_HOME: codexHome });
149
+ const r = await runCapture(BIN, ["exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "Reply exactly OK"], {
150
+ cwd: dir,
151
+ env: {
152
+ HOME: dir,
153
+ XDG_CONFIG_HOME: join(dir, ".config"),
154
+ CODEX_HOME: codexHome,
155
+ OPENAI_API_KEY: null,
156
+ CODEX_API_KEY: null,
157
+ CLAUDEXOR_CODEX_API_KEY: null,
158
+ },
159
+ timeoutMs: 25_000,
160
+ });
161
+ const text = `${r.stdout}\n${r.stderr}`;
162
+ if (r.code === 0 && text.includes("\"turn.completed\"") && text.includes("OK")) {
163
+ return { ok: true, detail: "isolated CODEX_HOME smoke passed" };
164
+ }
165
+ return { ok: false, detail: redactCodexDoctorDetail(text || `codex exited with code ${r.code}`) };
166
+ }
167
+ catch (err) {
168
+ return { ok: false, detail: redactCodexDoctorDetail(err instanceof Error ? err.message : String(err)) };
169
+ }
170
+ finally {
171
+ // codex can still be flushing session files into CODEX_HOME when the smoke
172
+ // returns. Cleanup is best-effort: a leaked OS tmp dir must never decide
173
+ // doctor/readiness truth (the smoke verdict is the codex run itself).
174
+ try {
175
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
176
+ }
177
+ catch {
178
+ /* OS tmp reaper owns the leftovers */
179
+ }
180
+ }
181
+ }
182
+ function redactCodexDoctorDetail(text) {
183
+ return redactSecrets(text).slice(0, 500);
184
+ }
185
+ /** Codex forwards images via `-i/--image <FILE>` (repeatable; file path only —
186
+ * it rejects remote URLs). Non-image attachments have no native codex surface. */
187
+ function codexImageArgs(attachments) {
188
+ const out = [];
189
+ for (const a of attachments ?? []) {
190
+ if (a.kind === "image")
191
+ out.push("-i", a.path);
192
+ }
193
+ return out;
194
+ }
195
+ /**
196
+ * Inject the Playwright browser MCP as stateless `-c mcp_servers.browser.*`
197
+ * config overrides (live-verified: codex accepts array-valued `-c` overrides and
198
+ * surfaces the tools as `mcp_tool_call` events the parser already maps). Stateless
199
+ * means NO scoped config.toml write — the user's `~/.codex/config.toml` is never
200
+ * touched. Empty when no browser this run, and ALWAYS empty under
201
+ * `external_context_policy: off` (adapter-level defense-in-depth mirroring the
202
+ * claude adapter; the orchestrator already nulls the browser under off).
203
+ */
204
+ export function codexBrowserArgs(browser, externalContextPolicy) {
205
+ if (!browser || externalContextPolicy === "off")
206
+ return [];
207
+ return [
208
+ "-c",
209
+ `mcp_servers.browser.command=${JSON.stringify(resolveNpxBin())}`,
210
+ "-c",
211
+ `mcp_servers.browser.args=${JSON.stringify(playwrightMcpArgs(browser))}`,
212
+ "-c",
213
+ "mcp_servers.browser.startup_timeout_sec=90",
214
+ "-c",
215
+ "mcp_servers.browser.tool_timeout_sec=120",
216
+ ];
217
+ }
218
+ /**
219
+ * True only when the config codex WILL load (the scoped `CODEX_HOME` if set,
220
+ * else `~/.codex`) actually defines `[mcp_servers.node_repl]`. We only ever
221
+ * disable node_repl when it already exists — a `-c mcp_servers.node_repl.*`
222
+ * override against a config that has NO node_repl creates a partial entry with
223
+ * no transport and codex refuses to load it ("invalid transport in
224
+ * mcp_servers.node_repl"), which broke every scoped-home / api_key / MCP run.
225
+ */
226
+ export function codexConfigHasNodeRepl(codexHome) {
227
+ const cfg = join(codexHome || defaultNativeCodexHome(), "config.toml");
228
+ try {
229
+ return existsSync(cfg) && readFileSync(cfg, "utf8").includes("[mcp_servers.node_repl]");
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ }
235
+ export function codexExecArgs(spec, opts = {}) {
236
+ // Codex.app's inherited `node_repl` MCP (its in-app-browser controller) can't
237
+ // run in headless `codex exec` and fails every call → it used to flip an
238
+ // otherwise-clean run to "errored". Disable it — but ONLY when it is actually
239
+ // present in the loaded config (codexConfigHasNodeRepl), never unconditionally
240
+ // (that is what created the invalid partial entry above).
241
+ const nodeReplArgs = opts.suppressNodeRepl ? ["-c", "mcp_servers.node_repl.enabled=false"] : [];
242
+ // Resume a native codex session as a follow-up turn (`codex exec resume <id>`),
243
+ // so a thread's later moves continue the same conversation instead of restarting.
244
+ // LIVE-VERIFIED (codex 0.137): the resume subcommand does NOT accept --sandbox;
245
+ // sandboxing must ride as `-c sandbox_mode="..."` config overrides there.
246
+ // Clamp the requested effort onto codex's supported ladder via the shared
247
+ // normalizer (single source: CODEX_EFFORT_LEVELS). Null = not requested OR
248
+ // effort not tunable -> pass no flag.
249
+ const effort = normalizeEffort(spec.effort_hint, CODEX_EFFORT_LEVELS);
250
+ if (spec.resume_session_id) {
251
+ const args = ["exec", "resume", spec.resume_session_id, "--json", ...sandboxConfigArgs(spec.access), "--skip-git-repo-check"];
252
+ // Structured output, LIVE-VERIFIED (0.137): --output-schema <FILE>.
253
+ if (opts.outputSchemaPath)
254
+ args.push("--output-schema", opts.outputSchemaPath);
255
+ if (spec.model_hint)
256
+ args.push("-m", spec.model_hint);
257
+ if (effort)
258
+ args.push("-c", `model_reasoning_effort="${effort}"`);
259
+ args.push(...codexWebArgs(spec.external_context_policy ?? "auto"));
260
+ // ALL `-c` config overrides go BEFORE `-i` so the variadic `-i/--image
261
+ // <FILE>...` can't swallow them as image paths; then images, then `--` so the
262
+ // positional prompt survives, then the prompt.
263
+ args.push(...codexBrowserArgs(spec.browser, spec.external_context_policy));
264
+ args.push(...nodeReplArgs);
265
+ const imageArgs = codexImageArgs(spec.attachments);
266
+ args.push(...imageArgs);
267
+ // `codex exec -i/--image <FILE>...` is VARIADIC, so a positional prompt placed
268
+ // right after it is swallowed as another "image" — the model then receives no
269
+ // prompt and never sees the attachment (the v0.13 "I don't see the image" bug).
270
+ // LIVE-VERIFIED on codex 0.142: `-i <path> -- "<prompt>"` => image IS described.
271
+ if (imageArgs.length > 0)
272
+ args.push("--");
273
+ args.push(spec.prompt);
274
+ return args;
275
+ }
276
+ const args = ["exec", "--json", ...sandboxArgs(spec.access), "--skip-git-repo-check"];
277
+ // Structured output, LIVE-VERIFIED (0.137): --output-schema <FILE>.
278
+ if (opts.outputSchemaPath)
279
+ args.push("--output-schema", opts.outputSchemaPath);
280
+ if (spec.model_hint)
281
+ args.push("-m", spec.model_hint);
282
+ if (effort)
283
+ args.push("-c", `model_reasoning_effort="${effort}"`);
284
+ args.push(...codexWebArgs(spec.external_context_policy ?? "auto"));
285
+ // ALL `-c` config overrides BEFORE `-i` (variadic) so they can't be eaten as
286
+ // image paths; then images, then `--`, then the prompt. See resume branch.
287
+ args.push(...codexBrowserArgs(spec.browser, spec.external_context_policy));
288
+ args.push(...nodeReplArgs);
289
+ const imageArgs = codexImageArgs(spec.attachments);
290
+ args.push(...imageArgs);
291
+ if (imageArgs.length > 0)
292
+ args.push("--");
293
+ args.push(spec.prompt);
294
+ return args;
295
+ }
296
+ /** Sandbox as `-c sandbox_mode=...` config (the only spelling `exec resume` accepts). */
297
+ function sandboxConfigArgs(access) {
298
+ switch (access) {
299
+ case "readonly":
300
+ return ["-c", 'sandbox_mode="read-only"'];
301
+ case "workspace_write":
302
+ return ["-c", 'sandbox_mode="workspace-write"'];
303
+ case "full":
304
+ case "external_sandbox_full":
305
+ return ["-c", 'sandbox_mode="danger-full-access"'];
306
+ case "inherit_native":
307
+ return [];
308
+ }
309
+ }
310
+ function codexWebArgs(policy) {
311
+ switch (policy) {
312
+ case "off":
313
+ return ["-c", 'web_search="disabled"'];
314
+ case "live":
315
+ return ["-c", 'web_search="live"'];
316
+ case "cached":
317
+ case "auto":
318
+ return ["-c", 'web_search="cached"'];
319
+ }
320
+ }
321
+ export function createCodexAdapter() {
322
+ return {
323
+ id: "codex",
324
+ async discover() {
325
+ const version = await detectVersion();
326
+ if (version === null) {
327
+ throw new HarnessUnavailableError("codex CLI not found on PATH (set CLAUDEXOR_CODEX_BIN to override)");
328
+ }
329
+ const apiKey = hasApiKey();
330
+ const authed = await loggedIn();
331
+ const authModes = [
332
+ ...(authed ? ["local_session"] : []),
333
+ ...(apiKey ? ["api_key"] : []),
334
+ ];
335
+ return HarnessManifestSchema.parse({
336
+ id: "codex",
337
+ display_name: "Codex CLI",
338
+ kind: "local_cli",
339
+ version,
340
+ adapter_version: CLAUDEXOR_VERSION,
341
+ provider_family: "openai",
342
+ capabilities: {
343
+ plan: true,
344
+ implement: true,
345
+ create_from_scratch: true,
346
+ review: true,
347
+ verify: true,
348
+ synthesize: true,
349
+ orchestrate: true,
350
+ read_files: true,
351
+ // mcp_servers.browser.*` overrides (live-verified) — gated on web policy.
352
+ browser_tool: true,
353
+ // LIVE-VERIFIED (codex 0.137): `codex exec --output-schema <FILE>`.
354
+ json_schema_output: true,
355
+ web_policy: "native",
356
+ // codex model_reasoning_effort accepts low|medium|high|xhigh (max clamps
357
+ // to xhigh). Single source for the manifest AND the run-time normalizer.
358
+ effort_levels: [...CODEX_EFFORT_LEVELS],
359
+ // Manifest model truth source (strict model-truth validation: an explicit model outside
360
+ // this list is refused, never forwarded to die as a native error).
361
+ // Current + still-API-available ids per the vendor Codex models page,
362
+ // verified against the installed CLI recorded below.
363
+ known_models: [
364
+ "gpt-5.5",
365
+ "gpt-5.4",
366
+ "gpt-5.4-mini",
367
+ "gpt-5.3-codex-spark",
368
+ "gpt-5.3-codex",
369
+ "gpt-5.2",
370
+ ],
371
+ known_models_verified_against: "0.137.0",
372
+ },
373
+ capability_profile: {
374
+ auth: {
375
+ supported_sources: ["native_session", "api_key_env", "provider_auth_file"],
376
+ preferred_source: apiKey ? "provider_auth_file" : authed ? "native_session" : null,
377
+ credential_transports: [
378
+ { source: "native_session", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
379
+ { source: "provider_auth_file", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
380
+ { source: "api_key_env", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
381
+ ],
382
+ },
383
+ access_control: { readonly_mechanism: "fs_sandbox" },
384
+ isolation: { supported_containment: ["env_or_file_injection"] },
385
+ // Codex accepts images via `codex exec -i/--image <FILE>` (file path; remote URLs rejected).
386
+ image_input: "file_path",
387
+ },
388
+ auth_modes: authModes,
389
+ access_profiles_supported: ["readonly", "workspace_write", "full", "inherit_native"],
390
+ });
391
+ },
392
+ async doctor(_spec) {
393
+ const version = await detectVersion();
394
+ if (version === null) {
395
+ return ConformanceReportSchema.parse({
396
+ harness_id: "codex",
397
+ status: "unavailable",
398
+ checks: [{ id: "installed", status: "fail", detail: "codex not found on PATH" }],
399
+ reasons: ["codex CLI not found (install Codex or set CLAUDEXOR_CODEX_BIN)"],
400
+ });
401
+ }
402
+ const apiKey = hasApiKey();
403
+ const authed = await loggedIn();
404
+ // Native session readiness is FIRST-CLASS: a logged-in subscription whose
405
+ // auth.json we can seed into the envelope is `ok` with no paid API smoke.
406
+ // (Bible: a stored key STRING alone is still not proof -> api-key route
407
+ // keeps the isolated smoke.) This is what makes subscription-first real.
408
+ const nativeReady = authed && nativeCodexSeedable();
409
+ const smoke = !nativeReady && apiKey ? await smokeIsolatedApiKey() : { ok: false, detail: nativeReady ? "skipped (native session ready)" : "no API key fallback available" };
410
+ const ok = nativeReady || smoke.ok;
411
+ const allIntents = ["plan", "spec", "implement", "repair", "create_from_scratch", "review", "verify", "synthesize", "explain", "audit", "orchestrate"];
412
+ return ConformanceReportSchema.parse({
413
+ harness_id: "codex",
414
+ status: ok ? "ok" : authed || apiKey ? "degraded" : "unavailable",
415
+ checks: [
416
+ { id: "installed", status: "pass", detail: version },
417
+ { id: "native_session", status: nativeReady ? "pass" : "fail", detail: nativeReady ? "native codex session seedable into envelope" : authed ? "logged in but ~/.codex/auth.json not found" : "not logged in (run `codex login`)" },
418
+ { id: "stored_key", status: apiKey ? "pass" : "fail", detail: apiKey ? "openai secret/env available (api-key fallback)" : "no openai key fallback" },
419
+ { id: "isolated_api_smoke", status: smoke.ok ? "pass" : nativeReady ? "skip" : apiKey ? "fail" : "skip", detail: smoke.detail },
420
+ ],
421
+ enabled_intents: ok ? allIntents : [],
422
+ disabled_intents: ok ? [] : allIntents,
423
+ reasons: ok
424
+ ? []
425
+ : apiKey
426
+ ? [`isolated Codex API-key smoke failed: ${smoke.detail}`]
427
+ : ["not authenticated (run `codex login` for native/subscription use, or store an openai API key fallback)"],
428
+ });
429
+ },
430
+ run(spec) {
431
+ return runCodex(spec);
432
+ },
433
+ review(spec) {
434
+ return runCodex(spec);
435
+ },
436
+ };
437
+ }
438
+ async function* runCodex(spec) {
439
+ const nativeAuthed = await loggedIn();
440
+ const key = codexApiKey();
441
+ const preferApi = spec.auth_preference === "api_key";
442
+ const scopedHome = Boolean(spec.env?.["CODEX_HOME"]);
443
+ const scopedHomeNeedsAuth = scopedHome && !hasScopedCodexAuth(spec.env);
444
+ // Seed credentials into the scoped CODEX_HOME. BOTH auth routes are supported
445
+ // with auto-fallback: `subscription` seeds the native session (auth.json copied
446
+ // from ~/.codex — the fix that makes subscription-first actually work inside an
447
+ // envelope); `api_key` seeds the OpenAI key. Order follows auth_preference, and
448
+ // each falls back to the other so a run is not stranded when one source is gone.
449
+ let authRoute = null;
450
+ if (scopedHomeNeedsAuth) {
451
+ const trySub = () => {
452
+ const ok = nativeAuthed ? ensureCodexNativeAuth(spec.env) : false;
453
+ if (ok)
454
+ authRoute = "subscription";
455
+ return ok;
456
+ };
457
+ const tryKey = () => {
458
+ if (!key)
459
+ return false;
460
+ ensureCodexApiAuth(spec.env, true);
461
+ const ok = hasScopedCodexAuth(spec.env);
462
+ if (ok)
463
+ authRoute = "api_key";
464
+ return ok;
465
+ };
466
+ const seeded = preferApi ? tryKey() || trySub() : trySub() || tryKey();
467
+ if (!seeded) {
468
+ yield {
469
+ type: "error",
470
+ session_id: spec.session_id,
471
+ ts: nowIso(),
472
+ error: "no usable codex auth for this envelope: native session not seedable (run `codex login`) and no OpenAI API key fallback available",
473
+ };
474
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
475
+ return;
476
+ }
477
+ // An EXPLICIT auth preference that could not be honored is disclosed as a
478
+ // typed marker; the orchestrator lifts it into route.fallback.auth_switched.
479
+ const preferred = preferApi ? "api_key" : "subscription";
480
+ if (spec.auth_preference !== "auto" && authRoute && authRoute !== preferred) {
481
+ yield {
482
+ type: "message",
483
+ session_id: spec.session_id,
484
+ ts: nowIso(),
485
+ text: `[auth] ${preferred} route unavailable; fell back to ${authRoute}`,
486
+ payload: { auth_switched: true, from_auth_mode: preferred === "subscription" ? "local_session" : "api_key", to_auth_mode: authRoute === "subscription" ? "local_session" : "api_key" },
487
+ };
488
+ }
489
+ }
490
+ // Codex authenticates from the seeded auth.json (never an env key), so scrub
491
+ // EVERY provider secret + base-URL redirect from the child — including other
492
+ // providers' keys (the cross-provider leak fix), via the single core table.
493
+ const env = {
494
+ ...spec.env,
495
+ ...providerScrubEnv(),
496
+ };
497
+ // Non-envelope run (no scoped CODEX_HOME): an explicit `api_key` preference is
498
+ // HONORED even when natively logged in (a private temp CODEX_HOME seeded with
499
+ // the key; codex ignores OPENAI_API_KEY without an auth.json) — and a missing
500
+ // key falls back to the native session with a typed disclosure. Without a
501
+ // preference, the key route is only the no-native fallback.
502
+ let tempCodexHome = null;
503
+ if (!scopedHome && key && (preferApi || !nativeAuthed)) {
504
+ tempCodexHome = mkdtempSync(join(tmpdir(), "claudexor-codex-auth-"));
505
+ env["CODEX_HOME"] = tempCodexHome;
506
+ ensureCodexApiAuth({ CODEX_HOME: tempCodexHome });
507
+ // Same disclosure as the envelope path: an explicit subscription preference
508
+ // that lands on the billed key route is never silent.
509
+ if (spec.auth_preference === "subscription") {
510
+ yield {
511
+ type: "message",
512
+ session_id: spec.session_id,
513
+ ts: nowIso(),
514
+ text: "[auth] subscription route unavailable (not logged in); fell back to api_key",
515
+ payload: { auth_switched: true, from_auth_mode: "local_session", to_auth_mode: "api_key" },
516
+ };
517
+ }
518
+ }
519
+ else if (!scopedHome && preferApi && !key && nativeAuthed) {
520
+ yield {
521
+ type: "message",
522
+ session_id: spec.session_id,
523
+ ts: nowIso(),
524
+ text: "[auth] api_key route unavailable (no key); fell back to subscription",
525
+ payload: { auth_switched: true, from_auth_mode: "api_key", to_auth_mode: "local_session" },
526
+ };
527
+ }
528
+ // Disable Codex.app's headless-incompatible node_repl MCP, but ONLY when the
529
+ // config codex will actually load (the resolved CODEX_HOME, else ~/.codex)
530
+ // already defines it — never create a transport-less partial entry on a scoped
531
+ // home (that broke codex startup, the "invalid transport" regression).
532
+ // Structured output: codex takes a FILE path; write the schema into the
533
+ // scoped CODEX_HOME (outside the worktree — never lands in a diff). A
534
+ // native-session run has no scoped home, so the schema goes to a private
535
+ // tmp dir instead — the capability must not silently vanish on that route.
536
+ let outputSchemaPath = null;
537
+ let tempSchemaDir = null;
538
+ if (spec.output_schema !== undefined && spec.output_schema !== null) {
539
+ try {
540
+ let dir = env["CODEX_HOME"];
541
+ if (!dir) {
542
+ tempSchemaDir = mkdtempSync(join(tmpdir(), "claudexor-codex-schema-"));
543
+ dir = tempSchemaDir;
544
+ }
545
+ outputSchemaPath = join(dir, `claudexor-output-schema-${spec.session_id}.json`);
546
+ writeFileSync(outputSchemaPath, JSON.stringify(spec.output_schema));
547
+ }
548
+ catch {
549
+ outputSchemaPath = null; // fail-open to fenced-JSON parsing
550
+ }
551
+ }
552
+ const args = codexExecArgs(spec, { suppressNodeRepl: codexConfigHasNodeRepl(env["CODEX_HOME"]), outputSchemaPath });
553
+ // Codex reports tokens but no $cost; estimate it from the (hint/configured)
554
+ // model so the budget ledger does not see every codex run as free.
555
+ const model = spec.model_hint ?? process.env.CLAUDEXOR_CODEX_MODEL ?? null;
556
+ // capture the native thread id (thread.started) so we can read the model
557
+ // codex recorded in its own rollout transcript; cache that one read.
558
+ let codexThreadId;
559
+ let transcriptModel;
560
+ try {
561
+ yield* runCliHarness({
562
+ bin: BIN,
563
+ args,
564
+ spec,
565
+ env,
566
+ label: "codex",
567
+ redact: redactSecrets,
568
+ parseEvent: (obj, sessionId) => {
569
+ // Bind the rollout transcript to THIS run via the native thread id.
570
+ const raw = obj;
571
+ if (raw?.type === "thread.started" && typeof raw.thread_id === "string")
572
+ codexThreadId = raw.thread_id;
573
+ const out = parseCodexEvent(obj, sessionId);
574
+ if (out === null)
575
+ return null;
576
+ for (const ev of out) {
577
+ // Do NOT fabricate observed_model from the request hint: route proof
578
+ // exists to catch silent fallback, so an unobserved model must stay
579
+ // unobserved. Record the requested model for diagnostics only.
580
+ if (ev.type === "started" && spec.model_hint && !ev.observed_model) {
581
+ ev.payload = { ...(ev.payload ?? {}), requested_model: spec.model_hint, observed_model_source: "unobserved" };
582
+ }
583
+ // codex's --json stream never carries the model, but the CLI
584
+ // records it in its own session rollout. Try to recover it as soon as
585
+ // the rollout's turn_context appears, then attach the transcript-sourced
586
+ // observation to the next normalized event. This keeps route proof from
587
+ // depending on reaching the final usage event under slow reviewer runs.
588
+ if (!ev.observed_model) {
589
+ transcriptModel ??= codexTranscriptModel(env["CODEX_HOME"], codexThreadId) ?? undefined;
590
+ if (transcriptModel) {
591
+ ev.observed_model = transcriptModel;
592
+ ev.payload = { ...(ev.payload ?? {}), observed_model_source: "transcript" };
593
+ }
594
+ }
595
+ // an api_key run uses a TEMPORARY CODEX_HOME that this process
596
+ // deletes on exit, so the native session it created is gone next turn.
597
+ // Strip its id from the event so it never poisons the thread resume map
598
+ // (a later `codex exec resume <ghost>` would deterministically fail).
599
+ if (ev.type === "started" && tempCodexHome && ev.payload && "native_session_id" in ev.payload) {
600
+ const { native_session_id: _dropped, ...rest } = ev.payload;
601
+ ev.payload = { ...rest, resume_disabled: "ephemeral_codex_home" };
602
+ }
603
+ if (ev.type === "usage" && ev.usage && ev.usage.cost_usd === undefined) {
604
+ const est = estimateCodexCostUsd(model, ev.usage);
605
+ if (est !== undefined) {
606
+ ev.usage.cost_usd = est;
607
+ ev.usage.estimated = true;
608
+ }
609
+ }
610
+ // Quota headroom: attach codex's own rate-window record to the usage event
611
+ // (fresh read per usage — the rollout accretes as the turn ends).
612
+ if (ev.type === "usage" && !ev.quota) {
613
+ const rl = codexTranscriptRateLimits(env["CODEX_HOME"], codexThreadId);
614
+ if (rl)
615
+ ev.quota = rl;
616
+ }
617
+ }
618
+ return out;
619
+ },
620
+ });
621
+ }
622
+ finally {
623
+ if (tempCodexHome) {
624
+ try {
625
+ rmSync(tempCodexHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
626
+ }
627
+ catch {
628
+ /* best-effort: OS tmp reaper owns the leftovers */
629
+ }
630
+ }
631
+ if (tempSchemaDir) {
632
+ try {
633
+ rmSync(tempSchemaDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
634
+ }
635
+ catch {
636
+ /* best-effort: OS tmp reaper owns the leftovers */
637
+ }
638
+ }
639
+ }
640
+ }
641
+ //# sourceMappingURL=index.js.map