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.
- package/README.md +8 -8
- package/bin/auxilo-cli.js +356 -14
- package/lib/installer.js +40 -0
- package/mcp-server.js +8 -8
- package/package.json +2 -1
- package/scripts/extract-local.js +107 -201
- package/scripts/providers/byo-key.js +631 -0
- package/scripts/providers/claude-code.js +477 -0
- package/scripts/providers/codex-cli.js +358 -0
- package/scripts/providers/index.js +316 -0
- package/scripts/providers/provider.interface.js +98 -0
- package/scripts/providers/schemas/extraction-envelope.schema.json +42 -0
- package/scripts/providers/schemas/judge-decisions.schema.json +20 -0
- package/scripts/runner.js +32 -0
|
@@ -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
|
+
};
|