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,631 @@
1
+ 'use strict';
2
+ /*
3
+ * scripts/providers/byo-key.js — BYO (bring-your-own) provider-key adapter
4
+ * (EXTRACT-PER-CLIENT W1 PART C).
5
+ *
6
+ * The builder's OWN key against an OpenAI-compatible chat-completions
7
+ * endpoint, or one of two thin payload variants (Anthropic Messages, Gemini
8
+ * generateContent) — selected by the `provider` field stored in
9
+ * ~/.auxilo/providers.json's `byo` object (the SAME file
10
+ * scripts/providers/index.js persists auto-detected `selected` into — one
11
+ * file, not two). Auxilo never sees this key: it is read from disk, used for
12
+ * exactly one outbound HTTP call, and never logged.
13
+ *
14
+ * providers.json shape (0600):
15
+ * {
16
+ * "selected": "byo-key", // written by index.js, untouched here
17
+ * "byo": {
18
+ * "provider": "openai"|"anthropic"|"gemini"|<custom>,
19
+ * "base_url": "https://...", // optional; vendor default if absent
20
+ * "model": "gpt-4o-mini",
21
+ * "api_key": "sk-..."
22
+ * }
23
+ * }
24
+ *
25
+ * Routing: provider 'anthropic' -> Anthropic Messages; provider 'gemini' ->
26
+ * Gemini generateContent; anything else (including 'openai' and any custom
27
+ * label with a custom base_url) -> the OpenAI-compatible chat-completions
28
+ * shape. `vendor` in the returned identity is always one of
29
+ * 'openai-compatible'|'anthropic'|'gemini' (the wire shape actually used),
30
+ * distinct from the free-form stored `provider` label.
31
+ */
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const os = require('os');
36
+
37
+ /** Same literal value as scripts/providers/index.js's PROVIDERS_STATE_PATH —
38
+ * duplicated (not imported) to avoid a circular require (index.js loads this
39
+ * module dynamically via loadOptionalProvider). test/byo-key-provider.test.js
40
+ * pins the two paths equal. */
41
+ const DEFAULT_PROVIDERS_STATE_PATH = path.join(os.homedir(), '.auxilo', 'providers.json');
42
+
43
+ const VENDOR_DEFAULT_BASE_URL = Object.freeze({
44
+ anthropic: 'https://api.anthropic.com/v1',
45
+ gemini: 'https://generativelanguage.googleapis.com/v1beta',
46
+ 'openai-compatible': 'https://api.openai.com/v1',
47
+ });
48
+
49
+ const ANTHROPIC_API_VERSION = '2023-06-01';
50
+ const DEFAULT_MAX_TOKENS = 4096;
51
+
52
+ function statePath(opts = {}) {
53
+ return opts.providersStatePath || DEFAULT_PROVIDERS_STATE_PATH;
54
+ }
55
+
56
+ /**
57
+ * true iff `target` did not resolve to an absolute path (EXTRACT-PER-CLIENT
58
+ * W1 FIX GOV-3 item 13). `os.homedir()` can — rarely — return `''` (HOME/
59
+ * USERPROFILE unset and the OS lookup fails); DEFAULT_PROVIDERS_STATE_PATH
60
+ * is a plain `path.join(os.homedir(), ...)` module constant, so that failure
61
+ * mode silently turns it into a RELATIVE path under the process's cwd — in
62
+ * this repo, a public git tree. Every entry point that touches
63
+ * providers.json (read, write, or clear) checks this first and refuses
64
+ * rather than guess a location, with reasonCode 'provider-home-unresolved'.
65
+ * A caller-supplied `opts.providersStatePath` (every test in this repo, and
66
+ * any future explicit override) is never subject to this — the caller
67
+ * chose that path deliberately.
68
+ */
69
+ function isHomeUnresolved(target) {
70
+ return typeof target !== 'string' || !target || !path.isAbsolute(target);
71
+ }
72
+
73
+ /**
74
+ * ONE writer for every providers.json write (EXTRACT-PER-CLIENT W1 FIX
75
+ * GOV-3 item 1 + should-fix item 9) — writeByoConfig, clearProvidersFile,
76
+ * AND scripts/providers/index.js's persistSelected all route through this,
77
+ * so there is exactly one place that gets the tmp+rename+chmod discipline
78
+ * right instead of three independent (and, before this fix, drifted) copies
79
+ * of it. Discipline, in order:
80
+ * 1. mkdir the parent dir 0700 (idempotent).
81
+ * 2. If a file already sits at `target`, chmod it 0600 BEFORE the
82
+ * rename lands (belt-and-suspenders — the post-rename chmod below is
83
+ * the one that actually matters for a stale `.tmp`).
84
+ * 3. Unlink any leftover `${target}.tmp` first (a crashed prior run, or a
85
+ * planted symlink), THEN create it fresh with `flag:'wx'` (O_EXCL) —
86
+ * refuses to silently reuse or follow anything already at that path.
87
+ * 4. Atomic rename tmp -> target.
88
+ * 5. chmodSync(target, 0o600) AFTER the rename. This is the literal fix
89
+ * for GOV-3 finding 1: writeFileSync's `mode` option only applies on
90
+ * CREATION, so a stale `.tmp` that survived a crash at 0644 would
91
+ * rename onto `target` and KEEP 0644, silently falsifying the
92
+ * "readable only by your user account" consent promise. The old
93
+ * `persistSelected` (scripts/providers/index.js) had this exact gap;
94
+ * this writer closes it everywhere at once.
95
+ * Throws (does not swallow) when the home directory could not be resolved
96
+ * (isHomeUnresolved) — callers decide how to surface that; see
97
+ * writeByoConfig/clearProvidersFile below and cmdProvider in
98
+ * bin/auxilo-cli.js, which catches this rather than let a raw stack out
99
+ * (should-fix item 10).
100
+ */
101
+ function writeProvidersStateAtomic(state, opts = {}) {
102
+ const target = statePath(opts);
103
+ if (isHomeUnresolved(target)) {
104
+ const err = new Error('cannot resolve ~/.auxilo/providers.json — the home directory did not resolve to an absolute path');
105
+ err.reasonCode = 'provider-home-unresolved';
106
+ throw err;
107
+ }
108
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
109
+ if (fs.existsSync(target)) fs.chmodSync(target, 0o600);
110
+ const tmp = `${target}.tmp`;
111
+ try { fs.unlinkSync(tmp); } catch { /* nothing there, or already gone — fine either way */ }
112
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600, flag: 'wx' });
113
+ fs.renameSync(tmp, target);
114
+ fs.chmodSync(target, 0o600); // the fix: applies even when the rename source was a stale, wider-mode tmp.
115
+ return target;
116
+ }
117
+
118
+ /** Read ~/.auxilo/providers.json. Never throws; missing/malformed -> {}. */
119
+ function readProvidersState(opts = {}) {
120
+ try {
121
+ const raw = fs.readFileSync(statePath(opts), 'utf8');
122
+ const parsed = JSON.parse(raw);
123
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
124
+ } catch {
125
+ return {};
126
+ }
127
+ }
128
+
129
+ /** The `byo` object, or null when absent/malformed/incomplete. */
130
+ function readByoConfig(opts = {}) {
131
+ const state = readProvidersState(opts);
132
+ const byo = state.byo;
133
+ if (!byo || typeof byo !== 'object' || Array.isArray(byo)) return null;
134
+ if (typeof byo.provider !== 'string' || !byo.provider) return null;
135
+ if (typeof byo.model !== 'string' || !byo.model) return null;
136
+ if (typeof byo.api_key !== 'string' || !byo.api_key) return null;
137
+ return {
138
+ provider: byo.provider,
139
+ base_url: typeof byo.base_url === 'string' && byo.base_url ? byo.base_url : null,
140
+ model: byo.model,
141
+ api_key: byo.api_key,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Write the `byo` object into providers.json, preserving every other
147
+ * top-level key (notably `selected`, written by index.js). 0600 tmp+rename —
148
+ * mirrors lib/installer.js's writeCredentials discipline (that module is not
149
+ * imported here to keep this file's dependency surface HTTP/fs-only, per
150
+ * source discipline; the discipline itself — chmod-then-tmp-write-then-
151
+ * rename, both 0600 — is copied, not the code).
152
+ *
153
+ * @param {{provider:string, base_url?:string|null, model:string, api_key:string}} byoConfig
154
+ */
155
+ function writeByoConfig(byoConfig, opts = {}) {
156
+ const state = readProvidersState(opts);
157
+ state.byo = {
158
+ provider: byoConfig.provider,
159
+ ...(byoConfig.base_url && { base_url: byoConfig.base_url }),
160
+ model: byoConfig.model,
161
+ api_key: byoConfig.api_key,
162
+ };
163
+ return writeProvidersStateAtomic(state, opts);
164
+ }
165
+
166
+ /**
167
+ * `clear` removes only the BYO credentials (the `byo` object) from
168
+ * providers.json — per spec, the `selected` field (written by
169
+ * scripts/providers/index.js's auto-detect path) is preserved, so clearing
170
+ * a BYO key never silently reverts or loses the account's auto-detected
171
+ * provider choice. (W1 integration: this replaces the narrower "delete the
172
+ * whole file" behavior PART C shipped as a flagged deviation.)
173
+ *
174
+ * If nothing is left after `byo` is removed (an empty object — no
175
+ * `selected` and nothing else was ever stored there), the file itself is
176
+ * removed rather than leaving an empty `{}` on disk. Otherwise the file is
177
+ * rewritten with `byo` gone via the same 0600 tmp+rename discipline as
178
+ * writeByoConfig. Never throws.
179
+ *
180
+ * `target` is checked for home-dir resolution before anything else
181
+ * (GOV-3 item 13) — an unresolved home directory returns 'unresolved'
182
+ * rather than guess a location to read/write. A stat/read failure other
183
+ * than ENOENT (e.g. EACCES) returns 'unreadable' rather than rethrow — this
184
+ * function's contract really is "never throws" now (should-fix item 10; the
185
+ * old rethrow on non-ENOENT contradicted this same docblock).
186
+ *
187
+ * @returns {'removed-file'|'removed-byo'|'noop'|'unreadable'|'unresolved'}
188
+ * 'noop' covers both "no file" and "a file with no `byo` key to clear".
189
+ */
190
+ function clearProvidersFile(opts = {}) {
191
+ const target = statePath(opts);
192
+ if (isHomeUnresolved(target)) return 'unresolved';
193
+ const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
194
+ let raw;
195
+ try {
196
+ raw = readFileSyncImpl(target, 'utf8');
197
+ } catch (err) {
198
+ if (err && err.code === 'ENOENT') return 'noop';
199
+ return 'unreadable'; // e.g. EACCES — cannot verify contents; never throws (contract)
200
+ }
201
+ let state;
202
+ try {
203
+ state = JSON.parse(raw);
204
+ } catch {
205
+ return 'noop'; // malformed JSON — nothing safely parseable to preserve or clear
206
+ }
207
+ if (!state || typeof state !== 'object' || Array.isArray(state) || !('byo' in state)) {
208
+ return 'noop';
209
+ }
210
+ delete state.byo;
211
+ if (Object.keys(state).length === 0) {
212
+ fs.unlinkSync(target);
213
+ return 'removed-file';
214
+ }
215
+ writeProvidersStateAtomic(state, opts);
216
+ return 'removed-byo';
217
+ }
218
+
219
+ /** Routing: which wire vendor a stored `provider` label maps to. */
220
+ function resolveVendor(providerLabel) {
221
+ if (providerLabel === 'anthropic') return 'anthropic';
222
+ if (providerLabel === 'gemini') return 'gemini';
223
+ return 'openai-compatible';
224
+ }
225
+
226
+ function baseUrlFor(vendor, configured) {
227
+ if (configured) return configured.replace(/\/+$/, '');
228
+ return VENDOR_DEFAULT_BASE_URL[vendor];
229
+ }
230
+
231
+ /**
232
+ * "Owner-read-only" predicate (EXTRACT-PER-CLIENT W1 FIX, PUNCH-LIST P1) —
233
+ * the runtime twin of bin/auxilo-cli.js's `providersFileModeUnsafe`, which
234
+ * only ever ran once, interactively, before `provider set` first wrote the
235
+ * file. That left a gap: if providers.json widens (a stray `chmod`, a
236
+ * umask surprise, manual editing) AFTER setup, detect()/runModel() would
237
+ * still call it "installed" and hand the builder's key to a spawn/fetch
238
+ * under insecure permissions. Duplicated (not imported) for the same reason
239
+ * DEFAULT_PROVIDERS_STATE_PATH above is duplicated — avoids a circular
240
+ * require, since bin/auxilo-cli.js requires this module, not the reverse.
241
+ * No file yet is not unsafe (writeByoConfig always writes 0600 itself); any
242
+ * OTHER stat failure (e.g. EACCES) fails CLOSED (treated as unsafe) rather
243
+ * than silently trusting a permission state it could not verify. Never
244
+ * throws.
245
+ */
246
+ function isProvidersFileModeUnsafe(opts = {}) {
247
+ const target = statePath(opts);
248
+ if (isHomeUnresolved(target)) return true; // can't even name the file — fail closed
249
+ const statSyncImpl = typeof opts.statSyncImpl === 'function' ? opts.statSyncImpl : fs.statSync;
250
+ let stat;
251
+ try {
252
+ stat = statSyncImpl(target);
253
+ } catch (err) {
254
+ if (err && err.code === 'ENOENT') return false;
255
+ return true; // cannot verify permissions — fail closed, not open
256
+ }
257
+ return (stat.mode & 0o077) !== 0;
258
+ }
259
+
260
+ /** true iff the configured base_url is present and is NOT https:// (GOV-3
261
+ * item 3 — a plaintext endpoint would send the transcript, and for two of
262
+ * the three vendors the key itself, in cleartext). An absent base_url is
263
+ * fine — the vendor default is always https (VENDOR_DEFAULT_BASE_URL). */
264
+ function isBaseUrlInsecure(baseUrl) {
265
+ if (!baseUrl) return false;
266
+ try {
267
+ return new URL(baseUrl).protocol !== 'https:';
268
+ } catch {
269
+ return true; // unparseable — treat as insecure, not as "no opinion"
270
+ }
271
+ }
272
+
273
+ /** detect(): true iff a complete BYO config is on disk, its file is
274
+ * actually owner-read-only, the home directory resolved, and any configured
275
+ * base_url is https:// — "usable now", not merely "configured once". */
276
+ function detect(opts = {}) {
277
+ if (isHomeUnresolved(statePath(opts))) return false;
278
+ if (isProvidersFileModeUnsafe(opts)) return false;
279
+ const config = readByoConfig(opts);
280
+ if (!config) return false;
281
+ return !isBaseUrlInsecure(config.base_url);
282
+ }
283
+
284
+ /** BYO has no meaningful local-auth concept — the key IS the auth. */
285
+ function checkAuthStatus() {
286
+ return 'unknown';
287
+ }
288
+
289
+ function buildRequest(vendor, config, text, opts) {
290
+ const baseUrl = baseUrlFor(vendor, config.base_url);
291
+ if (vendor === 'anthropic') {
292
+ return {
293
+ url: `${baseUrl}/messages`,
294
+ headers: {
295
+ 'Content-Type': 'application/json',
296
+ 'x-api-key': config.api_key,
297
+ 'anthropic-version': ANTHROPIC_API_VERSION,
298
+ },
299
+ body: JSON.stringify({
300
+ model: config.model,
301
+ max_tokens: opts.maxTokens || DEFAULT_MAX_TOKENS,
302
+ messages: [{ role: 'user', content: text }],
303
+ }),
304
+ };
305
+ }
306
+ if (vendor === 'gemini') {
307
+ // GOV-3 item 5: the key rides the `x-goog-api-key` header, never the URL
308
+ // query string — credentials in URLs land in proxy logs, server access
309
+ // logs, and any future error path that echoes `request.url`.
310
+ return {
311
+ url: `${baseUrl}/models/${encodeURIComponent(config.model)}:generateContent`,
312
+ headers: { 'Content-Type': 'application/json', 'x-goog-api-key': config.api_key },
313
+ body: JSON.stringify({
314
+ contents: [{ role: 'user', parts: [{ text }] }],
315
+ }),
316
+ };
317
+ }
318
+ // openai-compatible (default): OpenAI itself, or any OpenAI-compatible
319
+ // chat-completions endpoint (custom base_url).
320
+ return {
321
+ url: `${baseUrl}/chat/completions`,
322
+ headers: {
323
+ 'Content-Type': 'application/json',
324
+ Authorization: `Bearer ${config.api_key}`,
325
+ },
326
+ body: JSON.stringify({
327
+ model: config.model,
328
+ messages: [{ role: 'user', content: text }],
329
+ }),
330
+ };
331
+ }
332
+
333
+ /** Extract the model's reply text from each vendor's distinct response shape. */
334
+ function extractText(vendor, data) {
335
+ if (vendor === 'anthropic') {
336
+ const block = Array.isArray(data && data.content) ? data.content.find((b) => b && b.type === 'text') : null;
337
+ return block && typeof block.text === 'string' ? block.text : '';
338
+ }
339
+ if (vendor === 'gemini') {
340
+ const candidate = data && Array.isArray(data.candidates) ? data.candidates[0] : null;
341
+ const parts = candidate && candidate.content && Array.isArray(candidate.content.parts)
342
+ ? candidate.content.parts
343
+ : [];
344
+ return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('');
345
+ }
346
+ const choice = data && Array.isArray(data.choices) ? data.choices[0] : null;
347
+ const content = choice && choice.message && typeof choice.message.content === 'string'
348
+ ? choice.message.content
349
+ : '';
350
+ return content;
351
+ }
352
+
353
+ /** Extract usage where the vendor reports it; null otherwise (caller falls
354
+ * back to its own text-length estimate — same degrade-not-break contract
355
+ * every other provider follows, per provider.interface.js). */
356
+ function extractUsage(vendor, data) {
357
+ if (!data || typeof data !== 'object') return null;
358
+ if (vendor === 'anthropic' && data.usage) {
359
+ const inputTokens = Number(data.usage.input_tokens) || 0;
360
+ const outputTokens = Number(data.usage.output_tokens) || 0;
361
+ if (!inputTokens && !outputTokens) return null;
362
+ return { input_tokens: inputTokens, output_tokens: outputTokens };
363
+ }
364
+ if (vendor === 'openai-compatible' && data.usage) {
365
+ const inputTokens = Number(data.usage.prompt_tokens) || 0;
366
+ const outputTokens = Number(data.usage.completion_tokens) || 0;
367
+ if (!inputTokens && !outputTokens) return null;
368
+ return { input_tokens: inputTokens, output_tokens: outputTokens };
369
+ }
370
+ // Gemini's usageMetadata uses yet another field naming; folded in defensively.
371
+ if (vendor === 'gemini' && data.usageMetadata) {
372
+ const inputTokens = Number(data.usageMetadata.promptTokenCount) || 0;
373
+ const outputTokens = Number(data.usageMetadata.candidatesTokenCount) || 0;
374
+ if (!inputTokens && !outputTokens) return null;
375
+ return { input_tokens: inputTokens, output_tokens: outputTokens };
376
+ }
377
+ return null;
378
+ }
379
+
380
+ // GOV-3 should-fix item 8: cap the response body at 2MB, in both directions
381
+ // — a Content-Length that already exceeds the cap is rejected without
382
+ // reading a byte, and a body with no (or a lying) Content-Length is read in
383
+ // chunks with a running byte count, aborted the moment it crosses the cap.
384
+ // Without this, a stalling or oversized endpoint hangs or OOMs an
385
+ // unattended session-end hook.
386
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
387
+ const RESPONSE_TOO_LARGE = Symbol('provider-response-too-large');
388
+
389
+ function headerContentLength(res) {
390
+ try {
391
+ if (res && res.headers && typeof res.headers.get === 'function') {
392
+ const raw = res.headers.get('content-length');
393
+ const n = Number(raw);
394
+ return Number.isFinite(n) ? n : null;
395
+ }
396
+ } catch { /* no headers on this response — fall through to the byte-counted read */ }
397
+ return null;
398
+ }
399
+
400
+ /**
401
+ * Read `res` as JSON with the 2MB cap enforced. Prefers a byte-counted
402
+ * streaming read (undici's real fetch Response always exposes `res.body` as
403
+ * a ReadableStream) so a body with no/lying Content-Length is still capped;
404
+ * falls back to `res.json()` when the response has no stream body at all —
405
+ * covers this repo's test fixtures, which hand back a plain
406
+ * `{ok,status,json}` object, not a real Response.
407
+ */
408
+ async function readBoundedJson(res) {
409
+ const declaredLength = headerContentLength(res);
410
+ if (declaredLength !== null && declaredLength > MAX_RESPONSE_BYTES) {
411
+ const err = new Error(`response Content-Length ${declaredLength} exceeds the ${MAX_RESPONSE_BYTES}-byte cap`);
412
+ err[RESPONSE_TOO_LARGE] = true;
413
+ throw err;
414
+ }
415
+ if (!res || !res.body || typeof res.body.getReader !== 'function') {
416
+ return res.json();
417
+ }
418
+ const reader = res.body.getReader();
419
+ let total = 0;
420
+ const chunks = [];
421
+ for (;;) {
422
+ // eslint-disable-next-line no-await-in-loop
423
+ const { done, value } = await reader.read();
424
+ if (done) break;
425
+ total += value ? value.byteLength : 0;
426
+ if (total > MAX_RESPONSE_BYTES) {
427
+ try { await reader.cancel(); } catch { /* best-effort */ }
428
+ const err = new Error(`response body exceeded the ${MAX_RESPONSE_BYTES}-byte cap (byte-counted read)`);
429
+ err[RESPONSE_TOO_LARGE] = true;
430
+ throw err;
431
+ }
432
+ if (value) chunks.push(value);
433
+ }
434
+ const text = Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
435
+ return JSON.parse(text);
436
+ }
437
+
438
+ /**
439
+ * runModel(opts) — the provider.interface.js contract. `prompt`/`input`
440
+ * combine exactly like every other provider (`prompt + (input || '')`, the
441
+ * full instruction). No retries — a 429 or 5xx returns immediately with a
442
+ * matching reasonCode; the caller (extract-local.js) treats a failed
443
+ * provider as a skip, never as something to hammer.
444
+ *
445
+ * The key is READ from disk and used in exactly two places: the
446
+ * Authorization/x-api-key/x-goog-api-key header — Gemini moved off the URL
447
+ * query string, GOV-3 item 5, so ALL three vendors now carry the key in a
448
+ * header only. It is never interpolated into any log/console call in this
449
+ * module — a build-time grep test (test/byo-key-provider.test.js) asserts
450
+ * that no console or log call in this file's source can carry it.
451
+ *
452
+ * The abort timer is kept alive through the ENTIRE call, including the body
453
+ * read — GOV-3 should-fix item 8's other half: the old code cleared it in
454
+ * the `finally` of the fetch await, which resolves at headers, leaving
455
+ * `res.json()` unbounded in time. `clearTimer()` below runs on every exit
456
+ * path via the outer try/finally.
457
+ */
458
+ async function runModel(opts = {}) {
459
+ const target = statePath(opts);
460
+ if (isHomeUnresolved(target)) {
461
+ return {
462
+ ok: false,
463
+ text: '',
464
+ usage: null,
465
+ reason: 'cannot resolve ~/.auxilo/providers.json — the home directory did not resolve to an absolute path',
466
+ reasonCode: 'provider-home-unresolved',
467
+ authStatus: 'unknown',
468
+ };
469
+ }
470
+ // Checked BEFORE config completeness — a widened-permissions file is
471
+ // refused even if it happens to hold a complete config; defense in depth
472
+ // alongside detect()'s own gate above (this module's runModel may also be
473
+ // reached directly via an AUXILO_EXTRACTION_PROVIDER override, which never
474
+ // calls detect() at all).
475
+ if (isProvidersFileModeUnsafe(opts)) {
476
+ return {
477
+ ok: false,
478
+ text: '',
479
+ usage: null,
480
+ reason: '~/.auxilo/providers.json is not owner-read-only; refusing to use the stored key until its permissions are fixed (chmod 600 ~/.auxilo/providers.json) or the file is removed (`auxilo provider clear`)',
481
+ reasonCode: 'providers-file-mode-unsafe',
482
+ authStatus: 'unknown',
483
+ };
484
+ }
485
+ const config = readByoConfig(opts);
486
+ if (!config) {
487
+ return {
488
+ ok: false,
489
+ text: '',
490
+ usage: null,
491
+ reason: 'no BYO provider key configured — run `auxilo provider set`',
492
+ reasonCode: 'provider-not-configured',
493
+ authStatus: 'unknown',
494
+ };
495
+ }
496
+ // GOV-3 item 3: same override the CLI enforces at `set` time, re-checked
497
+ // here at read time — this module's runModel can be reached directly
498
+ // (AUXILO_EXTRACTION_PROVIDER override) without ever going through
499
+ // detect(), and a config could have been hand-edited on disk since `set`.
500
+ if (isBaseUrlInsecure(config.base_url)) {
501
+ return {
502
+ ok: false,
503
+ text: '',
504
+ usage: null,
505
+ reason: `configured base_url "${config.base_url}" is not https:// — refusing to send the transcript or the key over an insecure connection`,
506
+ reasonCode: 'provider-base-url-insecure',
507
+ authStatus: 'unknown',
508
+ };
509
+ }
510
+ const vendor = resolveVendor(config.provider);
511
+ const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
512
+ const text = prompt + String(opts.input || '');
513
+ const request = buildRequest(vendor, config, text, opts);
514
+ const fetchImpl = typeof opts.fetchImpl === 'function' ? opts.fetchImpl : fetch;
515
+ const timeoutMs = opts.timeoutMs || 120000;
516
+
517
+ const identity = { provider: 'byo-key', model: config.model, version: null, vendor };
518
+
519
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
520
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
521
+ const clearTimer = () => { if (timer) clearTimeout(timer); };
522
+
523
+ try {
524
+ let res;
525
+ try {
526
+ res = await fetchImpl(request.url, {
527
+ method: 'POST',
528
+ headers: request.headers,
529
+ body: request.body,
530
+ // GOV-3 item 4: a cross-host redirect would re-send the transcript
531
+ // (and, for two of three vendors, the key) to whatever the redirect
532
+ // target is, and accept its reply as the model's answer. One line.
533
+ redirect: 'error',
534
+ ...(controller && { signal: controller.signal }),
535
+ });
536
+ } catch (error) {
537
+ return {
538
+ ok: false,
539
+ text: '',
540
+ usage: null,
541
+ reason: `BYO provider request failed: ${error.message}`,
542
+ reasonCode: 'provider-error',
543
+ authStatus: 'unknown',
544
+ identity,
545
+ };
546
+ }
547
+
548
+ if (res.status === 429) {
549
+ return {
550
+ ok: false,
551
+ text: '',
552
+ usage: null,
553
+ reason: 'BYO provider rate-limited the request (HTTP 429)',
554
+ reasonCode: 'provider-rate-limited',
555
+ authStatus: 'unknown',
556
+ identity,
557
+ };
558
+ }
559
+ if (!res.ok) {
560
+ return {
561
+ ok: false,
562
+ text: '',
563
+ usage: null,
564
+ reason: `BYO provider returned HTTP ${res.status}`,
565
+ reasonCode: 'provider-error',
566
+ authStatus: 'unknown',
567
+ identity,
568
+ };
569
+ }
570
+
571
+ let data;
572
+ try {
573
+ data = await readBoundedJson(res);
574
+ } catch (error) {
575
+ if (error && error[RESPONSE_TOO_LARGE]) {
576
+ return {
577
+ ok: false,
578
+ text: '',
579
+ usage: null,
580
+ reason: `BYO provider response exceeded the ${MAX_RESPONSE_BYTES}-byte cap`,
581
+ reasonCode: 'provider-response-too-large',
582
+ authStatus: 'unknown',
583
+ identity,
584
+ };
585
+ }
586
+ return {
587
+ ok: false,
588
+ text: '',
589
+ usage: null,
590
+ reason: `BYO provider returned a non-JSON body: ${error.message}`,
591
+ reasonCode: 'provider-error',
592
+ authStatus: 'unknown',
593
+ identity,
594
+ };
595
+ }
596
+
597
+ return {
598
+ ok: true,
599
+ text: extractText(vendor, data),
600
+ usage: extractUsage(vendor, data),
601
+ reason: null,
602
+ authStatus: 'unknown',
603
+ identity,
604
+ };
605
+ } finally {
606
+ clearTimer();
607
+ }
608
+ }
609
+
610
+ module.exports = {
611
+ runModel,
612
+ detect,
613
+ checkAuthStatus,
614
+ readProvidersState,
615
+ readByoConfig,
616
+ writeByoConfig,
617
+ clearProvidersFile,
618
+ resolveVendor,
619
+ DEFAULT_PROVIDERS_STATE_PATH,
620
+ // Exported for direct unit coverage (test/byo-key-provider.test.js,
621
+ // test/extract-w1-fix2.test.js) AND for scripts/providers/index.js, which
622
+ // routes persistSelected's write through writeProvidersStateAtomic (ONE
623
+ // writer for every providers.json write, GOV-3 item 1) and checks
624
+ // isProvidersFileModeUnsafe before trusting a persisted `selected` value
625
+ // it reads (GOV-3 item 2, "read too, not just write").
626
+ isProvidersFileModeUnsafe,
627
+ isHomeUnresolved,
628
+ isBaseUrlInsecure,
629
+ writeProvidersStateAtomic,
630
+ MAX_RESPONSE_BYTES,
631
+ };