@momentiq/dark-factory-cli 0.1.0-alpha.4
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 +201 -0
- package/README.md +150 -0
- package/dist/adapters/_shared.d.ts +45 -0
- package/dist/adapters/_shared.d.ts.map +1 -0
- package/dist/adapters/_shared.js +200 -0
- package/dist/adapters/_shared.js.map +1 -0
- package/dist/adapters/codex-sdk.d.ts +279 -0
- package/dist/adapters/codex-sdk.d.ts.map +1 -0
- package/dist/adapters/codex-sdk.js +930 -0
- package/dist/adapters/codex-sdk.js.map +1 -0
- package/dist/adapters/critic-result-schema.d.ts +215 -0
- package/dist/adapters/critic-result-schema.d.ts.map +1 -0
- package/dist/adapters/critic-result-schema.js +153 -0
- package/dist/adapters/critic-result-schema.js.map +1 -0
- package/dist/adapters/critic.d.ts +62 -0
- package/dist/adapters/critic.d.ts.map +1 -0
- package/dist/adapters/critic.js +79 -0
- package/dist/adapters/critic.js.map +1 -0
- package/dist/adapters/cursor-sdk.d.ts +153 -0
- package/dist/adapters/cursor-sdk.d.ts.map +1 -0
- package/dist/adapters/cursor-sdk.js +818 -0
- package/dist/adapters/cursor-sdk.js.map +1 -0
- package/dist/adapters/gemini-sdk.d.ts +113 -0
- package/dist/adapters/gemini-sdk.d.ts.map +1 -0
- package/dist/adapters/gemini-sdk.js +532 -0
- package/dist/adapters/gemini-sdk.js.map +1 -0
- package/dist/adapters/grok-direct-sdk.d.ts +148 -0
- package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
- package/dist/adapters/grok-direct-sdk.js +694 -0
- package/dist/adapters/grok-direct-sdk.js.map +1 -0
- package/dist/branch-protection/audit_branch_protection.py +759 -0
- package/dist/branch-protection/index.d.ts +25 -0
- package/dist/branch-protection/index.d.ts.map +1 -0
- package/dist/branch-protection/index.js +70 -0
- package/dist/branch-protection/index.js.map +1 -0
- package/dist/branch-protection/spec-default.yaml +314 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +581 -0
- package/dist/cli.js.map +1 -0
- package/dist/cycle-doc-validator/index.d.ts +43 -0
- package/dist/cycle-doc-validator/index.d.ts.map +1 -0
- package/dist/cycle-doc-validator/index.js +69 -0
- package/dist/cycle-doc-validator/index.js.map +1 -0
- package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
- package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
- package/dist/cycle-tracker-sync/index.d.ts +20 -0
- package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
- package/dist/cycle-tracker-sync/index.js +71 -0
- package/dist/cycle-tracker-sync/index.js.map +1 -0
- package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
- package/dist/evidence/audit-trail.d.ts +59 -0
- package/dist/evidence/audit-trail.d.ts.map +1 -0
- package/dist/evidence/audit-trail.js +283 -0
- package/dist/evidence/audit-trail.js.map +1 -0
- package/dist/evidence/index.d.ts +4 -0
- package/dist/evidence/index.d.ts.map +1 -0
- package/dist/evidence/index.js +29 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/per-sha.d.ts +13 -0
- package/dist/evidence/per-sha.d.ts.map +1 -0
- package/dist/evidence/per-sha.js +97 -0
- package/dist/evidence/per-sha.js.map +1 -0
- package/dist/evidence/quality-gates.d.ts +19 -0
- package/dist/evidence/quality-gates.d.ts.map +1 -0
- package/dist/evidence/quality-gates.js +212 -0
- package/dist/evidence/quality-gates.js.map +1 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +414 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +99 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy/baseline.d.ts +15 -0
- package/dist/policy/baseline.d.ts.map +1 -0
- package/dist/policy/baseline.js +115 -0
- package/dist/policy/baseline.js.map +1 -0
- package/dist/policy/config.d.ts +64 -0
- package/dist/policy/config.d.ts.map +1 -0
- package/dist/policy/config.js +363 -0
- package/dist/policy/config.js.map +1 -0
- package/dist/policy/gate.d.ts +80 -0
- package/dist/policy/gate.d.ts.map +1 -0
- package/dist/policy/gate.js +1019 -0
- package/dist/policy/gate.js.map +1 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +14 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/merge-queue.d.ts +183 -0
- package/dist/policy/merge-queue.d.ts.map +1 -0
- package/dist/policy/merge-queue.js +310 -0
- package/dist/policy/merge-queue.js.map +1 -0
- package/dist/policy/profile.d.ts +98 -0
- package/dist/policy/profile.d.ts.map +1 -0
- package/dist/policy/profile.js +156 -0
- package/dist/policy/profile.js.map +1 -0
- package/dist/policy/tdd-classifier.d.ts +18 -0
- package/dist/policy/tdd-classifier.d.ts.map +1 -0
- package/dist/policy/tdd-classifier.js +79 -0
- package/dist/policy/tdd-classifier.js.map +1 -0
- package/dist/prompt.d.ts +13 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +175 -0
- package/dist/prompt.js.map +1 -0
- package/dist/report.d.ts +88 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +376 -0
- package/dist/report.js.map +1 -0
- package/dist/runner.d.ts +30 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +456 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +19 -0
- package/dist/security.js.map +1 -0
- package/dist/trusted-surface/rebind.d.ts +10 -0
- package/dist/trusted-surface/rebind.d.ts.map +1 -0
- package/dist/trusted-surface/rebind.js +154 -0
- package/dist/trusted-surface/rebind.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
// Cycle 322.7 — OpenAI Codex direct-SDK adapter via `@openai/codex-sdk`.
|
|
2
|
+
//
|
|
3
|
+
// Why a fourth adapter (manifesto §11 + §12): after 322.3 added the Grok
|
|
4
|
+
// critic the gate runs three vendor lineages (Cursor → OpenAI proxy,
|
|
5
|
+
// Gemini → Google, Grok → xAI), but the Cursor-routed GPT-5.5 critic
|
|
6
|
+
// empirically fails through Cursor's proxy capacity layer (Cursor support
|
|
7
|
+
// T-C42979, 2026-05-08 + 2026-05-11). A *direct* OpenAI critic via the
|
|
8
|
+
// Codex SDK closes that gap while exploiting the existing $200 ChatGPT
|
|
9
|
+
// Pro subscription instead of burning OpenAI API tokens per commit.
|
|
10
|
+
//
|
|
11
|
+
// The Codex SDK is a thin TypeScript wrapper around the `@openai/codex`
|
|
12
|
+
// CLI. The SDK spawns the CLI subprocess and exchanges JSONL events:
|
|
13
|
+
// - `~/.codex/auth.json` holds the cached subscription OAuth token
|
|
14
|
+
// (set up via `codex login`); the SDK subprocess inherits it.
|
|
15
|
+
// - `CODEX_API_KEY` env supports API-key auth (the CI cold path).
|
|
16
|
+
// - Critically, the SDK supports BOTH modes — declaring CODEX_API_KEY
|
|
17
|
+
// in `requiredEnvVars` would force Doppler re-exec on every local
|
|
18
|
+
// invocation even when subscription auth is the intended path.
|
|
19
|
+
// Instead the adapter declares `requiredEnvVars = []` and the
|
|
20
|
+
// doctor check validates AT LEAST ONE auth source is configured.
|
|
21
|
+
//
|
|
22
|
+
// The adapter:
|
|
23
|
+
// - implements `CriticAdapter` from `critic.ts` (the post-322.2 shape
|
|
24
|
+
// with `requiredEnvVars`)
|
|
25
|
+
// - uses Codex's `outputSchema` parameter (passes a JSON Schema to the
|
|
26
|
+
// model so the response in `Turn.finalResponse` is schema-validated
|
|
27
|
+
// JSON natively — no `parseAssistantJson` fallback chain needed,
|
|
28
|
+
// though we keep it as defense-in-depth for occasional format drift)
|
|
29
|
+
// - mirrors the 322.1 retry shape (`runRetryLoop` + per-attempt
|
|
30
|
+
// telemetry + permanent vs retryable failure classification) from a
|
|
31
|
+
// single source of truth in `cursor-sdk.ts`, so the policy + budget
|
|
32
|
+
// are byte-identical across adapters
|
|
33
|
+
// - routes diagnostic-redaction + JSON parsing + reviewer-metadata
|
|
34
|
+
// merge + error-result construction through `_shared.ts` so the
|
|
35
|
+
// security boundary cannot drift
|
|
36
|
+
// - is read-only by structure at THREE layers:
|
|
37
|
+
// 1. `sandboxMode: "read-only"` — the Codex CLI sandbox prevents
|
|
38
|
+
// file writes
|
|
39
|
+
// 2. `approvalPolicy: "never"` — no interactive prompts
|
|
40
|
+
// 3. `networkAccessEnabled: false` — the agent cannot exfiltrate
|
|
41
|
+
// diff content to external services
|
|
42
|
+
// Note: even with all three knobs set, the Codex agent CAN execute
|
|
43
|
+
// shell commands locally (read-only sandbox blocks WRITES, not
|
|
44
|
+
// READS — the spike artifact captures one such command_execution).
|
|
45
|
+
// This is acceptable because the agent runs over the diff under
|
|
46
|
+
// review; the read-only sandbox prevents the agent from
|
|
47
|
+
// modifying the repo state in any way.
|
|
48
|
+
//
|
|
49
|
+
// The implementation uses the dependency-injection ESCAPE hatch on the
|
|
50
|
+
// constructor (`createCodex` factory) so unit tests can supply a mock
|
|
51
|
+
// SDK that bypasses the real Codex CLI subprocess + network surface —
|
|
52
|
+
// this matches the testing posture of the 322.2 Gemini and 322.3 Grok
|
|
53
|
+
// adapters. The static import below pins `@openai/codex-sdk` as a hard
|
|
54
|
+
// dependency for production callers; tests still need the package
|
|
55
|
+
// resolvable at module-load time (so the static import succeeds) but
|
|
56
|
+
// the `createCodex` factory swap means tests never invoke the real
|
|
57
|
+
// Codex constructor or spawn the CLI subprocess. The doctor probe
|
|
58
|
+
// (`codex_sdk_loaded`) re-imports dynamically so a doctor run on a
|
|
59
|
+
// machine without the SDK installed surfaces a clear remediation
|
|
60
|
+
// instead of crashing at startup.
|
|
61
|
+
import { execFile } from "node:child_process";
|
|
62
|
+
import { existsSync } from "node:fs";
|
|
63
|
+
import { createRequire } from "node:module";
|
|
64
|
+
import os from "node:os";
|
|
65
|
+
import path from "node:path";
|
|
66
|
+
import { promisify } from "node:util";
|
|
67
|
+
import { Codex } from "@openai/codex-sdk";
|
|
68
|
+
import { compileCriticPrompt } from "../prompt.js";
|
|
69
|
+
import { parseCriticResult, } from "@momentiq/dark-factory-schemas";
|
|
70
|
+
import { CRITIC_RESULT_JSON_SCHEMA } from "./critic-result-schema.js";
|
|
71
|
+
import { buildErrorResult, mergeAdapterMetadata, normalizeCriticEcho, parseAssistantJson, writeRedactedDiagnostic, } from "./_shared.js";
|
|
72
|
+
import { PERMANENT_ERROR_CODES, runRetryLoop, } from "./cursor-sdk.js";
|
|
73
|
+
// Promisified `execFile` for safe arg-array subprocess invocations.
|
|
74
|
+
// NB: this is `execFile` (no shell parsing), NOT `exec` — fixed arg
|
|
75
|
+
// arrays are immune to command injection. The codex doctor probe
|
|
76
|
+
// invokes `codex --version` via this helper.
|
|
77
|
+
const execFileAsync = promisify(execFile);
|
|
78
|
+
export const CODEX_SDK_ADAPTER_ID = "codex-sdk";
|
|
79
|
+
export const CODEX_API_KEY_ENV = "CODEX_API_KEY";
|
|
80
|
+
export const CODEX_HOME_ENV = "CODEX_HOME";
|
|
81
|
+
// Issue #2103 — auth-mode vocabulary the codex-sdk adapter accepts on
|
|
82
|
+
// `critic.auth` (set by `applyProfileAuth()` from
|
|
83
|
+
// `profile.auth[critic.id]`). Strict-no-fallback contract:
|
|
84
|
+
//
|
|
85
|
+
// - "chatgpt": ChatGPT Pro / Plus / Business / Enterprise subscription
|
|
86
|
+
// OAuth via `~/.codex/auth.json` (cached by `codex login`). The
|
|
87
|
+
// adapter pins `forced_login_method: "chatgpt"` AND does NOT pass
|
|
88
|
+
// `apiKey` to the SDK — so a stray `CODEX_API_KEY` in the env
|
|
89
|
+
// cannot route the run through per-token API billing.
|
|
90
|
+
//
|
|
91
|
+
// - "api": `CODEX_API_KEY` env var (developer API key). The adapter
|
|
92
|
+
// pins `forced_login_method: "api"` and requires the env var to be
|
|
93
|
+
// set; if missing, the adapter throws a permanent_failure at
|
|
94
|
+
// attemptReview() — never falls back to subscription OAuth even if
|
|
95
|
+
// `~/.codex/auth.json` exists.
|
|
96
|
+
//
|
|
97
|
+
// When `critic.auth` is undefined (no profile auth pin), the adapter
|
|
98
|
+
// throws a configuration error directing the operator to add
|
|
99
|
+
// `profiles.<name>.auth[<critic.id>]` to `.agent-review/config.json`.
|
|
100
|
+
// This is intentional: removing the old env-presence inference means
|
|
101
|
+
// the operator's intent must be declared, not inferred. The error
|
|
102
|
+
// message names the critic + profile so the fix is mechanical.
|
|
103
|
+
export const CODEX_AUTH_CHATGPT = "chatgpt";
|
|
104
|
+
export const CODEX_AUTH_API = "api";
|
|
105
|
+
export const CODEX_AUTH_MODES = [
|
|
106
|
+
CODEX_AUTH_CHATGPT,
|
|
107
|
+
CODEX_AUTH_API,
|
|
108
|
+
];
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Bundled-binary resolution (#1471 P2 #2)
|
|
111
|
+
//
|
|
112
|
+
// The Codex SDK bundles the `codex` CLI as an `optionalDependency` on the
|
|
113
|
+
// `@openai/codex` npm package (which itself optional-depends on per-platform
|
|
114
|
+
// binary packages like `@openai/codex-linux-x64`, `@openai/codex-darwin-arm64`).
|
|
115
|
+
// A standard `npm ci` from `tools/agent-review/` puts the platform binary on
|
|
116
|
+
// disk at `node_modules/@openai/codex-<platform>-<arch>/vendor/<target>/codex/codex`
|
|
117
|
+
// without putting `codex` on PATH. The SDK itself resolves this binary
|
|
118
|
+
// internally via `findCodexPath()` (private to the SDK package; not exported).
|
|
119
|
+
//
|
|
120
|
+
// This helper mirrors the SDK's resolution so the doctor probe can probe the
|
|
121
|
+
// SAME binary the SDK will spawn at review time. Falls back to returning
|
|
122
|
+
// `null` when any step of the resolution fails (no @openai/codex package,
|
|
123
|
+
// unsupported platform, missing platform package), and the doctor then falls
|
|
124
|
+
// back to probing the PATH `codex` binary.
|
|
125
|
+
const CODEX_NPM_NAME = "@openai/codex";
|
|
126
|
+
const PLATFORM_PACKAGE_BY_TARGET = {
|
|
127
|
+
"x86_64-unknown-linux-musl": "@openai/codex-linux-x64",
|
|
128
|
+
"aarch64-unknown-linux-musl": "@openai/codex-linux-arm64",
|
|
129
|
+
"x86_64-apple-darwin": "@openai/codex-darwin-x64",
|
|
130
|
+
"aarch64-apple-darwin": "@openai/codex-darwin-arm64",
|
|
131
|
+
"x86_64-pc-windows-msvc": "@openai/codex-win32-x64",
|
|
132
|
+
"aarch64-pc-windows-msvc": "@openai/codex-win32-arm64",
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Compute the (platform, arch) target triple Codex uses to key its
|
|
136
|
+
* per-platform binary packages. Returns `null` for unsupported platforms.
|
|
137
|
+
* Exported for direct unit testing of the resolution logic.
|
|
138
|
+
*/
|
|
139
|
+
export function targetTripleForCurrentPlatform(platform = process.platform, arch = process.arch) {
|
|
140
|
+
switch (platform) {
|
|
141
|
+
case "linux":
|
|
142
|
+
case "android":
|
|
143
|
+
if (arch === "x64")
|
|
144
|
+
return "x86_64-unknown-linux-musl";
|
|
145
|
+
if (arch === "arm64")
|
|
146
|
+
return "aarch64-unknown-linux-musl";
|
|
147
|
+
return null;
|
|
148
|
+
case "darwin":
|
|
149
|
+
if (arch === "x64")
|
|
150
|
+
return "x86_64-apple-darwin";
|
|
151
|
+
if (arch === "arm64")
|
|
152
|
+
return "aarch64-apple-darwin";
|
|
153
|
+
return null;
|
|
154
|
+
case "win32":
|
|
155
|
+
if (arch === "x64")
|
|
156
|
+
return "x86_64-pc-windows-msvc";
|
|
157
|
+
if (arch === "arm64")
|
|
158
|
+
return "aarch64-pc-windows-msvc";
|
|
159
|
+
return null;
|
|
160
|
+
default:
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve the absolute path to the Codex CLI binary bundled inside
|
|
166
|
+
* `@openai/codex`'s per-platform optional dependency. Returns `null` when
|
|
167
|
+
* any step of the resolution fails (so the doctor can fall back to PATH).
|
|
168
|
+
*
|
|
169
|
+
* Mirrors `findCodexPath()` in `@openai/codex-sdk@0.130.0` —
|
|
170
|
+
* `node_modules/@openai/codex-sdk/dist/index.js:368-433`. The SDK does
|
|
171
|
+
* not export this function publicly, so we replicate the lookup here
|
|
172
|
+
* using the same `createRequire` + `package.json` walk so the doctor
|
|
173
|
+
* probes the EXACT binary the SDK will spawn at review time.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveBundledCodexCliPath() {
|
|
176
|
+
const targetTriple = targetTripleForCurrentPlatform();
|
|
177
|
+
if (!targetTriple)
|
|
178
|
+
return null;
|
|
179
|
+
const platformPackage = PLATFORM_PACKAGE_BY_TARGET[targetTriple];
|
|
180
|
+
if (!platformPackage)
|
|
181
|
+
return null;
|
|
182
|
+
// `createRequire(import.meta.url)` returns a `require` rooted at THIS
|
|
183
|
+
// compiled module. The SDK's `@openai/codex` direct dep is reachable
|
|
184
|
+
// from us because `@openai/codex-sdk` is OUR direct dep and `@openai/codex`
|
|
185
|
+
// is the SDK's direct dep. npm's hoisting algorithm lifts both into our
|
|
186
|
+
// node_modules tree so the resolution walk succeeds.
|
|
187
|
+
try {
|
|
188
|
+
const moduleRequire = createRequire(import.meta.url);
|
|
189
|
+
const codexPackageJsonPath = moduleRequire.resolve(`${CODEX_NPM_NAME}/package.json`);
|
|
190
|
+
const codexRequire = createRequire(codexPackageJsonPath);
|
|
191
|
+
const platformPackageJsonPath = codexRequire.resolve(`${platformPackage}/package.json`);
|
|
192
|
+
const vendorRoot = path.join(path.dirname(platformPackageJsonPath), "vendor");
|
|
193
|
+
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
|
|
194
|
+
const binaryPath = path.join(vendorRoot, targetTriple, "codex", codexBinaryName);
|
|
195
|
+
return existsSync(binaryPath) ? binaryPath : null;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Probe `codex login status` to detect authenticated state regardless of
|
|
203
|
+
* where credentials are stored (~/.codex/auth.json or OS keyring per
|
|
204
|
+
* `cli_auth_credentials_store: auto`). The CLI exits 0 when authenticated
|
|
205
|
+
* and non-zero when not, which is the canonical detector for #1471 P2 #1.
|
|
206
|
+
*
|
|
207
|
+
* The `exec` parameter defaults to the promisified `child_process.execFile`
|
|
208
|
+
* but accepts a test override so the probe can be invoked deterministically.
|
|
209
|
+
*
|
|
210
|
+
* Exported and used by {@link CodexSdkAdapter.doctor} (the doctor's
|
|
211
|
+
* `defaultAuthProbe` routes here with its `execCodex` hook injected).
|
|
212
|
+
*/
|
|
213
|
+
export async function probeCodexLoginStatus(cliPath, exec = (binaryPath, args, options) => execFileAsync(binaryPath, args, options ?? {}), timeoutMs = 5_000) {
|
|
214
|
+
try {
|
|
215
|
+
const { stdout } = await exec(cliPath, ["login", "status"], {
|
|
216
|
+
timeout: timeoutMs,
|
|
217
|
+
});
|
|
218
|
+
return { loggedIn: true, detail: stdout.trim() || "codex login status: ok" };
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
const e = err;
|
|
222
|
+
return { loggedIn: false, detail: `codex login status failed: ${e.message}` };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Reasoning-effort param id used in `critic.model.params`; mapped to
|
|
226
|
+
// Codex's `model_reasoning_effort` CLI config key inside the adapter.
|
|
227
|
+
const REASONING_EFFORT_PARAM_ID = "reasoning_effort";
|
|
228
|
+
// Allowed Codex reasoning-effort values per the SDK's ModelReasoningEffort
|
|
229
|
+
// union (`@openai/codex-sdk` 0.130 exports: "minimal" | "low" | "medium"
|
|
230
|
+
// | "high" | "xhigh"). Codex docs note `xhigh` is model-dependent; the
|
|
231
|
+
// default of `high` is universally supported.
|
|
232
|
+
const ALLOWED_REASONING_EFFORTS = new Set([
|
|
233
|
+
"minimal",
|
|
234
|
+
"low",
|
|
235
|
+
"medium",
|
|
236
|
+
"high",
|
|
237
|
+
"xhigh",
|
|
238
|
+
]);
|
|
239
|
+
export const DEFAULT_REASONING_EFFORT = "high";
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the Codex reasoning-effort from the critic config's
|
|
242
|
+
* `model.params`. Falls back to {@link DEFAULT_REASONING_EFFORT} when
|
|
243
|
+
* unset. Invalid values fall back to the default rather than corrupting
|
|
244
|
+
* the request body. Coerces string inputs (the config schema's `value`
|
|
245
|
+
* is `string | number | boolean`; reasoning effort is always a string
|
|
246
|
+
* enum). Exported for direct unit testing.
|
|
247
|
+
*/
|
|
248
|
+
export function resolveCodexReasoningEffort(critic) {
|
|
249
|
+
const param = critic.model.params.find((p) => p.id === REASONING_EFFORT_PARAM_ID);
|
|
250
|
+
if (!param)
|
|
251
|
+
return DEFAULT_REASONING_EFFORT;
|
|
252
|
+
const v = param.value;
|
|
253
|
+
if (typeof v !== "string")
|
|
254
|
+
return DEFAULT_REASONING_EFFORT;
|
|
255
|
+
const norm = v.toLowerCase();
|
|
256
|
+
if (ALLOWED_REASONING_EFFORTS.has(norm))
|
|
257
|
+
return norm;
|
|
258
|
+
return DEFAULT_REASONING_EFFORT;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Probe a thrown error for a Codex SDK structured error code. The
|
|
262
|
+
* Codex SDK surfaces errors as plain `Error` instances; some SDK
|
|
263
|
+
* upgrades add a `code` field (mirroring openai's `APIError.code`).
|
|
264
|
+
* The adapter treats:
|
|
265
|
+
* - `code` matching {@link PERMANENT_ERROR_CODES} → permanent
|
|
266
|
+
* - everything else → retryable
|
|
267
|
+
* Exported for unit testing.
|
|
268
|
+
*/
|
|
269
|
+
export function extractCodexErrorCode(err) {
|
|
270
|
+
if (!err || typeof err !== "object")
|
|
271
|
+
return null;
|
|
272
|
+
const e = err;
|
|
273
|
+
if (typeof e["code"] === "string")
|
|
274
|
+
return e["code"];
|
|
275
|
+
// Some SDK shapes nest under `cause.code`:
|
|
276
|
+
const cause = e["cause"];
|
|
277
|
+
if (cause && typeof cause === "object") {
|
|
278
|
+
const c = cause["code"];
|
|
279
|
+
if (typeof c === "string")
|
|
280
|
+
return c;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Issue #2103 — strict auth resolver. Validates `critic.auth` against
|
|
286
|
+
* the codex adapter vocabulary ({@link CODEX_AUTH_MODES}) and surfaces
|
|
287
|
+
* the missing-key case as a `permanent_failure` so the outer retry
|
|
288
|
+
* loop returns the error immediately (retrying a configuration
|
|
289
|
+
* mistake just wastes budget). Returns the resolved mode + an
|
|
290
|
+
* `apiKey` value that is INTENTIONALLY `undefined` under the
|
|
291
|
+
* "chatgpt" branch so the caller passes nothing to the SDK
|
|
292
|
+
* constructor — a stray `CODEX_API_KEY` cannot override the
|
|
293
|
+
* subscription path.
|
|
294
|
+
*
|
|
295
|
+
* Three failure shapes, all permanent (no retry):
|
|
296
|
+
*
|
|
297
|
+
* 1. `critic.auth === undefined`: profile didn't pin auth. Surfaces
|
|
298
|
+
* as `permanent_failure` with a message naming the critic + the
|
|
299
|
+
* config path the operator should edit
|
|
300
|
+
* (`profiles.<name>.auth[<critic.id>]`).
|
|
301
|
+
*
|
|
302
|
+
* 2. `critic.auth` is set but not in {@link CODEX_AUTH_MODES}:
|
|
303
|
+
* typo / unsupported value. The error message enumerates the
|
|
304
|
+
* valid set so the operator can self-correct.
|
|
305
|
+
*
|
|
306
|
+
* 3. `critic.auth === "api"` AND no `CODEX_API_KEY` in env (and no
|
|
307
|
+
* `apiKey` constructor override): the configured source is
|
|
308
|
+
* missing. Same shape as the prior "missing key" path, with the
|
|
309
|
+
* error explicitly mentioning the `api` auth mode so the
|
|
310
|
+
* operator doesn't try to fix it by running `codex login`.
|
|
311
|
+
*/
|
|
312
|
+
export function resolveAuthOrFail(critic, options, attemptIdx) {
|
|
313
|
+
const authRaw = critic.auth;
|
|
314
|
+
if (authRaw === undefined) {
|
|
315
|
+
return {
|
|
316
|
+
kind: "permanent_failure",
|
|
317
|
+
errorCode: null,
|
|
318
|
+
statusMessage: null,
|
|
319
|
+
result: buildErrorResult({
|
|
320
|
+
critic,
|
|
321
|
+
message: `codex critic "${critic.id}" has no auth source pinned. ` +
|
|
322
|
+
`Add \`profiles.<name>.auth["${critic.id}"]\` to .agent-review/config.json ` +
|
|
323
|
+
`with one of: ${CODEX_AUTH_MODES.join(", ")}. ` +
|
|
324
|
+
`(Issue #2103 — env-presence inference was removed to prevent silent fallback to API-key billing.)`,
|
|
325
|
+
retryable: false,
|
|
326
|
+
retryCount: attemptIdx,
|
|
327
|
+
}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (!CODEX_AUTH_MODES.includes(authRaw)) {
|
|
331
|
+
return {
|
|
332
|
+
kind: "permanent_failure",
|
|
333
|
+
errorCode: null,
|
|
334
|
+
statusMessage: null,
|
|
335
|
+
result: buildErrorResult({
|
|
336
|
+
critic,
|
|
337
|
+
message: `codex critic "${critic.id}" has unsupported auth value "${authRaw}". ` +
|
|
338
|
+
`Expected one of: ${CODEX_AUTH_MODES.join(", ")}. ` +
|
|
339
|
+
`Edit \`profiles.<name>.auth["${critic.id}"]\` in .agent-review/config.json.`,
|
|
340
|
+
retryable: false,
|
|
341
|
+
retryCount: attemptIdx,
|
|
342
|
+
}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const mode = authRaw;
|
|
346
|
+
if (mode === CODEX_AUTH_API) {
|
|
347
|
+
const apiKey = options.apiKey ?? process.env[CODEX_API_KEY_ENV];
|
|
348
|
+
if (!apiKey) {
|
|
349
|
+
return {
|
|
350
|
+
kind: "permanent_failure",
|
|
351
|
+
errorCode: null,
|
|
352
|
+
statusMessage: null,
|
|
353
|
+
result: buildErrorResult({
|
|
354
|
+
critic,
|
|
355
|
+
message: `codex critic "${critic.id}" is pinned to auth="api" but ${CODEX_API_KEY_ENV} is not set. ` +
|
|
356
|
+
`Either provision the key (e.g. via Doppler) or change the profile to auth="${CODEX_AUTH_CHATGPT}" and run \`codex login\`.`,
|
|
357
|
+
retryable: false,
|
|
358
|
+
retryCount: attemptIdx,
|
|
359
|
+
}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return { kind: "ok", mode, apiKey };
|
|
363
|
+
}
|
|
364
|
+
// mode === "chatgpt" — withhold apiKey so a stray env var cannot
|
|
365
|
+
// override subscription auth at the SDK level.
|
|
366
|
+
return { kind: "ok", mode, apiKey: undefined };
|
|
367
|
+
}
|
|
368
|
+
export class CodexSdkAdapter {
|
|
369
|
+
options;
|
|
370
|
+
id = CODEX_SDK_ADAPTER_ID;
|
|
371
|
+
// Cycle 322.7 — empty array because the Codex SDK supports BOTH auth modes:
|
|
372
|
+
// - Local: ~/.codex/auth.json from `codex login` (ChatGPT subscription OAuth)
|
|
373
|
+
// - CI: CODEX_API_KEY env var (OpenAI API key)
|
|
374
|
+
// doctor() validates that at least ONE is configured; the SDK subprocess
|
|
375
|
+
// inherits whichever is present. Declaring CODEX_API_KEY as "required"
|
|
376
|
+
// here would force Doppler re-exec even when subscription auth is the
|
|
377
|
+
// intended path.
|
|
378
|
+
requiredEnvVars = [];
|
|
379
|
+
createCodex;
|
|
380
|
+
constructor(options = {}) {
|
|
381
|
+
this.options = options;
|
|
382
|
+
this.createCodex =
|
|
383
|
+
options.createCodex ??
|
|
384
|
+
((opts) => new Codex({
|
|
385
|
+
...(opts.apiKey !== undefined ? { apiKey: opts.apiKey } : {}),
|
|
386
|
+
...(opts.config !== undefined
|
|
387
|
+
? { config: opts.config }
|
|
388
|
+
: {}),
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
async review(packet, critic, options) {
|
|
392
|
+
return runRetryLoop({
|
|
393
|
+
attempt: (idx) => this.attemptReview(packet, critic, options, idx),
|
|
394
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
395
|
+
...(this.options.sleep !== undefined ? { sleep: this.options.sleep } : {}),
|
|
396
|
+
buildExhausted: ({ last, totalAttempts, aborted }) => {
|
|
397
|
+
const retriesUsed = Math.max(0, totalAttempts - 1);
|
|
398
|
+
const summary = aborted
|
|
399
|
+
? last
|
|
400
|
+
? `codex SDK run aborted after ${retriesUsed} retries: ${last.message}`
|
|
401
|
+
: "codex SDK run aborted before any attempt completed"
|
|
402
|
+
: last
|
|
403
|
+
? `codex SDK run failed after ${retriesUsed} retries: ${last.message}`
|
|
404
|
+
: "codex SDK run failed with no captured failure metadata";
|
|
405
|
+
return buildErrorResult({
|
|
406
|
+
critic,
|
|
407
|
+
message: summary,
|
|
408
|
+
retryable: true,
|
|
409
|
+
...(last?.errorCode != null ? { code: last.errorCode } : {}),
|
|
410
|
+
retryCount: retriesUsed,
|
|
411
|
+
...(last?.runId !== null && last?.runId !== undefined
|
|
412
|
+
? { runId: last.runId }
|
|
413
|
+
: {}),
|
|
414
|
+
...(last?.agentId !== null && last?.agentId !== undefined
|
|
415
|
+
? { agentId: last.agentId }
|
|
416
|
+
: {}),
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async attemptReview(packet, critic, options, attemptIdx) {
|
|
422
|
+
const reasoningEffort = resolveCodexReasoningEffort(critic);
|
|
423
|
+
// Issue #2103 — strict-no-fallback auth resolution. The runner sets
|
|
424
|
+
// `critic.auth` via `applyProfileAuth()` from
|
|
425
|
+
// `profile.auth[critic.id]`; this adapter honors it without
|
|
426
|
+
// env-presence inference.
|
|
427
|
+
//
|
|
428
|
+
// - "chatgpt": pin `forced_login_method: "chatgpt"` AND withhold
|
|
429
|
+
// `apiKey` from the SDK — so even if `CODEX_API_KEY` is set in
|
|
430
|
+
// the env (Doppler leaks the CI key into local shells), the
|
|
431
|
+
// SDK falls through to `~/.codex/auth.json` and bills against
|
|
432
|
+
// the ChatGPT Pro subscription quota.
|
|
433
|
+
//
|
|
434
|
+
// - "api": pin `forced_login_method: "api"` and REQUIRE
|
|
435
|
+
// `CODEX_API_KEY` (or the `apiKey` constructor override for
|
|
436
|
+
// tests). Missing key returns `permanent_failure` immediately;
|
|
437
|
+
// no fallback to subscription OAuth.
|
|
438
|
+
//
|
|
439
|
+
// - undefined: configuration error. The operator must declare
|
|
440
|
+
// auth at the profile level (see CLAUDE.md / issue #2103). The
|
|
441
|
+
// adapter throws `permanent_failure` naming the critic so the
|
|
442
|
+
// fix is mechanical (add `profiles.<name>.auth[<critic.id>]`).
|
|
443
|
+
const authResolved = resolveAuthOrFail(critic, this.options, attemptIdx);
|
|
444
|
+
if (authResolved.kind === "permanent_failure")
|
|
445
|
+
return authResolved;
|
|
446
|
+
const { mode: forcedLoginMethod, apiKey } = authResolved;
|
|
447
|
+
const prompt = compileCriticPrompt({
|
|
448
|
+
packet,
|
|
449
|
+
critic,
|
|
450
|
+
blockingSeverities: options.blockingSeverities,
|
|
451
|
+
treatDiffAsUntrusted: true,
|
|
452
|
+
});
|
|
453
|
+
const startMs = Date.now();
|
|
454
|
+
if (attemptIdx === 0) {
|
|
455
|
+
options.emit?.({
|
|
456
|
+
ts: new Date().toISOString(),
|
|
457
|
+
event: "critic_run_started",
|
|
458
|
+
commit: packet.commit.sha,
|
|
459
|
+
criticId: critic.id,
|
|
460
|
+
adapter: this.id,
|
|
461
|
+
model: critic.model.id,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
let codex;
|
|
465
|
+
try {
|
|
466
|
+
codex = this.createCodex({
|
|
467
|
+
...(apiKey !== undefined ? { apiKey } : {}),
|
|
468
|
+
config: {
|
|
469
|
+
model: critic.model.id,
|
|
470
|
+
// Codex CLI key. Pinned via the constructor `config` map which
|
|
471
|
+
// the SDK serializes into `--config key=value` CLI overrides.
|
|
472
|
+
model_reasoning_effort: reasoningEffort,
|
|
473
|
+
// Reasoning tokens are model-internal; surfacing them in the
|
|
474
|
+
// event stream just bloats artifacts.
|
|
475
|
+
show_raw_agent_reasoning: false,
|
|
476
|
+
// Belt-and-suspenders against a stray CODEX_API_KEY clobbering
|
|
477
|
+
// the intended subscription path (or vice versa).
|
|
478
|
+
forced_login_method: forcedLoginMethod,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
const e = err;
|
|
484
|
+
options.emit?.({
|
|
485
|
+
ts: new Date().toISOString(),
|
|
486
|
+
event: "critic_run_error",
|
|
487
|
+
commit: packet.commit.sha,
|
|
488
|
+
criticId: critic.id,
|
|
489
|
+
adapter: this.id,
|
|
490
|
+
model: critic.model.id,
|
|
491
|
+
durationMs: Date.now() - startMs,
|
|
492
|
+
error: e.message,
|
|
493
|
+
status: "startup_failure",
|
|
494
|
+
retryCount: attemptIdx,
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
kind: "retryable_failure",
|
|
498
|
+
errorCode: null,
|
|
499
|
+
statusMessage: null,
|
|
500
|
+
message: `codex SDK construction failed: ${e.message}`,
|
|
501
|
+
runId: null,
|
|
502
|
+
agentId: null,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
let thread;
|
|
506
|
+
try {
|
|
507
|
+
thread = codex.startThread({
|
|
508
|
+
workingDirectory: packet.repoRoot,
|
|
509
|
+
// Defense in depth — critic must never write files even if a
|
|
510
|
+
// malicious diff convinces the model to try. Read-only sandbox
|
|
511
|
+
// blocks WRITES, not READS, so the agent may still run shell
|
|
512
|
+
// commands to explore the repo (see fixtures/spike-codex-2026-05.json).
|
|
513
|
+
sandboxMode: "read-only",
|
|
514
|
+
// No interactive prompts in non-interactive runs.
|
|
515
|
+
approvalPolicy: "never",
|
|
516
|
+
// Critic must not exfiltrate diff content to external services.
|
|
517
|
+
networkAccessEnabled: false,
|
|
518
|
+
// packet.repoRoot IS a git repo; let the CLI confirm.
|
|
519
|
+
skipGitRepoCheck: false,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
const e = err;
|
|
524
|
+
options.emit?.({
|
|
525
|
+
ts: new Date().toISOString(),
|
|
526
|
+
event: "critic_run_error",
|
|
527
|
+
commit: packet.commit.sha,
|
|
528
|
+
criticId: critic.id,
|
|
529
|
+
adapter: this.id,
|
|
530
|
+
model: critic.model.id,
|
|
531
|
+
durationMs: Date.now() - startMs,
|
|
532
|
+
error: e.message,
|
|
533
|
+
status: "startup_failure",
|
|
534
|
+
retryCount: attemptIdx,
|
|
535
|
+
});
|
|
536
|
+
return {
|
|
537
|
+
kind: "retryable_failure",
|
|
538
|
+
errorCode: null,
|
|
539
|
+
statusMessage: null,
|
|
540
|
+
message: `codex startThread failed: ${e.message}`,
|
|
541
|
+
runId: null,
|
|
542
|
+
agentId: null,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
let turn;
|
|
546
|
+
try {
|
|
547
|
+
turn = await thread.run(prompt.text, {
|
|
548
|
+
outputSchema: CRITIC_RESULT_JSON_SCHEMA,
|
|
549
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
const e = err;
|
|
554
|
+
const codeStr = extractCodexErrorCode(err);
|
|
555
|
+
// Permanent codes are the same classification used by the Cursor
|
|
556
|
+
// adapter: auth/quota/policy failures where retrying wastes
|
|
557
|
+
// budget AND can mask the real fault.
|
|
558
|
+
const permanent = codeStr !== null && PERMANENT_ERROR_CODES.has(codeStr);
|
|
559
|
+
const finalCode = codeStr ?? "transport_error";
|
|
560
|
+
options.emit?.({
|
|
561
|
+
ts: new Date().toISOString(),
|
|
562
|
+
event: "critic_run_error",
|
|
563
|
+
commit: packet.commit.sha,
|
|
564
|
+
criticId: critic.id,
|
|
565
|
+
adapter: this.id,
|
|
566
|
+
model: critic.model.id,
|
|
567
|
+
durationMs: Date.now() - startMs,
|
|
568
|
+
error: e.message,
|
|
569
|
+
status: permanent ? "run_failure_permanent" : "run_failure",
|
|
570
|
+
retryCount: attemptIdx,
|
|
571
|
+
errorCode: finalCode,
|
|
572
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
573
|
+
});
|
|
574
|
+
if (permanent) {
|
|
575
|
+
return {
|
|
576
|
+
kind: "permanent_failure",
|
|
577
|
+
errorCode: finalCode,
|
|
578
|
+
statusMessage: null,
|
|
579
|
+
result: buildErrorResult({
|
|
580
|
+
critic,
|
|
581
|
+
message: `codex SDK run failed (permanent, code=${finalCode}): ${e.message}`,
|
|
582
|
+
retryable: false,
|
|
583
|
+
code: finalCode,
|
|
584
|
+
retryCount: attemptIdx,
|
|
585
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
586
|
+
}),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
kind: "retryable_failure",
|
|
591
|
+
errorCode: finalCode,
|
|
592
|
+
statusMessage: null,
|
|
593
|
+
message: `codex SDK run failed: ${e.message}`,
|
|
594
|
+
runId: thread.id,
|
|
595
|
+
agentId: null,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
// Parse path. With `outputSchema` set, Codex enforces schema-validated
|
|
599
|
+
// JSON at the model level; the `parseAssistantJson` fallback chain is
|
|
600
|
+
// defense in depth against occasional format drift in older models or
|
|
601
|
+
// SDK regressions.
|
|
602
|
+
const parseOutcome = parseAssistantJson(turn.finalResponse);
|
|
603
|
+
if (!parseOutcome.ok) {
|
|
604
|
+
const diagPath = writeRedactedDiagnostic({
|
|
605
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
606
|
+
criticId: critic.id,
|
|
607
|
+
commit: packet.commit.sha,
|
|
608
|
+
rawText: turn.finalResponse,
|
|
609
|
+
});
|
|
610
|
+
options.emit?.({
|
|
611
|
+
ts: new Date().toISOString(),
|
|
612
|
+
event: "critic_run_error",
|
|
613
|
+
commit: packet.commit.sha,
|
|
614
|
+
criticId: critic.id,
|
|
615
|
+
adapter: this.id,
|
|
616
|
+
model: critic.model.id,
|
|
617
|
+
durationMs: Date.now() - startMs,
|
|
618
|
+
error: `invalid critic JSON: ${parseOutcome.message}`,
|
|
619
|
+
status: "invalid_json",
|
|
620
|
+
retryCount: attemptIdx,
|
|
621
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
622
|
+
});
|
|
623
|
+
return {
|
|
624
|
+
kind: "permanent_failure",
|
|
625
|
+
errorCode: null,
|
|
626
|
+
statusMessage: null,
|
|
627
|
+
result: buildErrorResult({
|
|
628
|
+
critic,
|
|
629
|
+
message: `codex critic returned invalid JSON: ${parseOutcome.message}`,
|
|
630
|
+
retryable: false,
|
|
631
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
632
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
633
|
+
retryCount: attemptIdx,
|
|
634
|
+
}),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
let result;
|
|
638
|
+
try {
|
|
639
|
+
// Drop schema-invalid `validation.qualityGateResults[]` entries
|
|
640
|
+
// (e.g. model emits `gate` instead of `command`) BEFORE strict
|
|
641
|
+
// parsing — the validation block is informational and gets
|
|
642
|
+
// overwritten below with deterministic packet evidence. Issue #1484.
|
|
643
|
+
const normalized = normalizeCriticEcho(parseOutcome.value);
|
|
644
|
+
const enriched = mergeAdapterMetadata(normalized, {
|
|
645
|
+
critic,
|
|
646
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
647
|
+
});
|
|
648
|
+
result = parseCriticResult(enriched, options.blockingSeverities);
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
const e = err;
|
|
652
|
+
const diagPath = writeRedactedDiagnostic({
|
|
653
|
+
diagnosticsDir: options.diagnosticsDir,
|
|
654
|
+
criticId: critic.id,
|
|
655
|
+
commit: packet.commit.sha,
|
|
656
|
+
rawText: turn.finalResponse,
|
|
657
|
+
});
|
|
658
|
+
options.emit?.({
|
|
659
|
+
ts: new Date().toISOString(),
|
|
660
|
+
event: "critic_run_error",
|
|
661
|
+
commit: packet.commit.sha,
|
|
662
|
+
criticId: critic.id,
|
|
663
|
+
adapter: this.id,
|
|
664
|
+
model: critic.model.id,
|
|
665
|
+
durationMs: Date.now() - startMs,
|
|
666
|
+
error: `schema validation failed: ${e.message}`,
|
|
667
|
+
status: "schema_violation",
|
|
668
|
+
retryCount: attemptIdx,
|
|
669
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
670
|
+
});
|
|
671
|
+
return {
|
|
672
|
+
kind: "permanent_failure",
|
|
673
|
+
errorCode: null,
|
|
674
|
+
statusMessage: null,
|
|
675
|
+
result: buildErrorResult({
|
|
676
|
+
critic,
|
|
677
|
+
message: `codex critic JSON failed schema validation: ${e.message}`,
|
|
678
|
+
retryable: false,
|
|
679
|
+
...(diagPath !== undefined ? { rawSamplePath: diagPath } : {}),
|
|
680
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
681
|
+
retryCount: attemptIdx,
|
|
682
|
+
}),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const durationMs = Date.now() - startMs;
|
|
686
|
+
const enriched = {
|
|
687
|
+
...result,
|
|
688
|
+
durationMs,
|
|
689
|
+
validation: {
|
|
690
|
+
qualityGateResults: packet.validation.evidence,
|
|
691
|
+
qualityGatesMissing: packet.validation.missing,
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
const blockerCount = enriched.findings.filter((f) => f.severity === "blocker").length;
|
|
695
|
+
const highCount = enriched.findings.filter((f) => f.severity === "high").length;
|
|
696
|
+
options.emit?.({
|
|
697
|
+
ts: new Date().toISOString(),
|
|
698
|
+
event: "critic_run_finished",
|
|
699
|
+
commit: packet.commit.sha,
|
|
700
|
+
criticId: critic.id,
|
|
701
|
+
adapter: this.id,
|
|
702
|
+
model: critic.model.id,
|
|
703
|
+
...(thread.id !== null ? { runId: thread.id } : {}),
|
|
704
|
+
durationMs,
|
|
705
|
+
...(enriched.verdict !== undefined ? { verdict: enriched.verdict } : {}),
|
|
706
|
+
findingCount: enriched.findings.length,
|
|
707
|
+
blockerCount,
|
|
708
|
+
highCount,
|
|
709
|
+
...(typeof turn.usage?.input_tokens === "number"
|
|
710
|
+
? { tokensIn: turn.usage.input_tokens }
|
|
711
|
+
: {}),
|
|
712
|
+
...(typeof turn.usage?.output_tokens === "number"
|
|
713
|
+
? { tokensOut: turn.usage.output_tokens }
|
|
714
|
+
: {}),
|
|
715
|
+
status: "complete",
|
|
716
|
+
retryCount: attemptIdx,
|
|
717
|
+
});
|
|
718
|
+
return { kind: "success", result: enriched };
|
|
719
|
+
}
|
|
720
|
+
async doctor(critic) {
|
|
721
|
+
const checks = [];
|
|
722
|
+
const apiKey = this.options.apiKey ?? process.env[CODEX_API_KEY_ENV];
|
|
723
|
+
const codexHome = this.options.codexHome ?? process.env[CODEX_HOME_ENV] ?? path.join(os.homedir(), ".codex");
|
|
724
|
+
const authPath = path.join(codexHome, "auth.json");
|
|
725
|
+
const authExists = existsSync(authPath);
|
|
726
|
+
// Cycle 322.7 follow-up #1492 + #1471 P2 #2: probe the BUNDLED Codex
|
|
727
|
+
// CLI binary (resolved via `@openai/codex` optional dep) before
|
|
728
|
+
// falling back to PATH. The SDK uses the bundled binary internally
|
|
729
|
+
// via `findCodexPath()` (not exported), so probing the same binary
|
|
730
|
+
// here is the only way for the doctor to reflect the SDK's runtime
|
|
731
|
+
// truth on a fresh `npm ci`-only install (no `npm install -g`).
|
|
732
|
+
//
|
|
733
|
+
// Resolution precedence:
|
|
734
|
+
// 1. `options.codexCliPathResolver` (test hook)
|
|
735
|
+
// 2. `resolveBundledCodexCliPath()` (production: walks
|
|
736
|
+
// @openai/codex package + platform-specific binary package)
|
|
737
|
+
// 3. PATH literal `codex` (fallback for workstations using
|
|
738
|
+
// `brew install codex` or `npm install -g @openai/codex`)
|
|
739
|
+
const resolveBundled = this.options.codexCliPathResolver ?? resolveBundledCodexCliPath;
|
|
740
|
+
const bundledPath = resolveBundled();
|
|
741
|
+
const execCodex = this.options.execCodex ??
|
|
742
|
+
((binaryPath, args, opts) => execFileAsync(binaryPath, args, opts ?? {}));
|
|
743
|
+
// Cycle 322.7 follow-up #1471 P2 #1: probe `codex login status` to
|
|
744
|
+
// detect keyring-backed auth. The default probe delegates to the
|
|
745
|
+
// exported `probeCodexLoginStatus`, passing the SAME `execCodex`
|
|
746
|
+
// hook so test overrides apply uniformly. The probed binary is the
|
|
747
|
+
// bundled one when resolvable, falling back to PATH `codex` — the
|
|
748
|
+
// same binary the SDK will spawn at review time, so the doctor's
|
|
749
|
+
// verdict reflects runtime truth. Skipped (treated as
|
|
750
|
+
// `loggedIn: false` with informational detail) when no codex
|
|
751
|
+
// binary is resolvable at all — the codex_cli_on_path check below
|
|
752
|
+
// will surface that as the actionable failure.
|
|
753
|
+
const defaultAuthProbe = () => probeCodexLoginStatus(bundledPath ?? "codex", execCodex);
|
|
754
|
+
const authProbe = this.options.codexAuthProbe ?? defaultAuthProbe;
|
|
755
|
+
const authProbeOutcome = await authProbe();
|
|
756
|
+
// Issue #2103 — strict auth check honors `critic.auth` when set:
|
|
757
|
+
// - "chatgpt": ONLY subscription sources count (file or keyring).
|
|
758
|
+
// A stray CODEX_API_KEY in the env does NOT pass the check —
|
|
759
|
+
// the whole point of the pin is to surface "subscription auth
|
|
760
|
+
// is not actually configured here" so the operator fixes it
|
|
761
|
+
// instead of silently routing to API billing.
|
|
762
|
+
// - "api": ONLY CODEX_API_KEY counts. Subscription presence is
|
|
763
|
+
// ignored (it would mislead the operator into thinking they're
|
|
764
|
+
// set up for CI when they aren't).
|
|
765
|
+
// - undefined (no profile context, direct adapter usage, tests):
|
|
766
|
+
// legacy "AT LEAST ONE of three sources" — preserves
|
|
767
|
+
// back-compat for non-profile call sites.
|
|
768
|
+
//
|
|
769
|
+
// The detail string discloses which path is active so operators
|
|
770
|
+
// can debug "I logged in but doctor still says missing".
|
|
771
|
+
const subscriptionActive = authExists || authProbeOutcome.loggedIn;
|
|
772
|
+
let hasAuth;
|
|
773
|
+
let authDetail;
|
|
774
|
+
let authRemediation;
|
|
775
|
+
if (critic.auth === CODEX_AUTH_CHATGPT) {
|
|
776
|
+
hasAuth = subscriptionActive;
|
|
777
|
+
if (authExists) {
|
|
778
|
+
authDetail = `${authPath} exists (subscription auth, file-backed; auth=${CODEX_AUTH_CHATGPT})`;
|
|
779
|
+
}
|
|
780
|
+
else if (authProbeOutcome.loggedIn) {
|
|
781
|
+
authDetail = `codex login status reports authenticated (keyring-backed; auth=${CODEX_AUTH_CHATGPT}): ${authProbeOutcome.detail}`;
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
authDetail = `critic pinned to auth="${CODEX_AUTH_CHATGPT}" but no subscription source: ${authPath} missing; ${authProbeOutcome.detail}`;
|
|
785
|
+
}
|
|
786
|
+
authRemediation = `run \`codex login\` once on this workstation to authenticate the ChatGPT Pro/Plus/Business/Enterprise subscription (supports keyring or ${authPath} depending on cli_auth_credentials_store)`;
|
|
787
|
+
}
|
|
788
|
+
else if (critic.auth === CODEX_AUTH_API) {
|
|
789
|
+
hasAuth = Boolean(apiKey);
|
|
790
|
+
authDetail = apiKey
|
|
791
|
+
? `${CODEX_API_KEY_ENV} set (API-key auth; auth=${CODEX_AUTH_API})`
|
|
792
|
+
: `critic pinned to auth="${CODEX_AUTH_API}" but ${CODEX_API_KEY_ENV} is unset`;
|
|
793
|
+
authRemediation = `export ${CODEX_API_KEY_ENV}=... (typically via Doppler for the CI profile)`;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
// Back-compat: no profile pin → any of the three sources works.
|
|
797
|
+
hasAuth = Boolean(apiKey) || subscriptionActive;
|
|
798
|
+
if (apiKey) {
|
|
799
|
+
authDetail = `${CODEX_API_KEY_ENV} set (API-key auth; no profile pin)`;
|
|
800
|
+
}
|
|
801
|
+
else if (authExists) {
|
|
802
|
+
authDetail = `${authPath} exists (subscription auth, file-backed; no profile pin)`;
|
|
803
|
+
}
|
|
804
|
+
else if (authProbeOutcome.loggedIn) {
|
|
805
|
+
authDetail = `codex login status reports authenticated (keyring-backed; no profile pin): ${authProbeOutcome.detail}`;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
authDetail = `no auth source: ${CODEX_API_KEY_ENV} unset; ${authPath} missing; ${authProbeOutcome.detail}`;
|
|
809
|
+
}
|
|
810
|
+
authRemediation = `run \`codex login\` once on this workstation (subscription auth) or \`export ${CODEX_API_KEY_ENV}=...\` (CI / Doppler), then pin via \`profiles.<name>.auth["${critic.id}"]\` in .agent-review/config.json`;
|
|
811
|
+
}
|
|
812
|
+
checks.push({
|
|
813
|
+
name: "codex_auth_present",
|
|
814
|
+
passed: hasAuth,
|
|
815
|
+
detail: authDetail,
|
|
816
|
+
...(hasAuth ? {} : { remediation: authRemediation }),
|
|
817
|
+
});
|
|
818
|
+
// SDK package presence — same pattern as gemini-sdk.ts:559-577.
|
|
819
|
+
let sdkLoaded = false;
|
|
820
|
+
try {
|
|
821
|
+
const mod = (await import("@openai/codex-sdk"));
|
|
822
|
+
sdkLoaded = typeof mod["Codex"] === "function";
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
sdkLoaded = false;
|
|
826
|
+
}
|
|
827
|
+
checks.push({
|
|
828
|
+
name: "codex_sdk_loaded",
|
|
829
|
+
passed: sdkLoaded,
|
|
830
|
+
detail: sdkLoaded
|
|
831
|
+
? "@openai/codex-sdk imported"
|
|
832
|
+
: "@openai/codex-sdk missing or shape unexpected",
|
|
833
|
+
...(sdkLoaded
|
|
834
|
+
? {}
|
|
835
|
+
: { remediation: "make agent-review-deps && make agent-review-build" }),
|
|
836
|
+
});
|
|
837
|
+
// The SDK spawns the `codex` binary as a subprocess. Probe the
|
|
838
|
+
// bundled binary first (resolved via @openai/codex's optional
|
|
839
|
+
// dep), then fall back to PATH `codex` (workstations using
|
|
840
|
+
// `brew install codex` or `npm install -g @openai/codex`). The
|
|
841
|
+
// probe MUST pass for one of the two — without a working binary,
|
|
842
|
+
// every Codex critic invocation fails at first turn (issue #1492).
|
|
843
|
+
let cliCheck;
|
|
844
|
+
const probeArgs = ["--version"];
|
|
845
|
+
let probedBinaryPath;
|
|
846
|
+
let probedSource = null;
|
|
847
|
+
let probeError = null;
|
|
848
|
+
let stdout = "";
|
|
849
|
+
if (bundledPath) {
|
|
850
|
+
probedBinaryPath = bundledPath;
|
|
851
|
+
try {
|
|
852
|
+
const result = await execCodex(bundledPath, probeArgs, { timeout: 5_000 });
|
|
853
|
+
stdout = result.stdout;
|
|
854
|
+
probedSource = "bundled";
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
probeError = err;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
probedBinaryPath = "codex";
|
|
862
|
+
}
|
|
863
|
+
if (probedSource === null) {
|
|
864
|
+
// Either bundled was unresolvable, or it was resolvable but the
|
|
865
|
+
// probe failed. Fall back to PATH `codex` so the doctor accounts
|
|
866
|
+
// for workstations that don't have a bundled binary (npm ci
|
|
867
|
+
// skipped optional deps, platform mismatch, etc.).
|
|
868
|
+
try {
|
|
869
|
+
const result = await execCodex("codex", probeArgs, { timeout: 5_000 });
|
|
870
|
+
stdout = result.stdout;
|
|
871
|
+
probedBinaryPath = "codex";
|
|
872
|
+
probedSource = "PATH";
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
// Preserve the first error if PATH probe also fails. This
|
|
876
|
+
// gives operators a more actionable message ("bundled was
|
|
877
|
+
// missing too") than just "spawn codex ENOENT" from PATH.
|
|
878
|
+
if (probeError === null)
|
|
879
|
+
probeError = err;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (probedSource !== null) {
|
|
883
|
+
const sourceLabel = probedSource === "bundled"
|
|
884
|
+
? `bundled @openai/codex binary at ${probedBinaryPath}`
|
|
885
|
+
: `codex on PATH`;
|
|
886
|
+
cliCheck = {
|
|
887
|
+
name: "codex_cli_on_path",
|
|
888
|
+
passed: true,
|
|
889
|
+
detail: `${sourceLabel} reports: ${stdout.trim()}`,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
const probeMsg = probeError?.message ?? "unknown error";
|
|
894
|
+
cliCheck = {
|
|
895
|
+
name: "codex_cli_on_path",
|
|
896
|
+
passed: false,
|
|
897
|
+
detail: `codex CLI not resolvable: ${probeMsg} (neither bundled @openai/codex binary nor PATH codex responded to --version)`,
|
|
898
|
+
remediation:
|
|
899
|
+
// Pin the suggested CLI version to the same one @openai/codex-sdk's
|
|
900
|
+
// direct dep declares (currently 0.130.0; check
|
|
901
|
+
// tools/agent-review/node_modules/@openai/codex-sdk/package.json
|
|
902
|
+
// after a bump). Aligns workstation install posture with the
|
|
903
|
+
// version pin in .github/workflows/agent-critic.yml so doctor +
|
|
904
|
+
// CI + runtime CLI semantics stay deterministic.
|
|
905
|
+
"install the Codex CLI pinned to the SDK's resolved version (currently 0.130.0). Recommended for CI: `npm install -g @openai/codex@0.130.0` (also satisfies the SDK's bundled-binary lookup if `npm ci` failed to install the optional platform package). Workstations: `brew install codex` is also supported on macOS but is unpinned — prefer `npm install -g @openai/codex@<lockfile-version>` to match the SDK's bundled-binary semantics. After install, run `codex login` once to seed authentication (keyring or ~/.codex/auth.json depending on cli_auth_credentials_store).",
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
checks.push(cliCheck);
|
|
909
|
+
// Sanity: critic.model.id should look like a Codex model id (gpt-*,
|
|
910
|
+
// o*, etc). The Codex SDK doesn't expose a `models.list` surface, so
|
|
911
|
+
// this check stays heuristic. The runtime error from an invalid
|
|
912
|
+
// model id is loud enough that we don't lose visibility — but a
|
|
913
|
+
// doctor-time heuristic catches obvious config typos earlier.
|
|
914
|
+
const familyOk = /^(gpt-|o\d)/i.test(critic.model.id);
|
|
915
|
+
checks.push({
|
|
916
|
+
name: "codex_model_id_family",
|
|
917
|
+
passed: familyOk,
|
|
918
|
+
detail: familyOk
|
|
919
|
+
? `${critic.model.id} matches gpt-*/o* family pattern`
|
|
920
|
+
: `${critic.model.id} does NOT match expected Codex model family (gpt-* or o*)`,
|
|
921
|
+
...(familyOk
|
|
922
|
+
? {}
|
|
923
|
+
: {
|
|
924
|
+
remediation: "the configured Codex critic's model.id should look like a Codex model (e.g., 'gpt-5.5-codex'). Update .agent-review/config.json:critics[].model.id.",
|
|
925
|
+
}),
|
|
926
|
+
});
|
|
927
|
+
return checks;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
//# sourceMappingURL=codex-sdk.js.map
|