@claudexor/harness-codex 1.0.0 → 2.1.2

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 CHANGED
@@ -1,14 +1,18 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
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
3
  import { resolveSecret } from "@claudexor/secrets";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { ConformanceReport as ConformanceReportSchema, HarnessManifest as HarnessManifestSchema, } from "@claudexor/schema";
7
+ import { abortSignalFromSpec, browserMcpCommand, HarnessUnavailableError, normalizeEffort, providerScrubEnv, resolveHarnessBinary, runCapture, runCliHarness, selectStrictAuthRoute, selectedAuthAvailable, selectedAuthReady, shouldVerifyApiKey, } from "@claudexor/core";
8
8
  import { CLAUDEXOR_VERSION, nowIso, redactSecrets } from "@claudexor/util";
9
9
  import { parseCodexEvent } from "./parse.js";
10
+ import { probeCodexCredentialProfile, resolveCodexProfileRoute } from "./profile.js";
11
+ import { smokeIsolatedApiKey } from "./smoke.js";
12
+ export { canonicalCodexProfileHome } from "./profile.js";
10
13
  import { estimateCodexCostUsd } from "./pricing.js";
11
- const BIN = process.env.CLAUDEXOR_CODEX_BIN || "codex";
14
+ import { codexImageArgs } from "./attachments.js";
15
+ export const BIN = process.env.CLAUDEXOR_CODEX_BIN || "codex";
12
16
  /**
13
17
  * Ordered (weakest→strongest) reasoning-effort levels codex's
14
18
  * `model_reasoning_effort` config accepts. SINGLE source: the manifest's
@@ -16,90 +20,41 @@ const BIN = process.env.CLAUDEXOR_CODEX_BIN || "codex";
16
20
  * `max` hint clamps to `xhigh` (the ceiling) via the shared normalizer.
17
21
  */
18
22
  const CODEX_EFFORT_LEVELS = ["low", "medium", "high", "xhigh"];
23
+ /** Exported for focused route-policy tests; runtime uses this exact selector. */
24
+ export const selectCodexRunAuthRoute = selectStrictAuthRoute;
25
+ export { CODEX_FILE_AUTH_ARGS, CODEX_FILE_AUTH_OVERRIDE, codexApiKey, codexAuthModeAt, defaultNativeCodexHome, ensureCodexApiAuth, probeLogin, } from "./auth.js";
26
+ import { CODEX_FILE_AUTH_ARGS, codexApiKey, codexAuthModeAt, defaultNativeCodexHome, ensureCodexApiAuth, hasApiKey, probeLogin, } from "./auth.js";
19
27
  /**
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.
28
+ * A TOML basic-string literal for a `-c key=value` override. `developer_instructions`
29
+ * is a documented additive Codex config key (layered as a developer block BEFORE
30
+ * AGENTS.md, not a replacement); passing per-invocation `-c` keeps it isolated
31
+ * to this run (never a shared-config mutation). Instructions may contain quotes
32
+ * and newlines, so they are TOML-escaped.
74
33
  */
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;
34
+ function tomlBasicString(value) {
35
+ // TOML basic-string escapes, built by code point so the SOURCE carries no
36
+ // literal control characters: a backslash and quote are escaped, a literal
37
+ // newline/tab/CR become their escapes (a raw newline is invalid in a basic
38
+ // string), other control chars become \uXXXX, and everything else is literal.
39
+ let out = '"';
40
+ for (const ch of value) {
41
+ const code = ch.charCodeAt(0);
42
+ if (ch === "\\")
43
+ out += "\\\\";
44
+ else if (ch === '"')
45
+ out += '\\"';
46
+ else if (code === 10)
47
+ out += "\\n";
48
+ else if (code === 13)
49
+ out += "\\r";
50
+ else if (code === 9)
51
+ out += "\\t";
52
+ else if (code < 32 || code === 127)
53
+ out += "\\u" + code.toString(16).padStart(4, "0");
54
+ else
55
+ out += ch;
98
56
  }
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"));
57
+ return out + '"';
103
58
  }
104
59
  function sandboxArgs(access) {
105
60
  switch (access) {
@@ -114,83 +69,61 @@ function sandboxArgs(access) {
114
69
  return [];
115
70
  }
116
71
  }
117
- async function detectVersion() {
72
+ async function detectVersion(abortSignal) {
118
73
  try {
119
- const r = await runCapture(BIN, ["--version"], { timeoutMs: 10_000 });
74
+ const r = await runCapture(BIN, ["--version"], {
75
+ timeoutMs: 10_000,
76
+ abortSignal,
77
+ cancelSignal: "SIGTERM",
78
+ cancelKillDelayMs: 0,
79
+ });
120
80
  return r.stdout.trim() || `${BIN} (version unknown)`;
121
81
  }
122
82
  catch {
123
83
  return null;
124
84
  }
125
85
  }
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());
86
+ export function redactCodexDoctorDetail(text) {
87
+ return redactSecrets(text).slice(0, 500);
137
88
  }
138
- function hasScopedCodexAuth(env) {
139
- const home = env?.["CODEX_HOME"];
140
- return Boolean(home && existsSync(join(home, "auth.json")));
89
+ export function codexNativeEnv(base, codexHome) {
90
+ return {
91
+ ...(base ?? {}),
92
+ ...providerScrubEnv(),
93
+ CODEX_HOME: codexHome ?? defaultNativeCodexHome(),
94
+ };
141
95
  }
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)) };
96
+ function codexNativeReadiness(login) {
97
+ if (login.probeError) {
98
+ return {
99
+ source: "native_session",
100
+ availability: "unknown",
101
+ verification: "not_run",
102
+ detail: `login-status probe failed: ${redactCodexDoctorDetail(login.probeError)}`,
103
+ };
169
104
  }
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
- }
105
+ if (login.method === "chatgpt") {
106
+ return {
107
+ source: "native_session",
108
+ availability: "available",
109
+ verification: "passed",
110
+ detail: "vendor status confirmed a native ChatGPT session in the exact run environment",
111
+ };
180
112
  }
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);
113
+ if (login.authed) {
114
+ return {
115
+ source: "native_session",
116
+ availability: "available",
117
+ verification: "failed",
118
+ detail: `Codex is authenticated via ${login.method}, not ChatGPT subscription auth`,
119
+ };
192
120
  }
193
- return out;
121
+ return {
122
+ source: "native_session",
123
+ availability: "unavailable",
124
+ verification: "not_run",
125
+ detail: "native Codex session is not logged in",
126
+ };
194
127
  }
195
128
  /**
196
129
  * Inject the Playwright browser MCP as stateless `-c mcp_servers.browser.*`
@@ -204,11 +137,12 @@ function codexImageArgs(attachments) {
204
137
  export function codexBrowserArgs(browser, externalContextPolicy) {
205
138
  if (!browser || externalContextPolicy === "off")
206
139
  return [];
140
+ const mcp = browserMcpCommand(browser);
207
141
  return [
208
142
  "-c",
209
- `mcp_servers.browser.command=${JSON.stringify(resolveNpxBin())}`,
143
+ `mcp_servers.browser.command=${JSON.stringify(mcp.command)}`,
210
144
  "-c",
211
- `mcp_servers.browser.args=${JSON.stringify(playwrightMcpArgs(browser))}`,
145
+ `mcp_servers.browser.args=${JSON.stringify(mcp.args)}`,
212
146
  "-c",
213
147
  "mcp_servers.browser.startup_timeout_sec=90",
214
148
  "-c",
@@ -248,18 +182,26 @@ export function codexExecArgs(spec, opts = {}) {
248
182
  // effort not tunable -> pass no flag.
249
183
  const effort = normalizeEffort(spec.effort_hint, CODEX_EFFORT_LEVELS);
250
184
  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>.
185
+ const args = [
186
+ "exec",
187
+ "resume",
188
+ spec.resume_session_id,
189
+ "--json",
190
+ ...CODEX_FILE_AUTH_ARGS,
191
+ ...sandboxConfigArgs(spec.access),
192
+ "--skip-git-repo-check",
193
+ ];
253
194
  if (opts.outputSchemaPath)
254
195
  args.push("--output-schema", opts.outputSchemaPath);
255
196
  if (spec.model_hint)
256
197
  args.push("-m", spec.model_hint);
257
198
  if (effort)
258
199
  args.push("-c", `model_reasoning_effort="${effort}"`);
200
+ if (spec.instructions && spec.instructions.trim())
201
+ args.push("-c", `developer_instructions=${tomlBasicString(spec.instructions)}`);
259
202
  args.push(...codexWebArgs(spec.external_context_policy ?? "auto"));
260
203
  // 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.
204
+ // <FILE>...` can't swallow them as image paths.
263
205
  args.push(...codexBrowserArgs(spec.browser, spec.external_context_policy));
264
206
  args.push(...nodeReplArgs);
265
207
  const imageArgs = codexImageArgs(spec.attachments);
@@ -273,14 +215,16 @@ export function codexExecArgs(spec, opts = {}) {
273
215
  args.push(spec.prompt);
274
216
  return args;
275
217
  }
276
- const args = ["exec", "--json", ...sandboxArgs(spec.access), "--skip-git-repo-check"];
277
- // Structured output, LIVE-VERIFIED (0.137): --output-schema <FILE>.
218
+ const args = ["exec", "--json", ...CODEX_FILE_AUTH_ARGS];
219
+ args.push(...sandboxArgs(spec.access), "--skip-git-repo-check");
278
220
  if (opts.outputSchemaPath)
279
221
  args.push("--output-schema", opts.outputSchemaPath);
280
222
  if (spec.model_hint)
281
223
  args.push("-m", spec.model_hint);
282
224
  if (effort)
283
225
  args.push("-c", `model_reasoning_effort="${effort}"`);
226
+ if (spec.instructions && spec.instructions.trim())
227
+ args.push("-c", `developer_instructions=${tomlBasicString(spec.instructions)}`);
284
228
  args.push(...codexWebArgs(spec.external_context_policy ?? "auto"));
285
229
  // ALL `-c` config overrides BEFORE `-i` (variadic) so they can't be eaten as
286
230
  // image paths; then images, then `--`, then the prompt. See resume branch.
@@ -318,18 +262,29 @@ function codexWebArgs(policy) {
318
262
  return ["-c", 'web_search="cached"'];
319
263
  }
320
264
  }
321
- export function createCodexAdapter() {
265
+ export function createCodexAdapter(deps = {}) {
266
+ const runtime = {
267
+ detectVersion,
268
+ probeLogin,
269
+ hasApiKey,
270
+ codexApiKey,
271
+ resolveProfileSecret: (ref) => resolveSecret(ref),
272
+ smokeIsolatedApiKey,
273
+ runCliHarness,
274
+ ...deps,
275
+ };
322
276
  return {
323
277
  id: "codex",
324
278
  async discover() {
325
- const version = await detectVersion();
279
+ const version = await runtime.detectVersion();
326
280
  if (version === null) {
327
281
  throw new HarnessUnavailableError("codex CLI not found on PATH (set CLAUDEXOR_CODEX_BIN to override)");
328
282
  }
329
- const apiKey = hasApiKey();
330
- const authed = await loggedIn();
283
+ const apiKey = runtime.hasApiKey();
284
+ const login = await runtime.probeLogin(BIN, { env: codexNativeEnv() });
285
+ const nativeSessionAvailable = login.method === "chatgpt";
331
286
  const authModes = [
332
- ...(authed ? ["local_session"] : []),
287
+ ...(nativeSessionAvailable ? ["local_session"] : []),
333
288
  ...(apiKey ? ["api_key"] : []),
334
289
  ];
335
290
  return HarnessManifestSchema.parse({
@@ -361,36 +316,48 @@ export function createCodexAdapter() {
361
316
  // Current + still-API-available ids per the vendor Codex models page,
362
317
  // verified against the installed CLI recorded below.
363
318
  known_models: [
319
+ "gpt-5.6",
320
+ "gpt-5.6-sol",
321
+ "gpt-5.6-terra",
322
+ "gpt-5.6-luna",
364
323
  "gpt-5.5",
365
324
  "gpt-5.4",
366
325
  "gpt-5.4-mini",
367
326
  "gpt-5.3-codex-spark",
368
- "gpt-5.3-codex",
369
- "gpt-5.2",
370
327
  ],
371
- known_models_verified_against: "0.137.0",
328
+ known_models_verified_against: "0.144.1",
372
329
  },
373
330
  capability_profile: {
374
331
  auth: {
375
- supported_sources: ["native_session", "api_key_env", "provider_auth_file"],
376
- preferred_source: apiKey ? "provider_auth_file" : authed ? "native_session" : null,
332
+ supported_sources: ["native_session", "provider_auth_file"],
333
+ preferred_source: nativeSessionAvailable
334
+ ? "native_session"
335
+ : apiKey
336
+ ? "provider_auth_file"
337
+ : null,
377
338
  credential_transports: [
378
339
  { source: "native_session", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
379
340
  { source: "provider_auth_file", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
380
- { source: "api_key_env", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
381
341
  ],
382
342
  },
383
343
  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",
344
+ isolation: { supported_containment: ["host_user_context", "env_or_file_injection"] },
345
+ attachment_inputs: [
346
+ {
347
+ kind: "image",
348
+ mime_types: ["image/png", "image/jpeg", "image/gif", "image/webp"],
349
+ max_bytes: 20 * 1024 * 1024,
350
+ max_count: 20,
351
+ transport: "file_path",
352
+ },
353
+ ],
387
354
  },
388
355
  auth_modes: authModes,
389
356
  access_profiles_supported: ["readonly", "workspace_write", "full", "inherit_native"],
390
357
  });
391
358
  },
392
359
  async doctor(_spec) {
393
- const version = await detectVersion();
360
+ const version = await runtime.detectVersion(_spec.abortSignal);
394
361
  if (version === null) {
395
362
  return ConformanceReportSchema.parse({
396
363
  harness_id: "codex",
@@ -399,145 +366,240 @@ export function createCodexAdapter() {
399
366
  reasons: ["codex CLI not found (install Codex or set CLAUDEXOR_CODEX_BIN)"],
400
367
  });
401
368
  }
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"];
369
+ const requestedSource = _spec.authSource;
370
+ const probeNative = requestedSource === undefined || requestedSource === "native_session";
371
+ const probeApi = requestedSource === undefined || requestedSource === "provider_auth_file";
372
+ const login = probeNative
373
+ ? await runtime.probeLogin(BIN, {
374
+ env: codexNativeEnv(_spec.env),
375
+ abortSignal: _spec.abortSignal,
376
+ })
377
+ : { authed: false, method: "logged_out", probeError: null };
378
+ const nativeSource = codexNativeReadiness(login);
379
+ const nativeReady = nativeSource.verification === "passed";
380
+ const apiKey = probeApi && runtime.hasApiKey();
381
+ const preference = requestedSource === "native_session"
382
+ ? "subscription"
383
+ : requestedSource === "provider_auth_file"
384
+ ? "api_key"
385
+ : (_spec.authPreference ?? "auto");
386
+ const shouldSmokeKey = probeApi && shouldVerifyApiKey({ preference, apiKeyAvailable: apiKey, nativeReady });
387
+ const smoke = shouldSmokeKey
388
+ ? await runtime.smokeIsolatedApiKey(_spec.abortSignal)
389
+ : {
390
+ ok: false,
391
+ detail: apiKey
392
+ ? "verification not run for the unselected API-key route"
393
+ : "no API key fallback available",
394
+ };
395
+ const ok = selectedAuthReady({ preference, nativeReady, apiKeyReady: smoke.ok });
396
+ const selectedAvailable = selectedAuthAvailable({
397
+ preference,
398
+ nativeAvailable: nativeSource.availability === "available",
399
+ apiKeyAvailable: apiKey,
400
+ });
401
+ const probeUnknown = preference !== "api_key" && nativeSource.availability === "unknown";
402
+ const allIntents = [
403
+ "plan",
404
+ "spec",
405
+ "implement",
406
+ "repair",
407
+ "create_from_scratch",
408
+ "review",
409
+ "verify",
410
+ "synthesize",
411
+ "explain",
412
+ "audit",
413
+ "orchestrate",
414
+ ];
415
+ const binPath = resolveHarnessBinary(BIN);
416
+ const apiSource = {
417
+ source: "provider_auth_file",
418
+ availability: apiKey ? "available" : "unavailable",
419
+ verification: smoke.ok ? "passed" : shouldSmokeKey ? "failed" : "not_run",
420
+ detail: smoke.detail,
421
+ };
422
+ const authSources = requestedSource === "native_session"
423
+ ? [nativeSource]
424
+ : requestedSource === "provider_auth_file"
425
+ ? [apiSource]
426
+ : requestedSource !== undefined
427
+ ? [
428
+ {
429
+ source: requestedSource,
430
+ availability: "unavailable",
431
+ verification: "not_run",
432
+ detail: `Codex does not support ${requestedSource}`,
433
+ },
434
+ ]
435
+ : [nativeSource, apiSource];
412
436
  return ConformanceReportSchema.parse({
413
437
  harness_id: "codex",
414
- status: ok ? "ok" : authed || apiKey ? "degraded" : "unavailable",
438
+ status: ok ? "ok" : selectedAvailable || probeUnknown ? "degraded" : "unavailable",
415
439
  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 },
440
+ {
441
+ id: "installed",
442
+ status: "pass",
443
+ detail: binPath ? `${version} at ${binPath}` : version,
444
+ },
445
+ ...(probeNative
446
+ ? [
447
+ {
448
+ id: "native_session",
449
+ status: nativeReady ? "pass" : "fail",
450
+ detail: nativeReady
451
+ ? "vendor status confirmed native ChatGPT auth in the exact run environment"
452
+ : login.probeError
453
+ ? `login-status probe failed (NOT an auth verdict): ${redactCodexDoctorDetail(login.probeError)}`
454
+ : login.authed
455
+ ? `logged in via ${login.method}, not ChatGPT subscription auth`
456
+ : "not logged in (run `codex login`)",
457
+ },
458
+ ]
459
+ : []),
460
+ ...(probeApi
461
+ ? [
462
+ {
463
+ id: "stored_key",
464
+ status: apiKey ? "pass" : "fail",
465
+ detail: apiKey
466
+ ? "openai secret/env available (API-key fallback)"
467
+ : "no openai key fallback",
468
+ },
469
+ {
470
+ id: "isolated_api_smoke",
471
+ status: smoke.ok ? "pass" : shouldSmokeKey ? "fail" : "skip",
472
+ detail: smoke.detail,
473
+ },
474
+ ]
475
+ : []),
420
476
  ],
477
+ auth_sources: authSources,
421
478
  enabled_intents: ok ? allIntents : [],
422
479
  disabled_intents: ok ? [] : allIntents,
423
480
  reasons: ok
424
481
  ? []
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)"],
482
+ : preference === "subscription"
483
+ ? [
484
+ login.probeError
485
+ ? `Codex native-session probe failed: ${redactCodexDoctorDetail(login.probeError)}`
486
+ : login.authed
487
+ ? `Codex is authenticated via ${login.method}, not a ChatGPT subscription session`
488
+ : "Codex subscription route is not ready (run `codex login`)",
489
+ ]
490
+ : preference === "api_key"
491
+ ? [
492
+ apiKey
493
+ ? `isolated Codex API-key smoke failed: ${smoke.detail}`
494
+ : "Codex API-key route is not configured",
495
+ ]
496
+ : apiKey
497
+ ? [`isolated Codex API-key smoke failed: ${smoke.detail}`]
498
+ : [
499
+ "not authenticated (run `codex login` for native/subscription use, or store an openai API key fallback)",
500
+ ],
428
501
  });
429
502
  },
430
503
  run(spec) {
431
- return runCodex(spec);
504
+ return runCodex(spec, runtime);
432
505
  },
433
506
  review(spec) {
434
- return runCodex(spec);
507
+ return runCodex(spec, runtime);
508
+ },
509
+ probeCredentialProfile(profile, abortSignal) {
510
+ return probeCodexCredentialProfile(profile, runtime, abortSignal);
435
511
  },
436
512
  };
437
513
  }
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 = () => {
514
+ async function* runCodex(spec, runtime) {
515
+ const profile = spec.credential_profile;
516
+ const authPreference = spec.auth_preference ?? "auto";
517
+ let nativeEnv = codexNativeEnv(spec.env);
518
+ let key;
519
+ let tempCodexHome = null;
520
+ let authRoute;
521
+ if (profile) {
522
+ const resolved = await resolveCodexProfileRoute(profile, spec.env, runtime, abortSignalFromSpec(spec));
523
+ if (resolved.refusal !== null) {
524
+ yield { type: "error", session_id: spec.session_id, ts: nowIso(), error: resolved.refusal };
525
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
526
+ return;
527
+ }
528
+ ({ nativeEnv, tempCodexHome } = resolved);
529
+ key = resolved.key ?? undefined;
530
+ authRoute = resolved.route;
531
+ }
532
+ else {
533
+ const nativeLogin = authPreference === "api_key"
534
+ ? { authed: false, method: "logged_out", probeError: null }
535
+ : await runtime.probeLogin(BIN, {
536
+ env: nativeEnv,
537
+ abortSignal: abortSignalFromSpec(spec),
538
+ });
539
+ const nativeSessionReady = nativeLogin.method === "chatgpt" && nativeLogin.probeError === null;
540
+ const trySubscription = () => nativeSessionReady;
541
+ const tryApiKey = () => {
542
+ key ??= runtime.codexApiKey();
458
543
  if (!key)
459
544
  return false;
460
- ensureCodexApiAuth(spec.env, true);
461
- const ok = hasScopedCodexAuth(spec.env);
462
- if (ok)
463
- authRoute = "api_key";
464
- return ok;
545
+ tempCodexHome = mkdtempSync(join(tmpdir(), "claudexor-codex-auth-"));
546
+ ensureCodexApiAuth({ CODEX_HOME: tempCodexHome });
547
+ if (codexAuthModeAt(tempCodexHome) === "api_key")
548
+ return true;
549
+ rmSync(tempCodexHome, { recursive: true, force: true });
550
+ tempCodexHome = null;
551
+ return false;
465
552
  };
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
- };
553
+ authRoute = selectCodexRunAuthRoute(authPreference, trySubscription, tryApiKey);
554
+ if (authRoute === null) {
555
+ const error = authPreference === "subscription"
556
+ ? "Codex subscription auth was explicitly requested but vendor status did not confirm a native ChatGPT session (run `codex login`)"
557
+ : authPreference === "api_key"
558
+ ? "Codex API-key auth was explicitly requested but no usable OpenAI API key route is ready"
559
+ : "no usable Codex auth: native ChatGPT session is not ready and no OpenAI API key fallback is ready";
560
+ yield { type: "error", session_id: spec.session_id, ts: nowIso(), error };
474
561
  yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
475
562
  return;
476
563
  }
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
564
  }
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) {
565
+ // Auto is subscription-first. Selecting its API-key fallback is a paid-route
566
+ // switch and must remain typed/visible; explicit routes never fall back.
567
+ if (!profile && authPreference === "auto" && authRoute === "api_key") {
520
568
  yield {
521
569
  type: "message",
522
570
  session_id: spec.session_id,
523
571
  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" },
572
+ text: "[auth] native subscription route unavailable; auto selected api_key",
573
+ payload: {
574
+ auth_switched: true,
575
+ from_auth_mode: "local_session",
576
+ to_auth_mode: "api_key",
577
+ reason: "readiness_preferred",
578
+ },
526
579
  };
527
580
  }
581
+ // Native auth uses the vendor-owned CODEX_HOME (which may resolve credentials
582
+ // through a config file or OS keychain); API auth uses an isolated generated
583
+ // auth file. Neither route inherits provider env credentials or redirects.
584
+ const env = authRoute === "subscription" ? nativeEnv : { ...spec.env, ...providerScrubEnv() };
585
+ // Non-envelope API-key routes use a private CODEX_HOME because Codex ignores
586
+ // OPENAI_API_KEY against its normal auth store. `tryApiKey` created and
587
+ // verified this file before route selection returned api_key.
588
+ if (authRoute === "api_key" && tempCodexHome) {
589
+ env["CODEX_HOME"] = tempCodexHome;
590
+ }
528
591
  // Disable Codex.app's headless-incompatible node_repl MCP, but ONLY when the
529
592
  // config codex will actually load (the resolved CODEX_HOME, else ~/.codex)
530
593
  // already defines it — never create a transport-less partial entry on a scoped
531
594
  // 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.
595
+ // Structured output: codex takes a FILE path. API routes may use their
596
+ // isolated CODEX_HOME; native routes must never write helper files into the
597
+ // vendor-owned native home, so they use a private temp directory.
536
598
  let outputSchemaPath = null;
537
599
  let tempSchemaDir = null;
538
600
  if (spec.output_schema !== undefined && spec.output_schema !== null) {
539
601
  try {
540
- let dir = env["CODEX_HOME"];
602
+ let dir = authRoute === "subscription" ? undefined : env["CODEX_HOME"];
541
603
  if (!dir) {
542
604
  tempSchemaDir = mkdtempSync(join(tmpdir(), "claudexor-codex-schema-"));
543
605
  dir = tempSchemaDir;
@@ -545,11 +607,22 @@ async function* runCodex(spec) {
545
607
  outputSchemaPath = join(dir, `claudexor-output-schema-${spec.session_id}.json`);
546
608
  writeFileSync(outputSchemaPath, JSON.stringify(spec.output_schema));
547
609
  }
548
- catch {
549
- outputSchemaPath = null; // fail-open to fenced-JSON parsing
610
+ catch (err) {
611
+ // FAIL-CLOSED (Quiz-6a): output_schema is a contract — running the
612
+ // child UNCONSTRAINED because a local schema file failed to write would
613
+ // silently drop it. Fail loudly; the caller retries or reroutes.
614
+ throw new Error(`codex output-schema file could not be written (${err instanceof Error ? err.message : String(err)}); refusing to run unconstrained`);
550
615
  }
551
616
  }
552
- const args = codexExecArgs(spec, { suppressNodeRepl: codexConfigHasNodeRepl(env["CODEX_HOME"]), outputSchemaPath });
617
+ const args = codexExecArgs(spec, {
618
+ suppressNodeRepl: codexConfigHasNodeRepl(env["CODEX_HOME"]),
619
+ outputSchemaPath,
620
+ });
621
+ // Route evidence: the auth mode this child ACTUALLY runs under, read from
622
+ // the same auth.json codex loads (typed `auth_mode` field — chatgpt vs
623
+ // apikey). Disclosed on the started event; quota attribution consumes it.
624
+ const credentialRoute = authRoute === "subscription" ? "vendor_native" : "managed_api_key";
625
+ const credentialSource = authRoute === "subscription" ? "native_session" : "api_key_env";
553
626
  // Codex reports tokens but no $cost; estimate it from the (hint/configured)
554
627
  // model so the budget ledger does not see every codex run as free.
555
628
  const model = spec.model_hint ?? process.env.CLAUDEXOR_CODEX_MODEL ?? null;
@@ -557,8 +630,9 @@ async function* runCodex(spec) {
557
630
  // codex recorded in its own rollout transcript; cache that one read.
558
631
  let codexThreadId;
559
632
  let transcriptModel;
633
+ const parseState = {}; // per-run finality (see CodexParseState)
560
634
  try {
561
- yield* runCliHarness({
635
+ yield* runtime.runCliHarness({
562
636
  bin: BIN,
563
637
  args,
564
638
  spec,
@@ -570,7 +644,7 @@ async function* runCodex(spec) {
570
644
  const raw = obj;
571
645
  if (raw?.type === "thread.started" && typeof raw.thread_id === "string")
572
646
  codexThreadId = raw.thread_id;
573
- const out = parseCodexEvent(obj, sessionId);
647
+ const out = parseCodexEvent(obj, sessionId, parseState);
574
648
  if (out === null)
575
649
  return null;
576
650
  for (const ev of out) {
@@ -578,14 +652,24 @@ async function* runCodex(spec) {
578
652
  // exists to catch silent fallback, so an unobserved model must stay
579
653
  // unobserved. Record the requested model for diagnostics only.
580
654
  if (ev.type === "started" && spec.model_hint && !ev.observed_model) {
581
- ev.payload = { ...(ev.payload ?? {}), requested_model: spec.model_hint, observed_model_source: "unobserved" };
655
+ ev.payload = {
656
+ ...(ev.payload ?? {}),
657
+ requested_model: spec.model_hint,
658
+ observed_model_source: "unobserved",
659
+ };
582
660
  }
661
+ // The route is fixed before spawn; attach it to every event so a
662
+ // later usage/quota record remains independently attributable.
663
+ ev.credential_route = credentialRoute;
664
+ ev.credential_source = credentialSource;
665
+ if (profile)
666
+ ev.credential_profile_id = profile.profile_id;
583
667
  // codex's --json stream never carries the model, but the CLI
584
668
  // records it in its own session rollout. Try to recover it as soon as
585
669
  // the rollout's turn_context appears, then attach the transcript-sourced
586
670
  // observation to the next normalized event. This keeps route proof from
587
671
  // depending on reaching the final usage event under slow reviewer runs.
588
- if (!ev.observed_model) {
672
+ if (!ev.observed_model && spec.evidence_policy !== "stream_only") {
589
673
  transcriptModel ??= codexTranscriptModel(env["CODEX_HOME"], codexThreadId) ?? undefined;
590
674
  if (transcriptModel) {
591
675
  ev.observed_model = transcriptModel;
@@ -596,7 +680,10 @@ async function* runCodex(spec) {
596
680
  // deletes on exit, so the native session it created is gone next turn.
597
681
  // Strip its id from the event so it never poisons the thread resume map
598
682
  // (a later `codex exec resume <ghost>` would deterministically fail).
599
- if (ev.type === "started" && tempCodexHome && ev.payload && "native_session_id" in ev.payload) {
683
+ if (ev.type === "started" &&
684
+ tempCodexHome &&
685
+ ev.payload &&
686
+ "native_session_id" in ev.payload) {
600
687
  const { native_session_id: _dropped, ...rest } = ev.payload;
601
688
  ev.payload = { ...rest, resume_disabled: "ephemeral_codex_home" };
602
689
  }
@@ -609,11 +696,17 @@ async function* runCodex(spec) {
609
696
  }
610
697
  // Quota headroom: attach codex's own rate-window record to the usage event
611
698
  // (fresh read per usage — the rollout accretes as the turn ends).
612
- if (ev.type === "usage" && !ev.quota) {
699
+ if (ev.type === "usage" && !ev.quota && spec.evidence_policy !== "stream_only") {
613
700
  const rl = codexTranscriptRateLimits(env["CODEX_HOME"], codexThreadId);
614
701
  if (rl)
615
702
  ev.quota = rl;
616
703
  }
704
+ if (ev.quota && profile && ev.quota.subject_id == null) {
705
+ ev.quota = { ...ev.quota, subject_id: profile.profile_id };
706
+ }
707
+ // A profiled run's quota windows belong to THE PROFILE's account
708
+ // (round-17 #2): the rollout record carries no subject, and an
709
+ // unstamped null would register as the engine-default subject.
617
710
  }
618
711
  return out;
619
712
  },