@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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +641 -0
- package/dist/index.js.map +1 -0
- package/dist/parse.d.ts +13 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +299 -0
- package/dist/parse.js.map +1 -0
- package/dist/pricing.d.ts +28 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +49 -0
- package/dist/pricing.js.map +1 -0
- package/dist/transcript.d.ts +24 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +145 -0
- package/dist/transcript.js.map +1 -0
- package/package.json +43 -0
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
|