auxilo-mcp 0.9.13 → 0.9.15

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/bin/auxilo-cli.js CHANGED
@@ -167,7 +167,7 @@ const CONSENT_TEXT = `
167
167
  • SCRUBS it locally (sensitivity filter: API keys, tokens, emails, PII
168
168
  are redacted first),
169
169
  • EXTRACTS reusable learnings locally through the first model client you
170
- have installed (Claude Code, then Codex) or, when neither is
170
+ are signed in to (Claude Code, then Codex) or, when neither is
171
171
  available, a provider key you set yourself. For this step your
172
172
  scrubbed transcript goes only to that provider, under your own
173
173
  account with them, and any use is charged to that account, never to
@@ -1402,10 +1402,13 @@ async function cmdProvider(flags) {
1402
1402
  return;
1403
1403
  }
1404
1404
 
1405
- // writeByoConfig throws ONLY on an unresolved home directory (GOV-3 item
1406
- // 13) caught here so that reaches a clean reason + exit(1), never a raw
1407
- // stack (should-fix item 10), matching the fail-closed contract every
1408
- // other providers.json entry point in this file now follows.
1405
+ // writeByoConfig throws on an unresolved home directory (GOV-3 item 13)
1406
+ // OR, since EXTRACTION-LOW-FOLLOWUPS item 4, on an existing providers.json
1407
+ // path that is not a regular file owned by this account (a symlink or a
1408
+ // foreign owner) both caught here so they reach a clean reason +
1409
+ // exit(1), never a raw stack (should-fix item 10), matching the
1410
+ // fail-closed contract every other providers.json entry point in this
1411
+ // file now follows.
1409
1412
  let written;
1410
1413
  try {
1411
1414
  written = byoKeyProvider.writeByoConfig({
@@ -1419,6 +1422,10 @@ async function cmdProvider(flags) {
1419
1422
  console.error(`auxilo provider set could not resolve your home directory (reasonCode: provider-home-unresolved). ${err.message}`);
1420
1423
  process.exit(1);
1421
1424
  }
1425
+ if (err && err.reasonCode === 'provider-state-target-unsafe') {
1426
+ console.error(`auxilo provider set refuses to continue: ${err.message} (reasonCode: provider-state-target-unsafe). Remove or replace it, then try again.`);
1427
+ process.exit(1);
1428
+ }
1422
1429
  throw err;
1423
1430
  }
1424
1431
  console.log(`\n✓ Saved to ${written} (mode 0600). This machine will use your own ${vendor} key for extraction when no earlier provider in the order is available.`);
@@ -14,7 +14,16 @@ const os = require('node:os');
14
14
  const path = require('node:path');
15
15
  const { findNearDuplicate, scoreChannels } = require('./similarity.js');
16
16
 
17
- const DEFAULT_INDEX_PATH = path.join(os.homedir(), '.auxilo', 'extracted-index.jsonl');
17
+ // TEST-HOME-ISOLATION incident 2: same AUXILO_HOME-over-os.homedir() fallback
18
+ // as scripts/providers/index.js's PROVIDERS_STATE_PATH and
19
+ // scripts/providers/byo-key.js's DEFAULT_PROVIDERS_STATE_PATH (duplicated,
20
+ // not imported — see those files' docblocks). This makes the default resolve
21
+ // through the same seam isolation already uses elsewhere, so a caller that
22
+ // forgets an explicit indexPath is still isolated by construction whenever
23
+ // AUXILO_HOME is set (scripts/test/run-isolated.js sets both AUXILO_HOME and
24
+ // HOME, so this is defense-in-depth, not the primary guard — every call site
25
+ // should still pass an explicit indexPath; see test/no-real-home-writes.test.js).
26
+ const DEFAULT_INDEX_PATH = path.join(process.env.AUXILO_HOME || os.homedir(), '.auxilo', 'extracted-index.jsonl');
18
27
  const HYDRATION_PAGE_SIZE = 500;
19
28
  const PROMPT_MEMORY_MAX_TOKENS = 1200;
20
29
  const PROMPT_MEMORY_MAX_ROWS = 40;
package/mcp-server.js CHANGED
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
198
198
  }
199
199
 
200
200
  const server = new Server(
201
- { name: 'auxilo', version: '0.9.13' },
201
+ { name: 'auxilo', version: '0.9.15' },
202
202
  {
203
203
  capabilities: { tools: {} },
204
204
  instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "mcpName": "io.github.silent-architects/auxilo",
5
5
  "description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
6
6
  "main": "mcp-server.js",
@@ -32,7 +32,8 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "start": "node mcp-server.js",
35
- "test": "node --test test/*.test.js && node tests/test-mobile-nav-overlay.js"
35
+ "test": "node scripts/test/run-isolated.js",
36
+ "test:host": "node scripts/test/run-host.js"
36
37
  },
37
38
  "keywords": [
38
39
  "mcp",
@@ -401,13 +401,24 @@ function judgeUsage(usage, prompt, completion) {
401
401
  /**
402
402
  * PART C — resolve the extraction_model identity for a runModel result.
403
403
  * Prefers the additive `identity` field a provider's runModel result may
404
- * carry (byo-key.js always sets one: {provider:'byo-key', model, version,
405
- * vendor}). claude-code.js/codex-cli.js shipped (PART A/B) before this stamp
406
- * existed and don't set one — rather than touch those provider modules
407
- * (outside this part's disjoint file scope, AGENTS.md's one-build rule),
408
- * this falls back to the resolved provider id alone (model/version/vendor
409
- * null) so every provider gets SOME stamp, never silently none. Best-effort:
410
- * a resolution failure here must never block extraction itself.
404
+ * carry. Current state (post Gate-A item a): byo-key.js always sets one
405
+ * ({provider:'byo-key', model, version:null, vendor}); codex-cli.js sets one
406
+ * on success ({provider:'codex-cli', model:null, version:<codex --version>,
407
+ * vendor:null} its result also carries the same object under the
408
+ * deprecated `extraction_model` alias, kept for one release only for
409
+ * test/codex-cli-provider.test.js). claude-code.js is the one provider that
410
+ * still sets no `identity` on its result that's the case this function's
411
+ * fallback exists for: it re-resolves via providers.resolveProvider() and
412
+ * stamps {provider: resolved.id, model: null, version: null, vendor: null},
413
+ * so every provider gets SOME stamp, never silently none. That re-resolution
414
+ * walks scripts/providers/index.js's PROVIDER_ORDER (claude-code →
415
+ * codex-cli → byo-key); resolveProvider/runModel there fall through from one
416
+ * provider to the next only on a NON_RETRYABLE_FOR_THIS_PROVIDER reasonCode
417
+ * (unauthenticated, not installed, a billing helper configured, an
418
+ * unconfigured BYO key, or an unsafe providers.json mode) — a provider that
419
+ * merely failed once (a timeout, a model error) is not retried under a
420
+ * different one. Best-effort throughout: a resolution failure here must
421
+ * never block extraction itself.
411
422
  */
412
423
  async function resolveExtractionModelIdentity(runModelResult, opts) {
413
424
  if (runModelResult && runModelResult.identity && typeof runModelResult.identity === 'object') {
@@ -447,6 +458,12 @@ async function defaultInvokeModel(transcript, invokeOpts, opts) {
447
458
  authStatus: result.authStatus,
448
459
  extractionModel: await resolveExtractionModelIdentity(result, opts),
449
460
  ...(result.authDiscrepancy !== undefined && { authDiscrepancy: result.authDiscrepancy }),
461
+ // EXTRACTION-RUN-LOG (0.9.15): additive passthrough for the one-line-per-run
462
+ // provider summary logged at the end of extractLocally() below. Only
463
+ // claude-code's runModel() currently sets these (argv/cliVersion); other
464
+ // providers simply omit them, which the log builder renders as 'n/a'.
465
+ ...(result.argv !== undefined && { argv: result.argv }),
466
+ ...(result.cliVersion !== undefined && { cliVersion: result.cliVersion }),
450
467
  };
451
468
  }
452
469
 
@@ -465,7 +482,20 @@ function defaultInvokeJudge(opts) {
465
482
  log: opts.log,
466
483
  mode: 'judge',
467
484
  });
468
- return { ok: result.ok, out: result.text, usage: result.usage, reason: result.reason };
485
+ return {
486
+ ok: result.ok,
487
+ out: result.text,
488
+ usage: result.usage,
489
+ reason: result.reason,
490
+ // EXTRACTION-RUN-LOG (0.9.15): threaded through so runAnchoredJudge below
491
+ // can report the judge call's status/argv/CLI version on the run summary
492
+ // log line. Additive — every existing invokeJudge stub in this repo's
493
+ // tests returns a subset of {ok,out,usage,reason} and these three simply
494
+ // come back undefined for them, changing nothing they assert.
495
+ reasonCode: result.reasonCode,
496
+ argv: result.argv,
497
+ cliVersion: result.cliVersion,
498
+ };
469
499
  };
470
500
  }
471
501
 
@@ -505,12 +535,26 @@ function parseJudgeDecisions(raw, candidates, rankings) {
505
535
 
506
536
  async function runAnchoredJudge(candidates, indexState, opts = {}) {
507
537
  const input = Array.isArray(candidates) ? candidates : [];
538
+ // judgeAttempted/judgeSucceeded (EXTRACTION-RUN-LOG, 0.9.15): additive
539
+ // status fields for the provider-run log line only — every field already on
540
+ // `empty` (kept/dropped/called/prompt_tokens/completion_tokens) keeps its
541
+ // exact pre-existing meaning and every existing caller's assertions on those
542
+ // fields are unaffected. judgeAttempted=true means invokeJudge was actually
543
+ // called (regardless of outcome); judgeSucceeded=true means it returned
544
+ // ok:true AND its output parsed into real decisions (the one path that
545
+ // actually judged anything, as opposed to failing open and keeping
546
+ // everything). This is a finer distinction than `called` — `called` is
547
+ // true even for a malformed-JSON response (an attempted-and-failed case) —
548
+ // so the log builder uses these two instead of `called` to tell
549
+ // "ran"/"failed"/"skipped(no-candidates)" apart accurately.
508
550
  const empty = {
509
551
  kept: input.slice(),
510
552
  dropped: [],
511
553
  called: false,
512
554
  prompt_tokens: 0,
513
555
  completion_tokens: 0,
556
+ judgeAttempted: false,
557
+ judgeSucceeded: false,
514
558
  };
515
559
  if (!input.length || !indexState || !indexState.usable ||
516
560
  !Array.isArray(indexState.rows) || !indexState.rows.length) {
@@ -527,17 +571,29 @@ async function runAnchoredJudge(candidates, indexState, opts = {}) {
527
571
  result = await invokeJudge(prompt, { candidates: input, rankings });
528
572
  } catch (error) {
529
573
  loudLocal(opts, `anchored judge unavailable; keeping all candidates: ${error.message}`);
530
- return empty;
574
+ return { ...empty, judgeAttempted: true };
531
575
  }
532
576
  if (!result || !result.ok) {
533
577
  loudLocal(opts, `anchored judge unavailable; keeping all candidates: ${result && result.reason ? result.reason : 'unknown error'}`);
534
- return empty;
578
+ return {
579
+ ...empty,
580
+ judgeAttempted: true,
581
+ judgeReasonCode: result && result.reasonCode,
582
+ judgeArgv: result && result.argv,
583
+ judgeCliVersion: result && result.cliVersion,
584
+ };
535
585
  }
536
586
  const parsed = parseJudgeDecisions(result.out, input, rankings);
537
587
  const usage = judgeUsage(result.usage, prompt, result.out);
588
+ const judgeMeta = {
589
+ judgeAttempted: true,
590
+ judgeReasonCode: result.reasonCode,
591
+ judgeArgv: result.argv,
592
+ judgeCliVersion: result.cliVersion,
593
+ };
538
594
  if (!parsed.ok) {
539
595
  loudLocal(opts, `anchored judge malformed; keeping all candidates: ${parsed.reason}`);
540
- return { ...empty, called: true, ...usage };
596
+ return { ...empty, called: true, ...usage, ...judgeMeta, judgeSucceeded: false };
541
597
  }
542
598
 
543
599
  const kept = [];
@@ -564,7 +620,7 @@ async function runAnchoredJudge(candidates, indexState, opts = {}) {
564
620
  kept.push(input[index]);
565
621
  }
566
622
  }
567
- return { kept, dropped, called: true, ...usage };
623
+ return { kept, dropped, called: true, ...usage, ...judgeMeta, judgeSucceeded: true };
568
624
  }
569
625
 
570
626
  /**
@@ -619,6 +675,68 @@ function immutableSet(ids) {
619
675
 
620
676
  const EXTRACTABLE_SOURCES = immutableSet(EXTRACTABLE_SOURCE_IDS);
621
677
 
678
+ // ─── Provider-run log line (EXTRACTION-RUN-LOG, PUNCH-LIST P3, 0.9.15) ─────
679
+ //
680
+ // One line per extractLocally() run, written to the runner's log at the point
681
+ // each run completes — never prompt/transcript content. reasonCode/argv/
682
+ // cliVersion are provider-internal diagnostics only (every provider's own
683
+ // `reason` string is secret-free by the same contract runModel() documents in
684
+ // scripts/providers/index.js), so this line carries no learning text.
685
+ const PRE_SPAWN_SKIP_REASON_CODES = new Set([
686
+ 'cli-unauthenticated',
687
+ 'cli-not-installed',
688
+ 'cli-billing-helper-configured',
689
+ 'cli-settings-isolation-unsupported',
690
+ 'provider-not-configured',
691
+ 'providers-file-mode-unsafe',
692
+ 'provider-not-installed',
693
+ 'no-usable-provider',
694
+ 'no-model-provider-available',
695
+ ]);
696
+
697
+ function formatArgvForLog(argv) {
698
+ if (!Array.isArray(argv) || !argv.length) return 'n/a';
699
+ return argv.map((arg) => (arg === '' ? "''" : arg)).join(' ');
700
+ }
701
+
702
+ /**
703
+ * `finder` and `judge` report whether each call was actually attempted
704
+ * (spawned) this run, not whether it succeeded — a spawn that ran and then
705
+ * hit a model error still counts as "ran" (it happened; the failure is in
706
+ * `reason`, not in whether isolation applied). `hooks` is `claude-code`-
707
+ * specific: 'isolated' whenever a claude-code spawn this run carried
708
+ * --setting-sources (the only state a spawn can be in per the fail-closed
709
+ * gate in scripts/providers/claude-code.js — it never spawns without the
710
+ * flag), 'unsupported' when the CLI was found not to support the flag at
711
+ * all, 'n/a' for a non-claude-code provider (codex-cli/byo-key isolate by a
712
+ * different mechanism entirely, out of this row's scope).
713
+ */
714
+ function logProviderRunSummary(opts, runId, modelResult, judged) {
715
+ try {
716
+ const log = typeof opts.log === 'function' ? opts.log : console.error;
717
+ const provider = (modelResult.extractionModel && modelResult.extractionModel.provider) || 'unknown';
718
+ const finderRan = Boolean(modelResult.ok) || !PRE_SPAWN_SKIP_REASON_CODES.has(modelResult.reasonCode);
719
+ const finder = finderRan ? 'ran' : 'skipped';
720
+ let judgeState;
721
+ if (!judged || !judged.judgeAttempted) judgeState = 'skipped(no-candidates)';
722
+ else if (judged.judgeSucceeded) judgeState = 'ran';
723
+ else judgeState = 'failed';
724
+ const argv = (finderRan && modelResult.argv) || (judged && judged.judgeArgv) || null;
725
+ const cliVersion = modelResult.cliVersion || (judged && judged.judgeCliVersion) || null;
726
+ const finderUnsupported = modelResult.reasonCode === 'cli-settings-isolation-unsupported';
727
+ const judgeUnsupported = Boolean(judged && judged.judgeReasonCode === 'cli-settings-isolation-unsupported');
728
+ const hooks = provider === 'claude-code'
729
+ ? ((finderUnsupported || judgeUnsupported) ? 'unsupported' : 'isolated')
730
+ : 'n/a';
731
+ log(
732
+ `[providers] run=${runId || 'unknown'} provider=${provider} cli=${cliVersion || '-'} ` +
733
+ `finder=${finder} judge=${judgeState} flags=${formatArgvForLog(argv)} hooks=${hooks}`
734
+ );
735
+ } catch {
736
+ // Logging must never block or fail extraction.
737
+ }
738
+ }
739
+
622
740
  async function extractLocally(transcript, sourceType, opts = {}) {
623
741
  if (sourceType && !EXTRACTABLE_SOURCES.has(sourceType)) {
624
742
  return { learnings: [], skipped: `local extraction not implemented for "${sourceType}" — agent contributes via auxilo_contribute` };
@@ -660,6 +778,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
660
778
  const modelResult = await invokeModel(transcript, { prompt });
661
779
  const { ok, out, reason } = modelResult;
662
780
  if (!ok) {
781
+ logProviderRunSummary(opts, opts.runId, modelResult, null);
663
782
  return {
664
783
  learnings: [],
665
784
  skipped: reason,
@@ -726,6 +845,7 @@ async function extractLocally(transcript, sourceType, opts = {}) {
726
845
  `lexical_filter=${lexicalDropped.length}, anchored_judge=${judged.dropped.length}`
727
846
  );
728
847
  }
848
+ logProviderRunSummary(opts, opts.runId, modelResult, judged);
729
849
  return {
730
850
  learnings: judged.kept,
731
851
  dedup_dropped: allDropped.length,
@@ -753,4 +873,6 @@ module.exports = {
753
873
  extractWithClaudeCode: claudeCodeProvider.extractWithClaudeCode,
754
874
  checkClaudeAuthStatus: claudeCodeProvider.checkClaudeAuthStatus,
755
875
  resolveClaudeBin: claudeCodeProvider.resolveClaudeBin,
876
+ // EXTRACTION-RUN-LOG (0.9.15) — exported for direct unit coverage.
877
+ formatArgvForLog, logProviderRunSummary, PRE_SPAWN_SKIP_REASON_CODES,
756
878
  };
@@ -37,8 +37,23 @@ const os = require('os');
37
37
  /** Same literal value as scripts/providers/index.js's PROVIDERS_STATE_PATH —
38
38
  * duplicated (not imported) to avoid a circular require (index.js loads this
39
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');
40
+ * pins the two paths equal.
41
+ *
42
+ * TEST-HOME-ISOLATION: `AUXILO_HOME`, when set, wins over `os.homedir()` —
43
+ * a dedicated override for auxilo's own state directory, distinct from the
44
+ * general-purpose `HOME` every other os.homedir()-based path in this repo
45
+ * reads (settings.json, the VERSION stamp, credentials.json, ...). Per-call
46
+ * `opts.providersStatePath` (every test in this repo passes one) still wins
47
+ * over BOTH — this only narrows what an omitted opts falls back to, closing
48
+ * the gap that let a bare `bin/auxilo-cli.js provider status|clear` call (no
49
+ * opts seam of its own — see cmdProvider in bin/auxilo-cli.js) or any future
50
+ * test that forgets its own override reach the real ~/.auxilo/providers.json
51
+ * (TEST-HOME-ISOLATION incident, 2026-09-06). Evaluated once at module load,
52
+ * same as before — every entry point that cares (scripts/test/run-isolated.js
53
+ * for `npm test`, scripts/check-test-count.sh for the CI gate) sets both
54
+ * AUXILO_HOME and HOME before node starts, so this still resolves correctly
55
+ * even though it's a frozen constant, not a per-call lookup. */
56
+ const DEFAULT_PROVIDERS_STATE_PATH = path.join(process.env.AUXILO_HOME || os.homedir(), '.auxilo', 'providers.json');
42
57
 
43
58
  const VENDOR_DEFAULT_BASE_URL = Object.freeze({
44
59
  anthropic: 'https://api.anthropic.com/v1',
@@ -70,6 +85,33 @@ function isHomeUnresolved(target) {
70
85
  return typeof target !== 'string' || !target || !path.isAbsolute(target);
71
86
  }
72
87
 
88
+ /**
89
+ * true iff something already sits at `target` and it is NOT a plain regular
90
+ * file owned by this process's own uid (EXTRACTION-LOW-FOLLOWUPS item 4).
91
+ * Guards the pre-rename chmod below: chmod follows symlinks, so a planted
92
+ * symlink at `target` (pointing at, say, another account's file, or a
93
+ * device node) would previously get its chmod(0600) applied to whatever it
94
+ * points at, not to providers.json itself — same-uid threat model only
95
+ * (matches item 2's TOCTOU acceptance above), but a fail-closed lstat is a
96
+ * one-line guard against it. ENOENT (nothing there yet) is safe — the write
97
+ * below creates it fresh with O_EXCL. Any other stat failure, a non-file
98
+ * (symlink/dir/fifo/device), or a foreign owner all fail CLOSED (refuse),
99
+ * never silently proceed.
100
+ */
101
+ function isUnsafeExistingTarget(target, opts = {}) {
102
+ const lstatSyncImpl = typeof opts.lstatSyncImpl === 'function' ? opts.lstatSyncImpl : fs.lstatSync;
103
+ let stat;
104
+ try {
105
+ stat = lstatSyncImpl(target);
106
+ } catch (err) {
107
+ if (err && err.code === 'ENOENT') return false; // nothing there — nothing to protect
108
+ return true; // cannot verify what's there — fail closed
109
+ }
110
+ if (!stat.isFile()) return true; // symlink, directory, fifo, device, … — never chmod/rename onto it
111
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return true; // foreign owner
112
+ return false;
113
+ }
114
+
73
115
  /**
74
116
  * ONE writer for every providers.json write (EXTRACT-PER-CLIENT W1 FIX
75
117
  * GOV-3 item 1 + should-fix item 9) — writeByoConfig, clearProvidersFile,
@@ -78,14 +120,19 @@ function isHomeUnresolved(target) {
78
120
  * right instead of three independent (and, before this fix, drifted) copies
79
121
  * of it. Discipline, in order:
80
122
  * 1. mkdir the parent dir 0700 (idempotent).
81
- * 2. If a file already sits at `target`, chmod it 0600 BEFORE the
123
+ * 2. lstat whatever already sits at `target` and refuse (reasonCode
124
+ * 'provider-state-target-unsafe') if it exists and is not a regular
125
+ * file owned by this uid (EXTRACTION-LOW-FOLLOWUPS item 4) — checked
126
+ * BEFORE the chmod below, which would otherwise follow a planted
127
+ * symlink.
128
+ * 3. If a file already sits at `target`, chmod it 0600 BEFORE the
82
129
  * rename lands (belt-and-suspenders — the post-rename chmod below is
83
130
  * the one that actually matters for a stale `.tmp`).
84
- * 3. Unlink any leftover `${target}.tmp` first (a crashed prior run, or a
131
+ * 4. Unlink any leftover `${target}.tmp` first (a crashed prior run, or a
85
132
  * planted symlink), THEN create it fresh with `flag:'wx'` (O_EXCL) —
86
133
  * 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
134
+ * 5. Atomic rename tmp -> target.
135
+ * 6. chmodSync(target, 0o600) AFTER the rename. This is the literal fix
89
136
  * for GOV-3 finding 1: writeFileSync's `mode` option only applies on
90
137
  * CREATION, so a stale `.tmp` that survived a crash at 0644 would
91
138
  * rename onto `target` and KEEP 0644, silently falsifying the
@@ -93,7 +140,8 @@ function isHomeUnresolved(target) {
93
140
  * `persistSelected` (scripts/providers/index.js) had this exact gap;
94
141
  * this writer closes it everywhere at once.
95
142
  * Throws (does not swallow) when the home directory could not be resolved
96
- * (isHomeUnresolved) callers decide how to surface that; see
143
+ * (isHomeUnresolved) or the existing target is unsafe (reasonCode
144
+ * 'provider-state-target-unsafe') — callers decide how to surface that; see
97
145
  * writeByoConfig/clearProvidersFile below and cmdProvider in
98
146
  * bin/auxilo-cli.js, which catches this rather than let a raw stack out
99
147
  * (should-fix item 10).
@@ -106,6 +154,11 @@ function writeProvidersStateAtomic(state, opts = {}) {
106
154
  throw err;
107
155
  }
108
156
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
157
+ if (isUnsafeExistingTarget(target, opts)) {
158
+ const err = new Error('refusing to write ~/.auxilo/providers.json — an existing entry at that path is not a regular file owned by this account (possible symlink or foreign owner)');
159
+ err.reasonCode = 'provider-state-target-unsafe';
160
+ throw err;
161
+ }
109
162
  if (fs.existsSync(target)) fs.chmodSync(target, 0o600);
110
163
  const tmp = `${target}.tmp`;
111
164
  try { fs.unlinkSync(tmp); } catch { /* nothing there, or already gone — fine either way */ }
@@ -150,6 +203,12 @@ function readByoConfig(opts = {}) {
150
203
  * source discipline; the discipline itself — chmod-then-tmp-write-then-
151
204
  * rename, both 0600 — is copied, not the code).
152
205
  *
206
+ * Throws a tagged error (writeProvidersStateAtomic) on 'provider-home-
207
+ * unresolved' OR, since EXTRACTION-LOW-FOLLOWUPS item 4, on
208
+ * 'provider-state-target-unsafe' (an existing entry at the target path is
209
+ * a symlink or owned by a different uid) — cmdProvider in bin/auxilo-cli.js
210
+ * catches both rather than let a raw stack out.
211
+ *
153
212
  * @param {{provider:string, base_url?:string|null, model:string, api_key:string}} byoConfig
154
213
  */
155
214
  function writeByoConfig(byoConfig, opts = {}) {
@@ -182,7 +241,11 @@ function writeByoConfig(byoConfig, opts = {}) {
182
241
  * rather than guess a location to read/write. A stat/read failure other
183
242
  * than ENOENT (e.g. EACCES) returns 'unreadable' rather than rethrow — this
184
243
  * function's contract really is "never throws" now (should-fix item 10; the
185
- * old rethrow on non-ENOENT contradicted this same docblock).
244
+ * old rethrow on non-ENOENT contradicted this same docblock). The rewrite
245
+ * path below can now also throw (writeProvidersStateAtomic's
246
+ * 'provider-state-target-unsafe', EXTRACTION-LOW-FOLLOWUPS item 4) — caught
247
+ * here too, folded into 'unreadable', so this function's own "never throws"
248
+ * contract holds regardless of what writeProvidersStateAtomic does.
186
249
  *
187
250
  * @returns {'removed-file'|'removed-byo'|'noop'|'unreadable'|'unresolved'}
188
251
  * 'noop' covers both "no file" and "a file with no `byo` key to clear".
@@ -209,10 +272,18 @@ function clearProvidersFile(opts = {}) {
209
272
  }
210
273
  delete state.byo;
211
274
  if (Object.keys(state).length === 0) {
212
- fs.unlinkSync(target);
275
+ try {
276
+ fs.unlinkSync(target);
277
+ } catch {
278
+ return 'unreadable'; // cannot remove it either — never throws (contract)
279
+ }
213
280
  return 'removed-file';
214
281
  }
215
- writeProvidersStateAtomic(state, opts);
282
+ try {
283
+ writeProvidersStateAtomic(state, opts);
284
+ } catch {
285
+ return 'unreadable'; // e.g. provider-state-target-unsafe — never throws (contract)
286
+ }
216
287
  return 'removed-byo';
217
288
  }
218
289
 
@@ -244,6 +315,8 @@ function baseUrlFor(vendor, configured) {
244
315
  * throws.
245
316
  */
246
317
  function isProvidersFileModeUnsafe(opts = {}) {
318
+ // EXTRACTION-LOW-FOLLOWUPS item 2 (TOCTOU, accepted on the record): a
319
+ // window exists between this check and readByoConfig()'s read below; only the same uid could win that race, and that uid already owns the key on disk, so it is accepted rather than replaced with an fd-based check-then-read.
247
320
  const target = statePath(opts);
248
321
  if (isHomeUnresolved(target)) return true; // can't even name the file — fail closed
249
322
  const statSyncImpl = typeof opts.statSyncImpl === 'function' ? opts.statSyncImpl : fs.statSync;
@@ -627,5 +700,7 @@ module.exports = {
627
700
  isHomeUnresolved,
628
701
  isBaseUrlInsecure,
629
702
  writeProvidersStateAtomic,
703
+ // Exported for direct unit coverage (EXTRACTION-LOW-FOLLOWUPS item 4).
704
+ isUnsafeExistingTarget,
630
705
  MAX_RESPONSE_BYTES,
631
706
  };
@@ -41,6 +41,93 @@ function resolveClaudeBin(opts = {}) {
41
41
  return 'claude';
42
42
  }
43
43
 
44
+ // ─── Child settings/hooks isolation (EXTRACTION-CHILD-HOOKS, PUNCH-LIST P1,
45
+ // 0.9.15) ────────────────────────────────────────────────────────────────
46
+ //
47
+ // Every `claude -p` extraction child previously loaded the OPERATOR'S OWN
48
+ // ~/.claude/settings.json and therefore fired their personal SessionStart
49
+ // hooks (mandate.sh, session-context.sh, ...) — output that reaches the
50
+ // extraction/judge prompt without ever passing the package's scrubber. That
51
+ // is a privacy defect: content from the machine's own hook configuration
52
+ // (potentially personal notes, live queries, etc.) enters a transcript that
53
+ // gets sent to the resolved provider.
54
+ //
55
+ // `--setting-sources <sources>` ("Comma-separated list of setting sources to
56
+ // load (user, project, local)") is documented on both installed CLIs probed
57
+ // during investigation (2.1.12, 2.1.260) — scratchpad hooks-0914/
58
+ // EXTRACTION-CHILD-HOOKS-FINDINGS.md. This build (hooks-0915) tested LIVE
59
+ // whether an EMPTY list (`--setting-sources ''`) — the narrowest, fully
60
+ // cwd-independent value, loading none of user/project/local — is accepted:
61
+ // it is (2.1.12, exit 0, hook_response count 0, well-formed result). Shipping
62
+ // `''` means no fresh-temp-cwd workaround is needed: an empty source list
63
+ // loads nothing regardless of the child's cwd, unlike the `project,local`
64
+ // fallback (which still honors a target repo's own `.claude/settings.json`).
65
+ const SETTING_SOURCES_VALUE = '';
66
+ const SETTING_SOURCES_ARGS = Object.freeze(['--setting-sources', SETTING_SOURCES_VALUE]);
67
+
68
+ /**
69
+ * Fail-closed detection of "this CLI build doesn't understand
70
+ * --setting-sources at all" (an old install predating the flag, or a rename).
71
+ * Detected from the SAME spawn that already carries the flag — no extra
72
+ * `--help`/`--version` probe call, so the happy-path spawn count for every
73
+ * existing caller/test is unchanged. A CLI rejecting an unknown flag exits
74
+ * non-zero with a message naming the flag (commander.js-style "error:
75
+ * unknown option '--setting-sources'"); this pattern-matches for that
76
+ * specific shape rather than treating every non-zero exit as unsupported (a
77
+ * real model/auth error must NOT be misreported as isolation-unsupported).
78
+ * Cached module-wide for the lifetime of the process ("once per run"): the
79
+ * first spawn that hits this failure marks the CLI unsupported and every
80
+ * subsequent runModel() call in the same process short-circuits BEFORE
81
+ * spawning again — it must never spawn without the flag, and re-attempting a
82
+ * doomed spawn every call would be silent waste, not safety.
83
+ */
84
+ let cachedSettingSourcesUnsupported; // undefined = not yet observed; true once detected
85
+
86
+ function looksLikeUnsupportedSettingSourcesFlag(res) {
87
+ if (!res || res.status === 0) return false;
88
+ const combined = `${String(res.stdout || '')}\n${String(res.stderr || '')}`;
89
+ return /--setting-sources/.test(combined) && /\b(unknown|unrecognized|invalid)\b.{0,20}\b(option|argument|flag)\b/i.test(combined);
90
+ }
91
+
92
+ function settingSourcesIsolationUnsupportedResult(authStatus) {
93
+ return {
94
+ ok: false,
95
+ text: '',
96
+ usage: null,
97
+ reason: 'installed Claude Code CLI does not support --setting-sources; extraction declines to run a child that would load the operator\'s own settings/hooks unisolated',
98
+ reasonCode: 'cli-settings-isolation-unsupported',
99
+ authStatus: authStatus || 'unknown',
100
+ };
101
+ }
102
+
103
+ /** Test-only: reset the module-level isolation-support cache between fixtures. */
104
+ function _resetSettingSourcesCacheForTests() {
105
+ cachedSettingSourcesUnsupported = undefined;
106
+ }
107
+
108
+ // ─── CLI version, for diagnostics only (no subprocess spawn) ───────────────
109
+ //
110
+ // Resolves the installed package's own package.json version by following the
111
+ // resolved binary's real path (e.g. `/usr/local/bin/claude` -> `.../
112
+ // node_modules/@anthropic-ai/claude-code/cli.js`) and reading the sibling
113
+ // package.json — filesystem-only, so it never adds a spawn to the extraction
114
+ // path (verified live: realpath + package.json read, no `claude --version`
115
+ // call). Best-effort: any failure (bare `claude` unresolved via PATH, an
116
+ // install layout that doesn't carry a sibling package.json, a fixture path in
117
+ // tests) yields null, never throws.
118
+ function getClaudeCliVersion(bin, opts = {}) {
119
+ const realpathSyncImpl = typeof opts.realpathSyncImpl === 'function' ? opts.realpathSyncImpl : fs.realpathSync;
120
+ const readFileSyncImpl = typeof opts.readFileSyncImpl === 'function' ? opts.readFileSyncImpl : fs.readFileSync;
121
+ try {
122
+ const real = realpathSyncImpl(bin);
123
+ const pkgPath = path.join(path.dirname(real), 'package.json');
124
+ const pkg = JSON.parse(readFileSyncImpl(pkgPath, 'utf8'));
125
+ return pkg && typeof pkg.version === 'string' ? pkg.version : null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
44
131
  // ─── Env scrub (EXTRACT-TOOLS-LOCK, PUNCH-LIST) ────────────────────────────
45
132
  //
46
133
  // SME-confirmed list (claude-code-guide, verified against official docs and
@@ -79,6 +166,20 @@ const SCRUBBED_CLIENT_ENV_VARS = Object.freeze([
79
166
  'CLOUD_ML_REGION',
80
167
  ]);
81
168
 
169
+ // ─── Spawn argv (EXTRACTION-ZERO-TOOL-CALLS control) ───────────────────────
170
+ //
171
+ // Named + frozen so a byte-pinned test (test/extraction-zero-tool-calls.test.js,
172
+ // test/claude-code-provider.test.js) can assert the exact argv without
173
+ // duplicating the literal, and so a future flag change is a conscious,
174
+ // greppable edit here rather than a silent literal tweak buried in the two
175
+ // runXMode() functions below. No behavior change: the two spawnSyncImpl()
176
+ // call sites below now pass these constants instead of inline array literals
177
+ // of the identical contents. 0.9.15 (EXTRACTION-CHILD-HOOKS) appends
178
+ // SETTING_SOURCES_ARGS to both — the child loads none of user/project/local
179
+ // settings, so the operator's own SessionStart hooks never fire.
180
+ const EXTRACT_MODE_ARGV = Object.freeze(['-p', '--no-session-persistence', '--tools', '', ...SETTING_SOURCES_ARGS]);
181
+ const JUDGE_MODE_ARGV = Object.freeze(['-p', '--output-format', 'json', '--no-session-persistence', '--tools', '', ...SETTING_SOURCES_ARGS]);
182
+
82
183
  /**
83
184
  * Build the subscription-auth-only environment shared by BOTH the extraction and
84
185
  * judge Claude CLI children — one function, no drift between the two spawns.
@@ -154,7 +255,19 @@ function managedSettingsPathForPlatform(opts) {
154
255
  // otherwise names a fixed, real, OS-level location no test should touch).
155
256
  if (typeof opts.managedSettingsPath === 'string') return opts.managedSettingsPath;
156
257
  const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
157
- return MANAGED_SETTINGS_PATH_BY_PLATFORM[platform] || MANAGED_SETTINGS_PATH_BY_PLATFORM.linux;
258
+ // EXTRACTION-LOW-FOLLOWUPS item 1: a raw `[platform]` index is reachable
259
+ // (only via the test seam opts.platform, per the row) with a prototype key
260
+ // ('constructor', 'toString', '__proto__', …) and would return a truthy
261
+ // Object.prototype value instead of falling through to the Linux default —
262
+ // fails OPEN with a bogus path silently in place of the real managed-
263
+ // settings check. hasOwnProperty scopes the lookup to the object's own
264
+ // enumerable keys, mirroring the guard at scripts/providers/index.js:139,
265
+ // so an unknown or prototype-polluting key falls CLOSED to the Linux path
266
+ // exactly like any other unrecognized platform string does today.
267
+ if (Object.prototype.hasOwnProperty.call(MANAGED_SETTINGS_PATH_BY_PLATFORM, platform)) {
268
+ return MANAGED_SETTINGS_PATH_BY_PLATFORM[platform];
269
+ }
270
+ return MANAGED_SETTINGS_PATH_BY_PLATFORM.linux;
158
271
  }
159
272
 
160
273
  /**
@@ -167,7 +280,7 @@ function managedSettingsPathForPlatform(opts) {
167
280
  * that matches Claude Code's own behavior for those files, which this
168
281
  * managed path does not share.)
169
282
  */
170
- function managedSettingsBlocksOrUnverifiable(filePath, existsSyncImpl, readFileSyncImpl) {
283
+ function managedSettingsBlocksOrUnverifiable(filePath, existsSyncImpl, readFileSyncImpl, log) {
171
284
  let exists;
172
285
  try {
173
286
  exists = existsSyncImpl(filePath);
@@ -176,7 +289,17 @@ function managedSettingsBlocksOrUnverifiable(filePath, existsSyncImpl, readFileS
176
289
  }
177
290
  if (!exists) return false;
178
291
  const parsed = readJsonSafe(filePath, readFileSyncImpl);
179
- if (parsed === null) return true; // present but unreadable/unparseable — fail closed
292
+ if (parsed === null) {
293
+ // EXTRACTION-LOW-FOLLOWUPS item 3: this fail-closed branch silently
294
+ // switches the builder away from claude-code (reasonCode
295
+ // 'cli-billing-helper-configured', same as a real detected helper) with
296
+ // no visible signal that the cause was an UNVERIFIABLE managed-settings
297
+ // file rather than an actual foreign-billing helper. One stderr line
298
+ // naming the reason code — never the file's contents or any key
299
+ // material, both of which stay out of every log call in this module.
300
+ log('[providers] managed-settings.json is present but unreadable/unparseable; failing closed and switching away from claude-code (reasonCode cli-billing-helper-configured)');
301
+ return true; // present but unreadable/unparseable — fail closed
302
+ }
180
303
  return settingsHasBillingHelper(parsed);
181
304
  }
182
305
 
@@ -195,8 +318,9 @@ function detectBillingHelperConfigured(opts = {}) {
195
318
  const existsSyncImpl = typeof opts.existsSyncImpl === 'function' ? opts.existsSyncImpl : fs.existsSync;
196
319
  const homeDir = typeof opts.homeDir === 'string' ? opts.homeDir : os.homedir();
197
320
  const cwd = typeof opts.cwd === 'string' ? opts.cwd : process.cwd();
321
+ const log = typeof opts.log === 'function' ? opts.log : console.error;
198
322
 
199
- if (managedSettingsBlocksOrUnverifiable(managedSettingsPathForPlatform(opts), existsSyncImpl, readFileSyncImpl)) {
323
+ if (managedSettingsBlocksOrUnverifiable(managedSettingsPathForPlatform(opts), existsSyncImpl, readFileSyncImpl, log)) {
200
324
  return true;
201
325
  }
202
326
 
@@ -274,6 +398,7 @@ function detect(opts = {}) {
274
398
  function runExtractMode(opts) {
275
399
  const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
276
400
  const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
401
+ if (cachedSettingSourcesUnsupported) return settingSourcesIsolationUnsupportedResult('unknown');
277
402
  const authStatus = checkAuthStatus({ spawnSyncImpl, claudeBin: bin, ...opts });
278
403
  if (authStatus === 'logged-out') {
279
404
  return {
@@ -287,11 +412,18 @@ function runExtractMode(opts) {
287
412
  }
288
413
  const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
289
414
  const stdin = prompt + String(opts.input || '').slice(0, 200000);
415
+ // --no-session-persistence (EXTRACT-PER-CLIENT W1 FIX GIVENS): matches the
416
+ // judge spawn below — an extraction run leaves no session file behind either.
417
+ // --setting-sources '' (EXTRACTION-CHILD-HOOKS, 0.9.15): the child loads none
418
+ // of user/project/local settings, so the operator's own SessionStart hooks
419
+ // never fire and their output never reaches this prompt.
420
+ const argv = EXTRACT_MODE_ARGV;
421
+ const cliVersion = getClaudeCliVersion(bin, opts);
290
422
  let res;
291
423
  try {
292
424
  // --no-session-persistence (EXTRACT-PER-CLIENT W1 FIX GIVENS): matches the
293
425
  // judge spawn below — an extraction run leaves no session file behind either.
294
- res = spawnSyncImpl(bin, ['-p', '--no-session-persistence', '--tools', ''], {
426
+ res = spawnSyncImpl(bin, argv, {
295
427
  input: stdin,
296
428
  encoding: 'utf-8',
297
429
  env: claudeChildEnv(),
@@ -299,14 +431,18 @@ function runExtractMode(opts) {
299
431
  maxBuffer: 20 * 1024 * 1024,
300
432
  });
301
433
  } catch (error) {
302
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): ${error.message}`, reasonCode: 'unknown', authStatus };
434
+ return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): ${error.message}`, reasonCode: 'unknown', authStatus, argv, cliVersion };
303
435
  }
304
436
  if (!res) {
305
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus };
437
+ return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus, argv, cliVersion };
438
+ }
439
+ if (looksLikeUnsupportedSettingSourcesFlag(res)) {
440
+ cachedSettingSourcesUnsupported = true;
441
+ return { ...settingSourcesIsolationUnsupportedResult(authStatus), argv, cliVersion };
306
442
  }
307
443
  const out = String(res.stdout || '');
308
444
  if (res.error) {
309
- return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): ${res.error.message}`, reasonCode: 'unknown', authStatus };
445
+ return { ok: false, text: '', usage: null, reason: `spawn failed (${bin}): ${res.error.message}`, reasonCode: 'unknown', authStatus, argv, cliVersion };
310
446
  }
311
447
  // Claude prints auth failures ("API Error: 401 ... Please run /login") to stdout.
312
448
  if (/Please run \/login|authentication_error|401/i.test(out) || /Please run \/login|authentication_error/i.test(String(res.stderr || ''))) {
@@ -317,6 +453,8 @@ function runExtractMode(opts) {
317
453
  reason: 'local model not authenticated in this context (run `claude auth login` once); skipping deterministic extraction',
318
454
  reasonCode: 'cli-unauthenticated',
319
455
  authStatus,
456
+ argv,
457
+ cliVersion,
320
458
  ...(authStatus === 'logged-in' && { authDiscrepancy: true }),
321
459
  };
322
460
  }
@@ -328,9 +466,11 @@ function runExtractMode(opts) {
328
466
  reason: `local model exited ${res.status}: ${(out || String(res.stderr || '')).slice(0, 160)}`,
329
467
  reasonCode: 'model-error',
330
468
  authStatus,
469
+ argv,
470
+ cliVersion,
331
471
  };
332
472
  }
333
- return { ok: true, text: out, usage: null, reason: null, authStatus };
473
+ return { ok: true, text: out, usage: null, reason: null, authStatus, argv, cliVersion };
334
474
  }
335
475
 
336
476
  /**
@@ -353,14 +493,18 @@ function normalizeJudgeUsage(rawUsage) {
353
493
  return { input_tokens: inputSum, output_tokens: output };
354
494
  }
355
495
 
356
- /** mode:'judge' — binary anchored-dedup decision. Argv byte-identical to pre-move. */
496
+ /** mode:'judge' — binary anchored-dedup decision. Argv byte-identical to pre-move plus
497
+ * the same --setting-sources '' isolation the extraction spawn above gains (0.9.15). */
357
498
  function runJudgeMode(opts) {
358
499
  const spawnSyncImpl = typeof opts.spawnSyncImpl === 'function' ? opts.spawnSyncImpl : spawnSync;
359
500
  const bin = typeof opts.claudeBin === 'string' ? opts.claudeBin : resolveClaudeBin(opts);
501
+ if (cachedSettingSourcesUnsupported) return settingSourcesIsolationUnsupportedResult('unknown');
360
502
  const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
503
+ const argv = JUDGE_MODE_ARGV;
504
+ const cliVersion = getClaudeCliVersion(bin, opts);
361
505
  let res;
362
506
  try {
363
- res = spawnSyncImpl(bin, ['-p', '--output-format', 'json', '--no-session-persistence', '--tools', ''], {
507
+ res = spawnSyncImpl(bin, argv, {
364
508
  input: prompt,
365
509
  encoding: 'utf8',
366
510
  env: claudeChildEnv(),
@@ -368,17 +512,21 @@ function runJudgeMode(opts) {
368
512
  maxBuffer: 20 * 1024 * 1024,
369
513
  });
370
514
  } catch (error) {
371
- return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): ${error.message}`, reasonCode: 'unknown', authStatus: 'unknown' };
515
+ return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): ${error.message}`, reasonCode: 'unknown', authStatus: 'unknown', argv, cliVersion };
372
516
  }
373
517
  if (!res) {
374
- return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus: 'unknown' };
518
+ return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): no process result`, reasonCode: 'unknown', authStatus: 'unknown', argv, cliVersion };
519
+ }
520
+ if (looksLikeUnsupportedSettingSourcesFlag(res)) {
521
+ cachedSettingSourcesUnsupported = true;
522
+ return { ...settingSourcesIsolationUnsupportedResult('unknown'), argv, cliVersion };
375
523
  }
376
524
  const stdout = String(res.stdout || '');
377
525
  if (res.error) {
378
- return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): ${res.error.message}`, reasonCode: 'unknown', authStatus: 'unknown' };
526
+ return { ok: false, text: '', usage: null, reason: `judge spawn failed (${bin}): ${res.error.message}`, reasonCode: 'unknown', authStatus: 'unknown', argv, cliVersion };
379
527
  }
380
528
  if (/Please run \/login|authentication_error|401/i.test(stdout) || /Please run \/login|authentication_error/i.test(String(res.stderr || ''))) {
381
- return { ok: false, text: '', usage: null, reason: 'local judge model is not authenticated', reasonCode: 'cli-unauthenticated', authStatus: 'unknown' };
529
+ return { ok: false, text: '', usage: null, reason: 'local judge model is not authenticated', reasonCode: 'cli-unauthenticated', authStatus: 'unknown', argv, cliVersion };
382
530
  }
383
531
  if (res.status !== 0) {
384
532
  return {
@@ -388,18 +536,20 @@ function runJudgeMode(opts) {
388
536
  reason: `local judge exited ${res.status}: ${(stdout || String(res.stderr || '')).slice(0, 160)}`,
389
537
  reasonCode: 'model-error',
390
538
  authStatus: 'unknown',
539
+ argv,
540
+ cliVersion,
391
541
  };
392
542
  }
393
543
  let wrapper;
394
544
  try {
395
545
  wrapper = JSON.parse(stdout);
396
546
  } catch {
397
- return { ok: false, text: '', usage: null, reason: 'local judge returned malformed JSON wrapper', reasonCode: 'model-error', authStatus: 'unknown' };
547
+ return { ok: false, text: '', usage: null, reason: 'local judge returned malformed JSON wrapper', reasonCode: 'model-error', authStatus: 'unknown', argv, cliVersion };
398
548
  }
399
549
  if (!wrapper || typeof wrapper.result !== 'string' || wrapper.is_error === true) {
400
- return { ok: false, text: '', usage: null, reason: 'local judge returned no successful result', reasonCode: 'model-error', authStatus: 'unknown' };
550
+ return { ok: false, text: '', usage: null, reason: 'local judge returned no successful result', reasonCode: 'model-error', authStatus: 'unknown', argv, cliVersion };
401
551
  }
402
- return { ok: true, text: wrapper.result, usage: normalizeJudgeUsage(wrapper.usage), reason: null, authStatus: 'unknown' };
552
+ return { ok: true, text: wrapper.result, usage: normalizeJudgeUsage(wrapper.usage), reason: null, authStatus: 'unknown', argv, cliVersion };
403
553
  }
404
554
 
405
555
  /**
@@ -474,4 +624,18 @@ module.exports = {
474
624
  // Exported for direct unit coverage (test/extract-w1-fix2.test.js, GOV-3 item 6).
475
625
  managedSettingsPathForPlatform,
476
626
  MANAGED_SETTINGS_PATH_BY_PLATFORM,
627
+ // Exported for direct byte-pinned coverage (test/extraction-zero-tool-calls.test.js,
628
+ // test/claude-code-provider.test.js; TRUST-PAGE control — SITE-PM: put the
629
+ // zero-tool-call assertion in the test suite). Carries the 0.9.15 argv
630
+ // (SETTING_SOURCES_ARGS included).
631
+ EXTRACT_MODE_ARGV,
632
+ JUDGE_MODE_ARGV,
633
+ // EXTRACTION-CHILD-HOOKS (0.9.15) — exported for direct unit coverage
634
+ // (test/claude-code-provider.test.js) and for extract-local.js's provider-run
635
+ // log line (getClaudeCliVersion).
636
+ SETTING_SOURCES_VALUE,
637
+ SETTING_SOURCES_ARGS,
638
+ getClaudeCliVersion,
639
+ looksLikeUnsupportedSettingSourcesFlag,
640
+ _resetSettingSourcesCacheForTests,
477
641
  };
@@ -78,7 +78,13 @@ const PROVIDERS = {
78
78
  'byo-key': byoKey,
79
79
  };
80
80
 
81
- const PROVIDERS_STATE_PATH = path.join(os.homedir(), '.auxilo', 'providers.json');
81
+ // TEST-HOME-ISOLATION: same AUXILO_HOME-over-os.homedir() fallback as
82
+ // scripts/providers/byo-key.js's DEFAULT_PROVIDERS_STATE_PATH (duplicated,
83
+ // not imported — see that file's docblock; test/byo-key-provider.test.js
84
+ // pins the two byte-equal). opts.providersStatePath, threaded through every
85
+ // resolveProvider()/runModel()/persistSelected() call site in this repo's
86
+ // tests, still wins over both.
87
+ const PROVIDERS_STATE_PATH = path.join(process.env.AUXILO_HOME || os.homedir(), '.auxilo', 'providers.json');
82
88
 
83
89
  function readProvidersState(statePath) {
84
90
  try {
@@ -232,6 +238,11 @@ const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
232
238
  'cli-billing-helper-configured',
233
239
  'provider-not-configured',
234
240
  'providers-file-mode-unsafe',
241
+ // EXTRACTION-CHILD-HOOKS (0.9.15): the resolved claude-code CLI doesn't
242
+ // support --setting-sources, so it can never run isolated — same
243
+ // "cannot run at all right now" class as the codes above, safe to try the
244
+ // next provider in PROVIDER_ORDER rather than reporting a hard failure.
245
+ 'cli-settings-isolation-unsupported',
235
246
  ]);
236
247
 
237
248
  /**
package/scripts/runner.js CHANGED
@@ -645,6 +645,11 @@ async function postExtractDetailed(transcript, sessionId, sourceType, _scrubRepo
645
645
  captureVisibility: opts.captureVisibility || CAPTURE_VISIBILITY,
646
646
  log: runnerLog,
647
647
  auditLog: auditDropLog,
648
+ // EXTRACTION-RUN-LOG (0.9.15): the identifier the provider-run summary
649
+ // log line reports as `run=`. sessionId is already this call's natural
650
+ // run identity (it's what extraction_id: `client-${sessionId}` uses
651
+ // below); opts.runId (if a caller supplied one) wins over it.
652
+ runId: opts.runId || sessionId,
648
653
  }));
649
654
  } catch (err) {
650
655
  throw new Error(`Local extraction failed: ${err.message}`);