auxilo-mcp 0.9.12 → 0.9.13

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.
@@ -0,0 +1,358 @@
1
+ 'use strict';
2
+ /*
3
+ * scripts/providers/codex-cli.js — Codex CLI provider adapter
4
+ * (EXTRACT-PER-CLIENT W1 PART B).
5
+ *
6
+ * Same runModel/detect shape as claude-code.js (this dir's other provider):
7
+ * spawns `codex exec` with the transcript+prompt on stdin, reads the model's
8
+ * answer back out, and never bills Auxilo — the child runs under the
9
+ * builder's own `codex` login (or their own OPENAI_API_KEY, see detect()
10
+ * below), scrubbed of every var that could redirect billing elsewhere.
11
+ *
12
+ * Flags verified live against `codex exec --help` (codex-cli 0.144.5) before
13
+ * this module was written — see BUILD-SPEC-EXTRACT-PER-CLIENT-W1 §1/§6:
14
+ * -s, --sandbox <read-only|workspace-write|danger-full-access>
15
+ * --skip-git-repo-check (codex refuses to run outside a git repo otherwise)
16
+ * --ephemeral (no session file left behind)
17
+ * --ignore-user-config (don't load ~/.codex/config.toml — auth still
18
+ * comes from CODEX_HOME/auth.json regardless)
19
+ * --output-schema <FILE> (a JSON-Schema HINT, not a hard parser — see
20
+ * schemas/*.schema.json for the shapes)
21
+ * -o, --output-last-message <FILE> (where the final answer lands)
22
+ * [PROMPT] — `-` reads the prompt from stdin instead of argv.
23
+ */
24
+
25
+ const { spawnSync } = require('child_process');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+ const crypto = require('crypto');
30
+
31
+ const { SCRUBBED_CLIENT_ENV_VARS } = require('./claude-code.js');
32
+
33
+ const EXTRACTION_SCHEMA_PATH = path.join(__dirname, 'schemas', 'extraction-envelope.schema.json');
34
+ const JUDGE_SCHEMA_PATH = path.join(__dirname, 'schemas', 'judge-decisions.schema.json');
35
+
36
+ /** Resolve the `codex` binary — hook/launchd env may have a minimal PATH. */
37
+ function resolveCodexBin(opts = {}) {
38
+ const homeDir = typeof opts.homeDir === 'string' ? opts.homeDir : os.homedir();
39
+ const existsSync = typeof opts.existsSync === 'function' ? opts.existsSync : fs.existsSync;
40
+ const candidates = [
41
+ path.join(homeDir, '.npm-global', 'bin', 'codex'),
42
+ '/usr/local/bin/codex',
43
+ '/opt/homebrew/bin/codex',
44
+ path.join(homeDir, '.local', 'bin', 'codex'),
45
+ ];
46
+ for (const c of candidates) {
47
+ try {
48
+ if (existsSync(c)) return c;
49
+ } catch (_) { /* ignore */ }
50
+ }
51
+ // No explicit filesystem candidate — let PATH resolve the final option.
52
+ return 'codex';
53
+ }
54
+
55
+ /**
56
+ * Build the subscription/login-only environment for the codex child — the
57
+ * SAME 28-name scrub PART A's claude-code.js established (imported, not
58
+ * duplicated: one list, no drift between providers), PLUS this module never
59
+ * reads or forwards OPENAI_API_KEY itself, even though its presence in the
60
+ * builder's own ~/.codex/auth.json is not disqualifying for detect() (see
61
+ * detect() below) — an env-level OPENAI_API_KEY could silently redirect
62
+ * billing to a DIFFERENT account than the one auth.json names, so it is
63
+ * scrubbed here regardless of source.
64
+ */
65
+ function codexChildEnv() {
66
+ const childEnv = { ...process.env, AUXILO_EXTRACTING: '1' };
67
+ for (const key of SCRUBBED_CLIENT_ENV_VARS) delete childEnv[key];
68
+ delete childEnv.OPENAI_API_KEY;
69
+ return childEnv;
70
+ }
71
+
72
+ /**
73
+ * Read ~/.codex/auth.json and return its `auth_mode` string, or null when the
74
+ * file is missing, unreadable, malformed, or auth_mode is absent/falsy. Never
75
+ * throws. A truthy `OPENAI_API_KEY` alongside a truthy auth_mode is fine and
76
+ * not inspected here — that key bills the BUILDER's own account if codex ever
77
+ * uses it, which zero-inference does not forbid (it forbids Auxilo billing);
78
+ * codexChildEnv() above still scrubs it from what our own child sees.
79
+ */
80
+ function readAuthMode(opts = {}) {
81
+ const homeDir = typeof opts.homeDir === 'string' ? opts.homeDir : os.homedir();
82
+ const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
83
+ try {
84
+ const raw = readFileSyncImpl(path.join(homeDir, '.codex', 'auth.json'), 'utf8');
85
+ const parsed = JSON.parse(raw);
86
+ if (parsed && typeof parsed === 'object' && typeof parsed.auth_mode === 'string' && parsed.auth_mode) {
87
+ return parsed.auth_mode;
88
+ }
89
+ return null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * detect(): true iff BOTH (a) the `codex` binary resolves — an explicit
97
+ * filesystem candidate, or (failing that) a cheap `codex --version` probe
98
+ * confirming PATH actually resolves it — AND (b) ~/.codex/auth.json exists,
99
+ * parses, and has a truthy auth_mode. Both legs required: a binary with no
100
+ * login, or a login with no binary, both mean extraction cannot run.
101
+ */
102
+ function detect(opts = {}) {
103
+ if (!readAuthMode(opts)) return false;
104
+ const bin = typeof opts.codexBin === 'string' ? opts.codexBin : resolveCodexBin(opts);
105
+ if (bin !== 'codex') return true; // explicit filesystem candidate found
106
+ const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
107
+ try {
108
+ const res = spawnSyncImpl(bin, ['--version'], { encoding: 'utf8', timeout: 5000, maxBuffer: 65536 });
109
+ return Boolean(res && !res.error && res.status === 0);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ // ─── codex --version capture (extraction_model.version) ───────────────────
116
+ //
117
+ // codex exposes no per-call model identifier (no --json event stream is
118
+ // requested here, and -o's last-message file carries prose/schema-shaped
119
+ // output only) — the CLI build version is the honest proxy for "which codex
120
+ // build ran this extraction". Captured once per process and cached: every
121
+ // runModel() call after the first reuses the cached value, so a session that
122
+ // calls runModel() twice (extract, then judge) only pays for one version
123
+ // probe. `undefined` = not yet probed; `null` = probed, could not determine.
124
+ let cachedVersion;
125
+
126
+ function getCodexVersion(opts = {}) {
127
+ if (cachedVersion !== undefined) return cachedVersion;
128
+ const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
129
+ const bin = typeof opts.codexBin === 'string' ? opts.codexBin : resolveCodexBin(opts);
130
+ try {
131
+ const res = spawnSyncImpl(bin, ['--version'], { encoding: 'utf8', timeout: 5000, maxBuffer: 65536 });
132
+ cachedVersion = (res && !res.error && res.status === 0) ? String(res.stdout || '').trim() || null : null;
133
+ } catch {
134
+ cachedVersion = null;
135
+ }
136
+ return cachedVersion;
137
+ }
138
+
139
+ /** Test-only: reset the module-level version cache between fixtures. */
140
+ function _resetVersionCacheForTests() {
141
+ cachedVersion = undefined;
142
+ }
143
+
144
+ /**
145
+ * Where the `-o` output file lives (GOV-3 should-fix item 11). The old
146
+ * default dropped it straight into `os.tmpdir()` — a WORLD-READABLE
147
+ * directory on every POSIX system — at codex's own umask, holding
148
+ * extracted-learning text (which may itself contain scrubbed-but-sensitive
149
+ * transcript fragments) until the best-effort unlink below runs. Now it
150
+ * lands in a PRIVATE 0700 subdirectory this process creates and owns,
151
+ * `auxilo-<pid>-<rand>/`, so nothing but this user can even list the
152
+ * directory to find the file, let alone read it before the chmod below
153
+ * lands. A caller-supplied `opts.outputPath` (every test in this repo)
154
+ * bypasses directory creation entirely — that path is the caller's to
155
+ * manage, and `cleanupDir` comes back null so invoke()'s finally block does
156
+ * not try to rmdir something it did not create.
157
+ */
158
+ function makeOutputLocation(opts, mode) {
159
+ if (typeof opts.outputPath === 'string') return { outputPath: opts.outputPath, cleanupDir: null };
160
+ const mkdirSyncImpl = typeof opts.mkdirSyncImpl === 'function' ? opts.mkdirSyncImpl : fs.mkdirSync;
161
+ const rand = crypto.randomBytes(6).toString('hex');
162
+ const dir = path.join(os.tmpdir(), `auxilo-${process.pid}-${rand}`);
163
+ mkdirSyncImpl(dir, { recursive: true, mode: 0o700 });
164
+ return { outputPath: path.join(dir, `${mode}.txt`), cleanupDir: dir };
165
+ }
166
+
167
+ function classifySpawnError(error, bin) {
168
+ if (error && error.code === 'ENOENT') {
169
+ return { reasonCode: 'cli-not-installed', reason: `codex binary not found (${bin}) — run \`npm install -g @openai/codex\` or the platform's install path` };
170
+ }
171
+ if (error && (error.code === 'ETIMEDOUT' || error.code === 'ETIMEOUT')) {
172
+ return { reasonCode: 'cli-timeout', reason: `codex exec timed out (${bin}): ${error.message}` };
173
+ }
174
+ return { reasonCode: 'unknown', reason: `spawn failed (${bin}): ${error ? error.message : 'no process result'}` };
175
+ }
176
+
177
+ /**
178
+ * Shared invocation for both modes: builds argv, spawns, reads the answer
179
+ * back from the `-o` file (falling back to stdout only if that file cannot
180
+ * be read — see module comment on why: --output-last-message's own docs
181
+ * promise a file is written on a normal completion, but say nothing about a
182
+ * crash/timeout/schema-rejection path, so a defensive stdout fallback covers
183
+ * the cases where no file ever landed; text as documented is the file's
184
+ * content and stdout is treated as the exception path, never the default).
185
+ *
186
+ * The `-o` file's private-dir creation, 0600 chmod, and cleanup (GOV-3
187
+ * should-fix item 11) are handled by an outer try/finally so EVERY exit
188
+ * path — auth-not-configured, every spawn-error/timeout classification,
189
+ * non-zero exit, empty output, and the normal success path — cleans up the
190
+ * same way. `cleanupDir` is null (nothing to remove) when the caller
191
+ * supplied its own `opts.outputPath`.
192
+ */
193
+ function invoke(opts, mode) {
194
+ const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
195
+ const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
196
+ const unlinkSyncImpl = typeof opts.unlinkSyncImpl === 'function' ? opts.unlinkSyncImpl : fs.unlinkSync;
197
+ const rmdirSyncImpl = typeof opts.rmdirSyncImpl === 'function' ? opts.rmdirSyncImpl : fs.rmdirSync;
198
+ const chmodSyncImpl = typeof opts.chmodSyncImpl === 'function' ? opts.chmodSyncImpl : fs.chmodSync;
199
+ const bin = typeof opts.codexBin === 'string' ? opts.codexBin : resolveCodexBin(opts);
200
+
201
+ const authMode = readAuthMode(opts);
202
+ if (!authMode) {
203
+ return {
204
+ ok: false,
205
+ text: '',
206
+ usage: null,
207
+ reason: 'codex CLI is not authenticated in this context (run `codex login` once); skipping deterministic extraction',
208
+ reasonCode: 'cli-unauthenticated',
209
+ authStatus: 'logged-out',
210
+ };
211
+ }
212
+
213
+ const { outputPath, cleanupDir } = makeOutputLocation(opts, mode);
214
+ try {
215
+ const schemaFile = mode === 'judge' ? JUDGE_SCHEMA_PATH : EXTRACTION_SCHEMA_PATH;
216
+ const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
217
+ const stdin = prompt + String(opts.input || '');
218
+ const args = [
219
+ 'exec',
220
+ '-s', 'read-only',
221
+ '--skip-git-repo-check',
222
+ '--ephemeral',
223
+ '--ignore-user-config',
224
+ '--output-schema', schemaFile,
225
+ '-o', outputPath,
226
+ '-',
227
+ ];
228
+
229
+ let res;
230
+ try {
231
+ res = spawnSyncImpl(bin, args, {
232
+ input: stdin,
233
+ encoding: 'utf-8',
234
+ env: codexChildEnv(),
235
+ timeout: opts.timeoutMs || 120000,
236
+ maxBuffer: 20 * 1024 * 1024,
237
+ });
238
+ } catch (error) {
239
+ const classified = classifySpawnError(error, bin);
240
+ return { ok: false, text: '', usage: null, authStatus: 'unknown', ...classified };
241
+ }
242
+ if (!res) {
243
+ return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus: 'unknown' };
244
+ }
245
+ if (res.error) {
246
+ const classified = classifySpawnError(res.error, bin);
247
+ return { ok: false, text: '', usage: null, authStatus: 'unknown', ...classified };
248
+ }
249
+ // spawnSync signals a timeout via `signal` (no `error`) when the child was
250
+ // killed for exceeding `timeout` and didn't set its own exit code.
251
+ if (res.signal && res.status === null) {
252
+ return { ok: false, text: '', usage: null, reason: `codex exec timed out (${bin}), signal ${res.signal}`, reasonCode: 'cli-timeout', authStatus: 'unknown' };
253
+ }
254
+
255
+ const stdout = String(res.stdout || '');
256
+ if (/not authenticated|not logged in|codex login/i.test(stdout) || /not authenticated|not logged in|codex login/i.test(String(res.stderr || ''))) {
257
+ return { ok: false, text: '', usage: null, reason: 'codex CLI reported it is not authenticated', reasonCode: 'cli-unauthenticated', authStatus: 'unknown' };
258
+ }
259
+ if (res.status !== 0) {
260
+ return {
261
+ ok: false,
262
+ text: '',
263
+ usage: null,
264
+ reason: `codex exec exited ${res.status}: ${(stdout || String(res.stderr || '')).slice(0, 160)}`,
265
+ reasonCode: 'model-error',
266
+ authStatus: 'unknown',
267
+ };
268
+ }
269
+
270
+ // Force 0600 before reading — codex writes this file itself, under its
271
+ // own umask, which may not match. Best-effort: a file that doesn't
272
+ // exist (never written) or can't be chmod'd fails silently here and the
273
+ // read attempt right below reports the real failure.
274
+ try { chmodSyncImpl(outputPath, 0o600); } catch { /* best-effort only */ }
275
+
276
+ let text;
277
+ let usedStdoutFallback = false;
278
+ try {
279
+ text = String(readFileSyncImpl(outputPath, 'utf8'));
280
+ } catch {
281
+ // --output-last-message's own docs make no promise about a file existing
282
+ // outside a normal completion — fall back to stdout rather than
283
+ // reporting a false failure when codex exited 0 but the file is absent.
284
+ text = stdout;
285
+ usedStdoutFallback = true;
286
+ }
287
+
288
+ if (!text.trim()) {
289
+ return {
290
+ ok: false,
291
+ text: '',
292
+ usage: null,
293
+ reason: usedStdoutFallback
294
+ ? 'codex exec produced no output-last-message file and stdout was empty'
295
+ : 'codex exec produced an empty output-last-message file',
296
+ reasonCode: 'cli-bad-output',
297
+ authStatus: 'unknown',
298
+ };
299
+ }
300
+
301
+ // extraction_model kept as a DEPRECATED ALIAS of `identity` for one
302
+ // release (GATE-A item a) — resolveExtractionModelIdentity
303
+ // (scripts/extract-local.js) reads `identity`, per the
304
+ // provider.interface.js contract every other provider follows
305
+ // (byo-key.js always set `identity`; this field name mismatch was the
306
+ // bug — codex's real version never reached the stamp, falling back to
307
+ // the generic {provider,model:null,version:null,vendor:null} instead).
308
+ // test/codex-cli-provider.test.js still reads `.extraction_model`
309
+ // directly against this module's own runModel(), so the alias stays
310
+ // until that test (and any external consumer) moves to `.identity`.
311
+ const identity = {
312
+ provider: 'codex-cli',
313
+ model: null, // codex exposes no per-call model id without --json (not requested)
314
+ version: getCodexVersion(opts),
315
+ vendor: null,
316
+ };
317
+ return {
318
+ ok: true,
319
+ text,
320
+ usage: null, // the -o file carries no token counts; caller estimates from text length
321
+ reason: null,
322
+ authStatus: 'unknown',
323
+ identity,
324
+ extraction_model: identity, // deprecated alias — remove after one release
325
+ };
326
+ } finally {
327
+ // Unlink the output file unconditionally (matches the pre-existing
328
+ // contract exercised by test/codex-cli-provider.test.js even for a
329
+ // caller-supplied opts.outputPath); rmdir the private directory ONLY
330
+ // when this call created it — a caller-supplied path is the caller's
331
+ // directory to manage, never ours to remove.
332
+ try { unlinkSyncImpl(outputPath); } catch { /* best-effort cleanup only */ }
333
+ if (cleanupDir) {
334
+ try { rmdirSyncImpl(cleanupDir); } catch { /* best-effort cleanup only */ }
335
+ }
336
+ }
337
+ }
338
+
339
+ /** runModel(opts) — the provider.interface.js contract. */
340
+ async function runModel(opts = {}) {
341
+ const mode = opts.mode === 'judge' ? 'judge' : 'extract';
342
+ return invoke(opts, mode);
343
+ }
344
+
345
+ module.exports = {
346
+ runModel,
347
+ detect,
348
+ resolveCodexBin,
349
+ readAuthMode,
350
+ codexChildEnv,
351
+ getCodexVersion,
352
+ EXTRACTION_SCHEMA_PATH,
353
+ JUDGE_SCHEMA_PATH,
354
+ _resetVersionCacheForTests,
355
+ // Exported for direct unit coverage (test/extract-w1-fix2.test.js, GOV-3
356
+ // should-fix item 11 — private-dir + 0600 output file).
357
+ makeOutputLocation,
358
+ };
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+ /*
3
+ * scripts/providers/index.js — provider registry + selection
4
+ * (EXTRACT-PER-CLIENT W1 PART A; selection fall-through added in the W1 P1
5
+ * fix — PUNCH-LIST).
6
+ *
7
+ * resolveProvider(): which ONE provider to try first. Selection order:
8
+ * AUXILO_EXTRACTION_PROVIDER env override (wins unconditionally, never
9
+ * persisted — "wins, never writes") → else the persisted `selected` choice
10
+ * from ~/.auxilo/providers.json if it is STILL usable (re-verified via its
11
+ * own detect() every call, never trusted blindly — a stale persisted choice
12
+ * falls through to a full re-scan rather than failing) → else the first
13
+ * provider whose detect() is true, in fixed order (claude-code → codex-cli →
14
+ * byo-key) → else ok:false, with a reason naming every provider tried.
15
+ *
16
+ * runModel(): resolves via resolveProvider(), then actually RUNS it. A
17
+ * non-override resolution that fails with a reasonCode meaning "this
18
+ * provider cannot run at all" (NON_RETRYABLE_FOR_THIS_PROVIDER — e.g.
19
+ * unauthenticated, not installed, a billing helper is configured) falls
20
+ * through to the next provider in PROVIDER_ORDER rather than reporting a
21
+ * hard failure; a working provider that merely failed once (timeout, model
22
+ * error) does not fall through — that is still the builder's chosen
23
+ * provider having a bad run, not a reason to switch under them. An explicit
24
+ * env override never falls through, honoring the operator's explicit
25
+ * choice. Every provider exhausted → reasonCode 'no-usable-provider' with
26
+ * every attempt's reason summarized in `reason`.
27
+ *
28
+ * codex-cli.js and byo-key.js don't exist yet (PART B/C). loadOptionalProvider()
29
+ * degrades a missing module into a "not installed yet" stub so this file — and
30
+ * everything that calls into it — never throws on a fresh PART A checkout; PART
31
+ * B/C only need to add their files, not touch this registration.
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const os = require('os');
37
+
38
+ const claudeCode = require('./claude-code.js');
39
+
40
+ function notInstalledProvider(id) {
41
+ return {
42
+ id,
43
+ detect() {
44
+ return false;
45
+ },
46
+ async runModel() {
47
+ return {
48
+ ok: false,
49
+ text: '',
50
+ usage: null,
51
+ reasonCode: 'provider-not-installed',
52
+ reason: `${id} support is not installed yet`,
53
+ authStatus: 'unknown',
54
+ };
55
+ },
56
+ };
57
+ }
58
+
59
+ function loadOptionalProvider(id, modulePath) {
60
+ try {
61
+ // eslint-disable-next-line global-require, import/no-dynamic-require
62
+ return require(modulePath);
63
+ } catch (err) {
64
+ if (err && err.code === 'MODULE_NOT_FOUND') {
65
+ return notInstalledProvider(id);
66
+ }
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ const codexCli = loadOptionalProvider('codex-cli', './codex-cli.js');
72
+ const byoKey = loadOptionalProvider('byo-key', './byo-key.js');
73
+
74
+ const PROVIDER_ORDER = Object.freeze(['claude-code', 'codex-cli', 'byo-key']);
75
+ const PROVIDERS = {
76
+ 'claude-code': claudeCode,
77
+ 'codex-cli': codexCli,
78
+ 'byo-key': byoKey,
79
+ };
80
+
81
+ const PROVIDERS_STATE_PATH = path.join(os.homedir(), '.auxilo', 'providers.json');
82
+
83
+ function readProvidersState(statePath) {
84
+ try {
85
+ const raw = fs.readFileSync(statePath, 'utf8');
86
+ const parsed = JSON.parse(raw);
87
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Persist the auto-detected choice into ~/.auxilo/providers.json's `selected`
95
+ * key — the SAME file PART C's BYO-key store uses (one file, not two). Never
96
+ * called for an env-override selection (item 7: "env override always wins,
97
+ * never writes"). Persisting is a convenience for `auxilo status`, not a
98
+ * correctness requirement — failure here is swallowed.
99
+ *
100
+ * Routes through byo-key.js's writeProvidersStateAtomic — GOV-3 item 1's
101
+ * "ONE writer" for every providers.json write, so this call gets the exact
102
+ * same tmp+wx+rename+POST-RENAME-chmod discipline byo-key.js's own writers
103
+ * use, instead of a second, independently-drifted copy that (as found)
104
+ * omitted the post-rename chmod. It merges only the `selected` field — the
105
+ * `byo` object (and anything else already in the file) is read back and
106
+ * passed through UNCHANGED, never reconstructed or re-derived here, so this
107
+ * function cannot corrupt or partially rewrite credential fields it did not
108
+ * itself validate.
109
+ */
110
+ function persistSelected(providerId, opts) {
111
+ const statePath = opts.providersStatePath || PROVIDERS_STATE_PATH;
112
+ try {
113
+ if (typeof byoKey.writeProvidersStateAtomic !== 'function') return; // PART C not installed yet
114
+ const state = readProvidersState(statePath);
115
+ state.selected = providerId; // merge only this field — `byo` (if present) passes through as-read
116
+ byoKey.writeProvidersStateAtomic(state, opts);
117
+ } catch {
118
+ // Best-effort — selection still works in-process even if the write fails.
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Resolve which provider should run this call. Auto-detect results are cached
124
+ * (per the supplied `opts.providerCache` object, or a module-level default for
125
+ * real production runs) so a single extraction session detects once and reuses
126
+ * the choice across both the extraction call and the judge call — mirrors the
127
+ * one-call-per-session runner pattern (runner.js calls postExtractDetailed once
128
+ * per session; extractLocally's extract call and its anchored-judge call are the
129
+ * two runModel invocations within that one session that must agree).
130
+ * An env override is re-evaluated every call (cheap: no detect() needed) and is
131
+ * never cached or persisted.
132
+ */
133
+ const defaultCache = {};
134
+
135
+ async function resolveProvider(opts = {}) {
136
+ const env = opts.env || process.env;
137
+ const override = env.AUXILO_EXTRACTION_PROVIDER;
138
+ if (override) {
139
+ if (!Object.prototype.hasOwnProperty.call(PROVIDERS, override)) {
140
+ return {
141
+ ok: false,
142
+ reason: `AUXILO_EXTRACTION_PROVIDER="${override}" is not a known provider (expected one of: ${PROVIDER_ORDER.join(', ')})`,
143
+ };
144
+ }
145
+ return { ok: true, id: override, module: PROVIDERS[override] };
146
+ }
147
+
148
+ const cache = opts.providerCache || defaultCache;
149
+ if (cache.resolved) return cache.resolved;
150
+
151
+ // Fast path (EXTRACT-PER-CLIENT W1 FIX, PUNCH-LIST P1, item 3): the
152
+ // persisted `selected` choice — written by a prior successful resolution,
153
+ // possibly in an earlier process — skips re-probing every provider ahead
154
+ // of it in PROVIDER_ORDER when it is STILL usable (the common
155
+ // steady-state case: same builder, same login, repeated extraction
156
+ // calls). "Usable" is re-verified via that provider's own detect() every
157
+ // time, never trusted blindly from the file alone. A stale persisted
158
+ // choice does NOT fail — it logs one line and falls through to the full
159
+ // ordered scan below, which re-detects from scratch and persists (and
160
+ // caches) whatever wins.
161
+ //
162
+ // GOV-3 item 2 ("checked on WRITE only, never on READ") is satisfied HERE
163
+ // by delegation, not by a duplicate check: `persistedMod.detect(opts)`
164
+ // below, when the persisted choice is 'byo-key', IS byo-key.js's own
165
+ // detect() — which (post-fix) checks isProvidersFileModeUnsafe before
166
+ // trusting anything on disk and returns false on an unsafe file. That
167
+ // reads as "no longer usable" through the EXACT SAME stale-selection path
168
+ // already below (log one line, fall through to the full ordered scan,
169
+ // where byo-key's own detect() applies the identical check again on its
170
+ // turn). A second, generic mode check gated on the `selected` string
171
+ // alone was tried and reverted here — it broke the legitimate steady-state
172
+ // fast path for claude-code/codex-cli selections (neither of which reads
173
+ // this file's contents at all, so an insecure-mode providers.json is not
174
+ // their concern) whenever the fixture file wasn't deliberately chmod'd
175
+ // 0600 (e.g. a plain `fs.writeFileSync` at the OS umask). Gating strictly
176
+ // on "is the persisted provider byo-key" would be correct but is also a
177
+ // no-op — byo-key.detect() already returns false in that case, which is
178
+ // exactly what the fast path's existing fallthrough handles.
179
+ const log = typeof opts.log === 'function' ? opts.log : console.error;
180
+ const statePath = opts.providersStatePath || PROVIDERS_STATE_PATH;
181
+ const persistedState = readProvidersState(statePath);
182
+ const persistedId = persistedState.selected;
183
+ if (typeof persistedId === 'string' && Object.prototype.hasOwnProperty.call(PROVIDERS, persistedId)) {
184
+ const persistedMod = PROVIDERS[persistedId];
185
+ let stillUsable = false;
186
+ try {
187
+ stillUsable = await persistedMod.detect(opts);
188
+ } catch {
189
+ stillUsable = false;
190
+ }
191
+ if (stillUsable) {
192
+ const resolved = { ok: true, id: persistedId, module: persistedMod };
193
+ cache.resolved = resolved;
194
+ return resolved;
195
+ }
196
+ log(`[providers] persisted selection "${persistedId}" is no longer usable; re-detecting`);
197
+ }
198
+
199
+ const tried = [];
200
+ for (const id of PROVIDER_ORDER) {
201
+ const mod = PROVIDERS[id];
202
+ tried.push(id);
203
+ let available = false;
204
+ try {
205
+ available = await mod.detect(opts);
206
+ } catch {
207
+ available = false;
208
+ }
209
+ if (available) {
210
+ const resolved = { ok: true, id, module: mod };
211
+ cache.resolved = resolved;
212
+ persistSelected(id, opts);
213
+ return resolved;
214
+ }
215
+ }
216
+ return { ok: false, reason: `no extraction model provider available — tried: ${tried.join(', ')}` };
217
+ }
218
+
219
+ /**
220
+ * reasonCodes meaning "this provider cannot run at all right now" — safe to
221
+ * try the NEXT provider in PROVIDER_ORDER rather than reporting a hard
222
+ * failure (EXTRACT-PER-CLIENT W1 FIX, PUNCH-LIST P1, item 2). Everything
223
+ * else (timeouts, model errors, malformed output, rate limits) means the
224
+ * chosen provider DID run and failed on THIS call — that is not a signal to
225
+ * silently switch providers out from under the builder, so those propagate
226
+ * as-is (a working provider that failed once is still the builder's chosen
227
+ * provider).
228
+ */
229
+ const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
230
+ 'cli-unauthenticated',
231
+ 'cli-not-installed',
232
+ 'cli-billing-helper-configured',
233
+ 'provider-not-configured',
234
+ 'providers-file-mode-unsafe',
235
+ ]);
236
+
237
+ /**
238
+ * runModel(opts) — resolve a starting provider via resolveProvider(), then
239
+ * walk PROVIDER_ORDER from there, calling each candidate's OWN runModel()
240
+ * (never a separate detect() pre-check — a provider's runModel() already
241
+ * performs the equivalent authoritative check internally and returns a
242
+ * specific, accurate reason, so a second detect() call would only add a
243
+ * redundant probe without adding information). Falls through to the next
244
+ * provider when the current one's failure reasonCode is in
245
+ * NON_RETRYABLE_FOR_THIS_PROVIDER; when resolveProvider itself found no
246
+ * usable provider at all, the walk starts at PROVIDER_ORDER's beginning so
247
+ * every provider still gets an actual runModel() call and contributes its
248
+ * own reason (not just a single generic "no provider available"). An
249
+ * explicit AUXILO_EXTRACTION_PROVIDER override never falls through — it is
250
+ * the operator's explicit choice, so its own failure reason is reported
251
+ * as-is. When every provider tried is exhausted, returns reasonCode
252
+ * 'no-usable-provider' with a bounded summary of every provider's reason in
253
+ * `reason` (no secrets — each provider's own reason string is already
254
+ * secret-free by contract). Never throws.
255
+ */
256
+ async function runModel(opts = {}) {
257
+ const mode = opts.mode === 'judge' ? 'judge' : 'extract';
258
+ const env = opts.env || process.env;
259
+ const override = env.AUXILO_EXTRACTION_PROVIDER;
260
+
261
+ if (override) {
262
+ const resolved = await resolveProvider(opts);
263
+ if (!resolved.ok) {
264
+ return {
265
+ ok: false,
266
+ text: '',
267
+ usage: null,
268
+ reasonCode: 'no-model-provider-available',
269
+ reason: resolved.reason,
270
+ authStatus: 'unknown',
271
+ };
272
+ }
273
+ return resolved.module.runModel({ ...opts, mode });
274
+ }
275
+
276
+ const log = typeof opts.log === 'function' ? opts.log : console.error;
277
+ const resolved = await resolveProvider(opts);
278
+ const startIdx = resolved.ok ? PROVIDER_ORDER.indexOf(resolved.id) : -1;
279
+ const order = startIdx === -1 ? PROVIDER_ORDER.slice() : PROVIDER_ORDER.slice(startIdx);
280
+
281
+ const attempts = [];
282
+ for (const id of order) {
283
+ const mod = PROVIDERS[id];
284
+ // eslint-disable-next-line no-await-in-loop
285
+ const result = await mod.runModel({ ...opts, mode });
286
+ if (result.ok) return result;
287
+ attempts.push({ id, reasonCode: result.reasonCode, reason: result.reason });
288
+ if (!NON_RETRYABLE_FOR_THIS_PROVIDER.has(result.reasonCode)) {
289
+ return result;
290
+ }
291
+ log(`[providers] ${id} unusable (${result.reasonCode}); trying next provider`);
292
+ }
293
+
294
+ const summary = attempts
295
+ .map((a) => `${a.id}=${a.reasonCode || 'unknown'}`)
296
+ .join('; ')
297
+ .slice(0, 480);
298
+ const lastAttempt = attempts[attempts.length - 1];
299
+ const lastReason = (lastAttempt && lastAttempt.reason) || resolved.reason || 'no provider available';
300
+ return {
301
+ ok: false,
302
+ text: '',
303
+ usage: null,
304
+ reasonCode: 'no-usable-provider',
305
+ reason: `${lastReason} (tried: ${summary})`.slice(0, 600),
306
+ authStatus: 'unknown',
307
+ };
308
+ }
309
+
310
+ module.exports = {
311
+ runModel,
312
+ resolveProvider,
313
+ PROVIDER_ORDER,
314
+ PROVIDERS_STATE_PATH,
315
+ NON_RETRYABLE_FOR_THIS_PROVIDER,
316
+ };