auxilo-mcp 0.9.16 → 0.9.18
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 +61 -7
- package/lib/installer.js +366 -45
- package/lib/runner-autoupdate.js +21 -0
- package/mcp-server.js +20 -1
- package/package.json +1 -1
- package/scripts/extract-local.js +83 -36
- package/scripts/providers/index.js +93 -3
package/bin/auxilo-cli.js
CHANGED
|
@@ -553,7 +553,20 @@ async function cmdStatus() {
|
|
|
553
553
|
const reg = c.mcp
|
|
554
554
|
? (c.registered ? 'MCP registered' : 'detected, MCP NOT registered')
|
|
555
555
|
: 'poll-based source (no MCP config)';
|
|
556
|
-
|
|
556
|
+
// MCP-SERVER-STALENESS (0.9.17): show the pin written into this client's
|
|
557
|
+
// config alongside the installed runner version, so a stale pin (or a
|
|
558
|
+
// pre-0.9.17 unpinned entry) is visible without opening the config file.
|
|
559
|
+
let pinNote = '';
|
|
560
|
+
if (c.mcp && c.registered) {
|
|
561
|
+
if (c.mcpPin === 'unpinned') {
|
|
562
|
+
pinNote = ' (pin: unpinned — run `npx auxilo setup` to pin)';
|
|
563
|
+
} else if (c.mcpPin) {
|
|
564
|
+
pinNote = c.mcpPinStale
|
|
565
|
+
? ` (pin: v${c.mcpPin}, runner: v${s.runnerVersion} — STALE, run \`npx auxilo setup\`)`
|
|
566
|
+
: ` (pin: v${c.mcpPin})`;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
console.log(` ${c.name}: ${reg}${pinNote}`);
|
|
557
570
|
}
|
|
558
571
|
|
|
559
572
|
console.log(`Auth: ${s.auth.credentialsFile
|
|
@@ -573,7 +586,7 @@ async function cmdStatus() {
|
|
|
573
586
|
);
|
|
574
587
|
if (autoupdateLine) console.log(autoupdateLine);
|
|
575
588
|
}
|
|
576
|
-
console.log(extractionProviderLine(
|
|
589
|
+
console.log(extractionProviderLine(lastRecordedProviderResolution()));
|
|
577
590
|
// Lazy require: scripts/runner.js is a heavier module (sources, sensitivity
|
|
578
591
|
// filter, ops-alert) than this one status line needs at require-time for
|
|
579
592
|
// every CLI invocation.
|
|
@@ -612,18 +625,58 @@ function runnerSkewLine(skew) {
|
|
|
612
625
|
*/
|
|
613
626
|
const CLI_CLEAN_LANE_CALIBRATED_PROVIDERS = ['claude-code'];
|
|
614
627
|
|
|
628
|
+
/**
|
|
629
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1): `auxilo status` used to feed
|
|
630
|
+
* extractionProviderLine() a LIVE `providers.resolveProvider({})` call — a
|
|
631
|
+
* fresh detect() answering "what would run right now" (and, on a full scan,
|
|
632
|
+
* capable of writing ~/.auxilo/providers.json's `selected` field as a side
|
|
633
|
+
* effect of a status check), not "what actually ran". This function replaces
|
|
634
|
+
* that with two read-only, no-detect sources of TRUTH, in priority order:
|
|
635
|
+
* (1) AUXILO_EXTRACTION_PROVIDER, if set, is a certain fact about the
|
|
636
|
+
* current session's config — reading it is not a guess — validated
|
|
637
|
+
* against providers.PROVIDER_ORDER exactly as resolveProvider() itself
|
|
638
|
+
* validates an override, without calling it.
|
|
639
|
+
* (2) Otherwise, providers.json's `selected` field — the LAST provider a
|
|
640
|
+
* genuine resolveProvider() full-scan actually chose and persisted
|
|
641
|
+
* (scripts/providers/index.js's persistSelected(), only ever called
|
|
642
|
+
* after a real detect() succeeded) — read here with a plain
|
|
643
|
+
* fs.readFileSync, no detect() invoked, no possibility of writing.
|
|
644
|
+
* Neither source can misrepresent "would run" as "ran": (1) is what WILL
|
|
645
|
+
* run (an explicit operator override, not a probe), and (2) is what was
|
|
646
|
+
* last recorded to have been selected, honestly labeled as such below.
|
|
647
|
+
* `{ok:false}` (shown as "no recorded provider selection yet") when neither
|
|
648
|
+
* source has an answer — e.g. a fresh install that has never extracted.
|
|
649
|
+
*/
|
|
650
|
+
function lastRecordedProviderResolution() {
|
|
651
|
+
const override = process.env.AUXILO_EXTRACTION_PROVIDER;
|
|
652
|
+
if (override) {
|
|
653
|
+
if (providers.PROVIDER_ORDER.includes(override)) return { ok: true, id: override };
|
|
654
|
+
return {
|
|
655
|
+
ok: false,
|
|
656
|
+
reason: `AUXILO_EXTRACTION_PROVIDER="${override}" is not a known provider (expected one of: ${providers.PROVIDER_ORDER.join(', ')})`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
const raw = fs.readFileSync(providers.PROVIDERS_STATE_PATH, 'utf8');
|
|
661
|
+
const parsed = JSON.parse(raw);
|
|
662
|
+
const id = parsed && typeof parsed === 'object' && typeof parsed.selected === 'string' ? parsed.selected : null;
|
|
663
|
+
if (id) return { ok: true, id };
|
|
664
|
+
} catch { /* no recorded selection yet, or the file is unreadable/corrupt */ }
|
|
665
|
+
return { ok: false, reason: 'no recorded provider selection yet' };
|
|
666
|
+
}
|
|
667
|
+
|
|
615
668
|
/**
|
|
616
669
|
* EXTRACT-PER-CLIENT W1 PART A/C — one unconditional line naming which
|
|
617
|
-
* extraction model provider resolves, why (env override vs
|
|
618
|
-
* and (PART C) whether that provider's submissions can reach
|
|
619
|
-
* auto-publish path at all (server-side gate:
|
|
620
|
-
* CLEAN_LANE_CALIBRATED_PROVIDERS, mirrored above).
|
|
670
|
+
* extraction model provider resolves, why (env override vs last recorded
|
|
671
|
+
* selection), and (PART C) whether that provider's submissions can reach
|
|
672
|
+
* the clean-lane auto-publish path at all (server-side gate:
|
|
673
|
+
* lib/clean-lane.js's CLEAN_LANE_CALIBRATED_PROVIDERS, mirrored above).
|
|
621
674
|
*/
|
|
622
675
|
function extractionProviderLine(resolution) {
|
|
623
676
|
if (resolution && resolution.ok) {
|
|
624
677
|
const via = process.env.AUXILO_EXTRACTION_PROVIDER
|
|
625
678
|
? 'env override AUXILO_EXTRACTION_PROVIDER'
|
|
626
|
-
: '
|
|
679
|
+
: 'last recorded selection';
|
|
627
680
|
const calibration = CLI_CLEAN_LANE_CALIBRATED_PROVIDERS.includes(resolution.id)
|
|
628
681
|
? 'clean-lane calibrated'
|
|
629
682
|
: 'review-lane only';
|
|
@@ -1626,6 +1679,7 @@ module.exports = {
|
|
|
1626
1679
|
parseFlags,
|
|
1627
1680
|
runnerSkewLine,
|
|
1628
1681
|
extractionProviderLine,
|
|
1682
|
+
lastRecordedProviderResolution,
|
|
1629
1683
|
resolveBaseUrl,
|
|
1630
1684
|
shortFlags,
|
|
1631
1685
|
groupSummaryRows,
|
package/lib/installer.js
CHANGED
|
@@ -29,9 +29,37 @@ const { hasAuxiloSessionEndHook } = require('./hook-status.js');
|
|
|
29
29
|
|
|
30
30
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* MCP registration entry written into every client config (spec §LW-12
|
|
34
|
+
* step 1). MCP-SERVER-STALENESS (0.9.17): the entry is now PINNED to an
|
|
35
|
+
* exact installed version (`npx auxilo-mcp@<version>`) rather than the bare
|
|
36
|
+
* package name — an unpinned `npx auxilo-mcp` follows whatever npx's local
|
|
37
|
+
* cache happens to resolve `latest` to, which only refreshes when that
|
|
38
|
+
* cache expires (the INSTALL-0828 incident: a 10-week-old cached build
|
|
39
|
+
* served silently). MCP_ENTRY itself stays the UNPINNED template (base
|
|
40
|
+
* command + package name) — see mcpEntry(version) below for the pinned
|
|
41
|
+
* shape every writer actually uses.
|
|
42
|
+
*/
|
|
33
43
|
const MCP_ENTRY = Object.freeze({ command: 'npx', args: ['auxilo-mcp'] });
|
|
34
44
|
|
|
45
|
+
/** Bare package name portion of MCP_ENTRY.args[0], for pin construction/parsing. */
|
|
46
|
+
const MCP_PACKAGE_NAME = MCP_ENTRY.args[0];
|
|
47
|
+
|
|
48
|
+
/** True when `args` is shaped like an Auxilo-owned MCP entry (pinned or not). */
|
|
49
|
+
function isOwnedMcpArgs(args) {
|
|
50
|
+
return Array.isArray(args) && args.length === 1 && typeof args[0] === 'string' &&
|
|
51
|
+
(args[0] === MCP_PACKAGE_NAME || args[0].startsWith(`${MCP_PACKAGE_NAME}@`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse the version pin (or 'unpinned') out of an owned args array, or null
|
|
56
|
+
* when `args` isn't a recognizable Auxilo entry at all.
|
|
57
|
+
*/
|
|
58
|
+
function parseMcpPin(args) {
|
|
59
|
+
if (!isOwnedMcpArgs(args)) return null;
|
|
60
|
+
return args[0] === MCP_PACKAGE_NAME ? 'unpinned' : args[0].slice(MCP_PACKAGE_NAME.length + 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
35
63
|
/** Default production API base (README.md / openapi.json servers[0]). */
|
|
36
64
|
const DEFAULT_BASE_URL = 'https://auxilo.io';
|
|
37
65
|
|
|
@@ -418,11 +446,66 @@ function detectClients(homeDir, opts = {}) {
|
|
|
418
446
|
|
|
419
447
|
// ─── MCP registration (spec §LW-12 step 1) ──────────────────────────────────
|
|
420
448
|
|
|
449
|
+
/**
|
|
450
|
+
* F3 (0.9.17 fix pass): a unique `<file>.tmp-<pid>-<rand>` path for atomic
|
|
451
|
+
* config writers, matching the RUNNER_STACK convention (installRunnerAtomic
|
|
452
|
+
* above) instead of a fixed `<file>.tmp` name — two concurrent writers
|
|
453
|
+
* (e.g. `auxilo setup` and a self-update's rewriteMcpPins racing) no longer
|
|
454
|
+
* collide on the same tmp path and lose one writer's update.
|
|
455
|
+
*/
|
|
456
|
+
function uniqueTmpPath(filePath) {
|
|
457
|
+
return `${filePath}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* F3 (0.9.17 fix pass): sweep leftover `<file>.tmp-<pid>-<rand>` files from
|
|
462
|
+
* a writer that crashed or was killed mid-write, before starting a new
|
|
463
|
+
* write to the same path. Scoped tightly: only this exact file's own tmp
|
|
464
|
+
* prefix, only that file's own directory, and only entries older than an
|
|
465
|
+
* hour — never a tmp file another concurrent writer just created moments
|
|
466
|
+
* ago. Best-effort throughout; a sweep failure must never block the real
|
|
467
|
+
* write.
|
|
468
|
+
*/
|
|
469
|
+
function sweepStaleTmpFiles(filePath) {
|
|
470
|
+
const dir = path.dirname(filePath);
|
|
471
|
+
const prefix = `${path.basename(filePath)}.tmp-`;
|
|
472
|
+
const cutoffMs = Date.now() - 60 * 60 * 1000;
|
|
473
|
+
let names;
|
|
474
|
+
try {
|
|
475
|
+
names = fs.readdirSync(dir);
|
|
476
|
+
} catch {
|
|
477
|
+
return; // dir missing/unreadable — nothing to sweep
|
|
478
|
+
}
|
|
479
|
+
for (const name of names) {
|
|
480
|
+
if (!name.startsWith(prefix)) continue;
|
|
481
|
+
const full = path.join(dir, name);
|
|
482
|
+
try {
|
|
483
|
+
if (fs.statSync(full).mtimeMs < cutoffMs) fs.rmSync(full, { force: true });
|
|
484
|
+
} catch { /* best-effort */ }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* F2 (0.9.17 fix pass): the existing file's mode (owner/group/other bits
|
|
490
|
+
* only), or null when the file doesn't exist yet (format's own default
|
|
491
|
+
* applies via the umask, same as before this fix).
|
|
492
|
+
*/
|
|
493
|
+
function existingMode(filePath) {
|
|
494
|
+
try {
|
|
495
|
+
return fs.statSync(filePath).mode & 0o777;
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
421
501
|
/** Atomic JSON write: tmp + rename (same convention as mcp-server.js setup). */
|
|
422
502
|
function writeJsonAtomic(filePath, obj) {
|
|
423
|
-
const tmp = `${filePath}.tmp`;
|
|
424
503
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
504
|
+
sweepStaleTmpFiles(filePath);
|
|
505
|
+
const mode = existingMode(filePath);
|
|
506
|
+
const tmp = uniqueTmpPath(filePath);
|
|
425
507
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
|
|
508
|
+
if (mode !== null) fs.chmodSync(tmp, mode); // F2: preserve the existing file's mode across re-pin
|
|
426
509
|
fs.renameSync(tmp, filePath);
|
|
427
510
|
}
|
|
428
511
|
|
|
@@ -443,9 +526,15 @@ function readClientConfig(configPath) {
|
|
|
443
526
|
}
|
|
444
527
|
}
|
|
445
528
|
|
|
446
|
-
/**
|
|
447
|
-
|
|
448
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Fresh MCP server entry object (never share the frozen MCP_ENTRY).
|
|
531
|
+
* MCP-SERVER-STALENESS (0.9.17): when `version` is given, the package spec
|
|
532
|
+
* is pinned EXACT (`auxilo-mcp@<version>`); omitted/falsy keeps the legacy
|
|
533
|
+
* unpinned shape (only used by parity checks below — every real writer call
|
|
534
|
+
* always passes a version, defaulting to packageVersion() in registerMcp).
|
|
535
|
+
*/
|
|
536
|
+
function mcpEntry(version) {
|
|
537
|
+
return { command: MCP_ENTRY.command, args: [version ? `${MCP_PACKAGE_NAME}@${version}` : MCP_PACKAGE_NAME] };
|
|
449
538
|
}
|
|
450
539
|
|
|
451
540
|
/** Result helpers shared by every registerMcp format writer. */
|
|
@@ -456,12 +545,45 @@ function registeredResult(configPath) {
|
|
|
456
545
|
return { changed: true, status: 'registered', configPath };
|
|
457
546
|
}
|
|
458
547
|
|
|
459
|
-
/**
|
|
460
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Default writer: Claude-Desktop-style `mcpServers` object (most clients;
|
|
550
|
+
* also Continue.dev's drop-in file, which shares the identical shape — see
|
|
551
|
+
* registerJsonDropin below).
|
|
552
|
+
*
|
|
553
|
+
* MCP-SERVER-STALENESS (0.9.17): now UPSERTS instead of pure create-once.
|
|
554
|
+
* An absent entry is created pinned to `version`. An EXISTING entry is
|
|
555
|
+
* re-pinned only when it is recognizably OURS (isOwnedMcpArgs — command
|
|
556
|
+
* 'npx', a single `auxilo-mcp`/`auxilo-mcp@X.Y.Z` arg) and the pin differs;
|
|
557
|
+
* a foreign/hand-edited entry sharing the `auxilo` key is left completely
|
|
558
|
+
* untouched (never overwritten). F1 (0.9.17 fix pass): an owned entry is
|
|
559
|
+
* re-pinned by patching `command`/`args` IN PLACE on the existing object,
|
|
560
|
+
* not by replacing it wholesale — any other keys the user added to their
|
|
561
|
+
* own Auxilo entry (env, disabled, timeout, alwaysAllow, cwd, …) and their
|
|
562
|
+
* key order survive both `auxilo setup` re-runs and the self-update rewrite
|
|
563
|
+
* path, same as the foreign-entry case above.
|
|
564
|
+
*/
|
|
565
|
+
function registerJsonMcpServers(client, version) {
|
|
461
566
|
const config = readClientConfig(client.configPath);
|
|
462
|
-
|
|
463
|
-
if (!
|
|
464
|
-
|
|
567
|
+
const existing = config.mcpServers && config.mcpServers.auxilo;
|
|
568
|
+
if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
|
|
569
|
+
return unchangedResult(client.configPath); // foreign entry — never touch
|
|
570
|
+
}
|
|
571
|
+
const target = mcpEntry(version);
|
|
572
|
+
if (existing && existing.command === target.command &&
|
|
573
|
+
JSON.stringify(existing.args) === JSON.stringify(target.args)) {
|
|
574
|
+
return unchangedResult(client.configPath); // already pinned correctly
|
|
575
|
+
}
|
|
576
|
+
if (existing) {
|
|
577
|
+
// F1 (0.9.17 fix pass): patch command/args IN PLACE on the existing
|
|
578
|
+
// object instead of replacing it wholesale — any other keys the user
|
|
579
|
+
// added to their own Auxilo entry (env, disabled, timeout, alwaysAllow,
|
|
580
|
+
// cwd, …) survive the re-pin, and existing key order is preserved.
|
|
581
|
+
existing.command = target.command;
|
|
582
|
+
existing.args = target.args;
|
|
583
|
+
} else {
|
|
584
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
585
|
+
config.mcpServers.auxilo = target;
|
|
586
|
+
}
|
|
465
587
|
writeJsonAtomic(client.configPath, config);
|
|
466
588
|
return registeredResult(client.configPath);
|
|
467
589
|
}
|
|
@@ -469,60 +591,133 @@ function registerJsonMcpServers(client) {
|
|
|
469
591
|
/** TOML section header used for Codex idempotency (substring probe — no TOML dep). */
|
|
470
592
|
const CODEX_SECTION_HEADER = '[mcp_servers.auxilo]';
|
|
471
593
|
|
|
594
|
+
/** Bound our `[mcp_servers.auxilo]` section: header .. next top-level `[` at line-start, or EOF. */
|
|
595
|
+
function codexSectionBounds(raw, headerIdx) {
|
|
596
|
+
const afterHeader = headerIdx + CODEX_SECTION_HEADER.length;
|
|
597
|
+
const nextHeaderMatch = raw.slice(afterHeader).match(/\n\[/);
|
|
598
|
+
const end = nextHeaderMatch ? afterHeader + nextHeaderMatch.index : raw.length;
|
|
599
|
+
return { start: headerIdx, end };
|
|
600
|
+
}
|
|
601
|
+
|
|
472
602
|
/**
|
|
473
|
-
* Codex CLI writer: APPEND-ONLY into ~/.codex/config.toml
|
|
474
|
-
* never parsed
|
|
475
|
-
*
|
|
603
|
+
* Codex CLI writer: APPEND-ONLY into ~/.codex/config.toml when our section
|
|
604
|
+
* is absent. Existing TOML is never parsed or reformatted as a whole file —
|
|
605
|
+
* MCP-SERVER-STALENESS (0.9.17): when our section IS present, ONLY its own
|
|
606
|
+
* `args = [...]` line is rewritten (via targeted string replace, scoped to
|
|
607
|
+
* our section's bounds so an unrelated `[mcp_servers.other]` table sharing
|
|
608
|
+
* the same key names is never touched), and only when that line is
|
|
609
|
+
* recognizably ours (isOwnedMcpArgs) — a hand-edited section is left
|
|
610
|
+
* byte-identical. Every other byte of the file, including comments and
|
|
611
|
+
* formatting inside or outside our section, survives untouched.
|
|
476
612
|
*/
|
|
477
|
-
function registerCodexToml(client) {
|
|
613
|
+
function registerCodexToml(client, version) {
|
|
478
614
|
const raw = fs.existsSync(client.configPath)
|
|
479
615
|
? fs.readFileSync(client.configPath, 'utf-8')
|
|
480
616
|
: '';
|
|
481
|
-
|
|
617
|
+
const target = mcpEntry(version);
|
|
618
|
+
const headerIdx = raw.indexOf(CODEX_SECTION_HEADER);
|
|
619
|
+
|
|
620
|
+
if (headerIdx === -1) {
|
|
621
|
+
const section = `\n${CODEX_SECTION_HEADER}\n` +
|
|
622
|
+
`command = ${JSON.stringify(target.command)}\n` +
|
|
623
|
+
`args = ${JSON.stringify(target.args)}\n`;
|
|
624
|
+
fs.mkdirSync(path.dirname(client.configPath), { recursive: true });
|
|
625
|
+
sweepStaleTmpFiles(client.configPath);
|
|
626
|
+
const mode = existingMode(client.configPath);
|
|
627
|
+
const tmp = uniqueTmpPath(client.configPath);
|
|
628
|
+
fs.writeFileSync(tmp, raw + section);
|
|
629
|
+
if (mode !== null) fs.chmodSync(tmp, mode); // F2: preserve existing file's mode
|
|
630
|
+
fs.renameSync(tmp, client.configPath);
|
|
631
|
+
return registeredResult(client.configPath);
|
|
632
|
+
}
|
|
482
633
|
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
634
|
+
const { start, end } = codexSectionBounds(raw, headerIdx);
|
|
635
|
+
const section = raw.slice(start, end);
|
|
636
|
+
const cmdMatch = section.match(/^command = (".*")$/m);
|
|
637
|
+
const argsMatch = section.match(/^args = (\[.*\])$/m);
|
|
638
|
+
let currentCommand = null;
|
|
639
|
+
let currentArgs = null;
|
|
640
|
+
try {
|
|
641
|
+
currentCommand = cmdMatch ? JSON.parse(cmdMatch[1]) : null;
|
|
642
|
+
currentArgs = argsMatch ? JSON.parse(argsMatch[1]) : null;
|
|
643
|
+
} catch { /* malformed line — treated as not-owned below */ }
|
|
486
644
|
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
645
|
+
const owned = currentCommand === target.command && isOwnedMcpArgs(currentArgs);
|
|
646
|
+
if (!owned) return unchangedResult(client.configPath); // foreign/hand-edited section — never touch
|
|
647
|
+
|
|
648
|
+
if (JSON.stringify(currentArgs) === JSON.stringify(target.args)) {
|
|
649
|
+
return unchangedResult(client.configPath); // already pinned correctly
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const newSection = section.replace(/^args = \[.*\]$/m, `args = ${JSON.stringify(target.args)}`);
|
|
653
|
+
const newRaw = raw.slice(0, start) + newSection + raw.slice(end);
|
|
654
|
+
sweepStaleTmpFiles(client.configPath);
|
|
655
|
+
const mode = existingMode(client.configPath);
|
|
656
|
+
const tmp = uniqueTmpPath(client.configPath);
|
|
657
|
+
fs.writeFileSync(tmp, newRaw);
|
|
658
|
+
if (mode !== null) fs.chmodSync(tmp, mode); // F2: preserve existing file's mode
|
|
490
659
|
fs.renameSync(tmp, client.configPath);
|
|
491
660
|
return registeredResult(client.configPath);
|
|
492
661
|
}
|
|
493
662
|
|
|
494
|
-
/** Continue.dev writer: standalone drop-in file under ~/.continue/mcpServers
|
|
495
|
-
function registerJsonDropin(client) {
|
|
496
|
-
|
|
497
|
-
if (config.mcpServers && config.mcpServers.auxilo) return unchangedResult(client.configPath);
|
|
498
|
-
if (!config.mcpServers) config.mcpServers = {};
|
|
499
|
-
config.mcpServers.auxilo = mcpEntry();
|
|
500
|
-
writeJsonAtomic(client.configPath, config);
|
|
501
|
-
return registeredResult(client.configPath);
|
|
663
|
+
/** Continue.dev writer: standalone drop-in file under ~/.continue/mcpServers/ — identical shape to registerJsonMcpServers. */
|
|
664
|
+
function registerJsonDropin(client, version) {
|
|
665
|
+
return registerJsonMcpServers(client, version);
|
|
502
666
|
}
|
|
503
667
|
|
|
504
668
|
/** opencode writer: `mcp` key, entry shape {type:'local', command:[bin,...args]}. */
|
|
505
|
-
function registerOpencode(client) {
|
|
669
|
+
function registerOpencode(client, version) {
|
|
506
670
|
const config = readClientConfig(client.configPath);
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
671
|
+
const existing = config.mcp && config.mcp.auxilo;
|
|
672
|
+
const existingArgs = existing && Array.isArray(existing.command) ? existing.command.slice(1) : null;
|
|
673
|
+
const existingOwned = existing && existing.type === 'local' &&
|
|
674
|
+
Array.isArray(existing.command) && existing.command[0] === MCP_ENTRY.command &&
|
|
675
|
+
isOwnedMcpArgs(existingArgs);
|
|
676
|
+
if (existing && !existingOwned) return unchangedResult(client.configPath); // foreign entry — never touch
|
|
677
|
+
|
|
678
|
+
const targetCommand = [MCP_ENTRY.command, ...mcpEntry(version).args];
|
|
679
|
+
if (existing && JSON.stringify(existing.command) === JSON.stringify(targetCommand)) {
|
|
680
|
+
return unchangedResult(client.configPath); // already pinned correctly
|
|
681
|
+
}
|
|
682
|
+
if (existing) {
|
|
683
|
+
// F1 (0.9.17 fix pass): patch command IN PLACE — preserves any other
|
|
684
|
+
// keys the user added to their entry (env, cwd, …) and key order.
|
|
685
|
+
existing.command = targetCommand;
|
|
686
|
+
} else {
|
|
687
|
+
if (!config.mcp) config.mcp = {};
|
|
688
|
+
config.mcp.auxilo = { type: 'local', command: targetCommand };
|
|
689
|
+
}
|
|
510
690
|
writeJsonAtomic(client.configPath, config);
|
|
511
691
|
return registeredResult(client.configPath);
|
|
512
692
|
}
|
|
513
693
|
|
|
514
694
|
/** Amp writer: settings.json flat `amp.mcpServers` key (an object of servers). */
|
|
515
|
-
function registerAmp(client) {
|
|
695
|
+
function registerAmp(client, version) {
|
|
516
696
|
const config = readClientConfig(client.configPath);
|
|
517
697
|
const servers = config['amp.mcpServers'];
|
|
518
|
-
|
|
519
|
-
|
|
698
|
+
const existing = servers && servers.auxilo;
|
|
699
|
+
if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
|
|
700
|
+
return unchangedResult(client.configPath); // foreign entry — never touch
|
|
701
|
+
}
|
|
702
|
+
const target = mcpEntry(version);
|
|
703
|
+
if (existing && existing.command === target.command &&
|
|
704
|
+
JSON.stringify(existing.args) === JSON.stringify(target.args)) {
|
|
705
|
+
return unchangedResult(client.configPath); // already pinned correctly
|
|
706
|
+
}
|
|
707
|
+
if (existing) {
|
|
708
|
+
// F1 (0.9.17 fix pass): patch command/args IN PLACE — preserves any
|
|
709
|
+
// other keys the user added to their entry and key order.
|
|
710
|
+
existing.command = target.command;
|
|
711
|
+
existing.args = target.args;
|
|
712
|
+
} else {
|
|
713
|
+
config['amp.mcpServers'] = { ...(servers || {}), auxilo: target };
|
|
714
|
+
}
|
|
520
715
|
writeJsonAtomic(client.configPath, config);
|
|
521
716
|
return registeredResult(client.configPath);
|
|
522
717
|
}
|
|
523
718
|
|
|
524
719
|
/** OpenHands writer: `stdio_servers` ARRAY of {name, command, args}, keyed by name. */
|
|
525
|
-
function registerOpenHands(client) {
|
|
720
|
+
function registerOpenHands(client, version) {
|
|
526
721
|
const config = readClientConfig(client.configPath);
|
|
527
722
|
// GOV-3 M1: a non-array stdio_servers means the schema isn't what we expect
|
|
528
723
|
// (drift, or an object map) — skip loudly rather than clobbering the user's
|
|
@@ -531,8 +726,26 @@ function registerOpenHands(client) {
|
|
|
531
726
|
throw new Error(`Unexpected stdio_servers shape in ${client.configPath} (not an array) — add the Auxilo entry manually`);
|
|
532
727
|
}
|
|
533
728
|
const servers = config.stdio_servers || [];
|
|
534
|
-
|
|
535
|
-
|
|
729
|
+
const idx = servers.findIndex((s) => s && s.name === 'auxilo');
|
|
730
|
+
const existing = idx === -1 ? null : servers[idx];
|
|
731
|
+
if (existing && !(existing.command === MCP_ENTRY.command && isOwnedMcpArgs(existing.args))) {
|
|
732
|
+
return unchangedResult(client.configPath); // foreign entry — never touch
|
|
733
|
+
}
|
|
734
|
+
const target = mcpEntry(version);
|
|
735
|
+
if (existing && existing.command === target.command &&
|
|
736
|
+
JSON.stringify(existing.args) === JSON.stringify(target.args)) {
|
|
737
|
+
return unchangedResult(client.configPath); // already pinned correctly
|
|
738
|
+
}
|
|
739
|
+
if (existing) {
|
|
740
|
+
// F1 (0.9.17 fix pass): patch command/args IN PLACE — preserves any
|
|
741
|
+
// other keys the user added to their entry (env, cwd, …) and array
|
|
742
|
+
// order/identity (existing === servers[idx] === config.stdio_servers[idx]).
|
|
743
|
+
existing.command = target.command;
|
|
744
|
+
existing.args = target.args;
|
|
745
|
+
config.stdio_servers = servers;
|
|
746
|
+
} else {
|
|
747
|
+
config.stdio_servers = [...servers, { name: 'auxilo', ...target }];
|
|
748
|
+
}
|
|
536
749
|
writeJsonAtomic(client.configPath, config);
|
|
537
750
|
return registeredResult(client.configPath);
|
|
538
751
|
}
|
|
@@ -548,17 +761,28 @@ const MCP_WRITERS = Object.freeze({
|
|
|
548
761
|
});
|
|
549
762
|
|
|
550
763
|
/**
|
|
551
|
-
* Register the Auxilo MCP server in one client's config file.
|
|
764
|
+
* Register (or re-pin) the Auxilo MCP server in one client's config file.
|
|
552
765
|
* Dispatches on the client's `format` (default 'json-mcpServers'). All JSON
|
|
553
|
-
* writers are read-modify-write with tmp+rename; existing keys
|
|
554
|
-
* no-op (file
|
|
555
|
-
*
|
|
766
|
+
* writers are read-modify-write with tmp+rename; existing non-Auxilo keys
|
|
767
|
+
* preserved untouched; no-op (file byte-identical) when the auxilo entry is
|
|
768
|
+
* already pinned to `version`. An existing entry that doesn't look like ours
|
|
769
|
+
* (isOwnedMcpArgs) is left completely alone — this function only ever
|
|
770
|
+
* creates a fresh entry or re-pins one it recognizes as its own. The Codex
|
|
771
|
+
* TOML writer is append-only for a fresh section, and rewrites only its own
|
|
772
|
+
* `args =` line for an existing owned one.
|
|
773
|
+
*
|
|
774
|
+
* MCP-SERVER-STALENESS (0.9.17): `version` defaults to packageVersion() (the
|
|
775
|
+
* currently running package's own version) so every call — interactive
|
|
776
|
+
* setup and the programmatic rewriteMcpPins() path alike — pins to an EXACT
|
|
777
|
+
* version rather than writing the old bare `auxilo-mcp` (which npx resolves
|
|
778
|
+
* to whatever `latest` its local cache currently holds).
|
|
556
779
|
*
|
|
557
780
|
* @param {object} client Entry from clientRegistry (must have configPath).
|
|
781
|
+
* @param {string} [version] Exact version to pin; defaults to packageVersion().
|
|
558
782
|
* @returns {{ changed: boolean, status: 'registered'|'already-registered', configPath: string }}
|
|
559
783
|
* @throws on malformed existing JSON (B15 — caller skips that client loudly)
|
|
560
784
|
*/
|
|
561
|
-
function registerMcp(client) {
|
|
785
|
+
function registerMcp(client, version) {
|
|
562
786
|
if (!client || !client.configPath) {
|
|
563
787
|
throw new Error(`registerMcp: client ${client && client.id} has no MCP config path`);
|
|
564
788
|
}
|
|
@@ -566,7 +790,7 @@ function registerMcp(client) {
|
|
|
566
790
|
if (!writer) {
|
|
567
791
|
throw new Error(`registerMcp: client ${client.id} has unknown config format "${client.format}"`);
|
|
568
792
|
}
|
|
569
|
-
return writer(client);
|
|
793
|
+
return writer(client, version || packageVersion());
|
|
570
794
|
}
|
|
571
795
|
|
|
572
796
|
/**
|
|
@@ -596,6 +820,85 @@ function mcpRegistrationPresent(client) {
|
|
|
596
820
|
}
|
|
597
821
|
}
|
|
598
822
|
|
|
823
|
+
/**
|
|
824
|
+
* Read the pin currently written in a client's config: an exact version
|
|
825
|
+
* string, `'unpinned'` (legacy bare `auxilo-mcp` entry, pre-0.9.17), or
|
|
826
|
+
* `null` when not registered / not an owned shape / config unreadable.
|
|
827
|
+
* Read-only, never throws (status must never throw on local probes).
|
|
828
|
+
*/
|
|
829
|
+
function mcpPinnedVersion(client) {
|
|
830
|
+
try {
|
|
831
|
+
if (client.format === 'toml-codex') {
|
|
832
|
+
const raw = fs.readFileSync(client.configPath, 'utf-8');
|
|
833
|
+
const headerIdx = raw.indexOf(CODEX_SECTION_HEADER);
|
|
834
|
+
if (headerIdx === -1) return null;
|
|
835
|
+
const { start, end } = codexSectionBounds(raw, headerIdx);
|
|
836
|
+
const argsMatch = raw.slice(start, end).match(/^args = (\[.*\])$/m);
|
|
837
|
+
return argsMatch ? parseMcpPin(JSON.parse(argsMatch[1])) : null;
|
|
838
|
+
}
|
|
839
|
+
const config = JSON.parse(fs.readFileSync(client.configPath, 'utf-8'));
|
|
840
|
+
switch (client.format || 'json-mcpServers') {
|
|
841
|
+
case 'opencode': {
|
|
842
|
+
const entry = config.mcp && config.mcp.auxilo;
|
|
843
|
+
return entry && Array.isArray(entry.command) ? parseMcpPin(entry.command.slice(1)) : null;
|
|
844
|
+
}
|
|
845
|
+
case 'amp': {
|
|
846
|
+
const entry = config['amp.mcpServers'] && config['amp.mcpServers'].auxilo;
|
|
847
|
+
return entry ? parseMcpPin(entry.args) : null;
|
|
848
|
+
}
|
|
849
|
+
case 'openhands-stdio': {
|
|
850
|
+
const entry = Array.isArray(config.stdio_servers) &&
|
|
851
|
+
config.stdio_servers.find((s) => s && s.name === 'auxilo');
|
|
852
|
+
return entry ? parseMcpPin(entry.args) : null;
|
|
853
|
+
}
|
|
854
|
+
default: {
|
|
855
|
+
const entry = config.mcpServers && config.mcpServers.auxilo;
|
|
856
|
+
return entry ? parseMcpPin(entry.args) : null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} catch {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* MCP-SERVER-STALENESS (0.9.17): rewrite the Auxilo MCP pin to `version` in
|
|
866
|
+
* every DETECTED client whose config ALREADY carries a registered Auxilo
|
|
867
|
+
* entry. Called by lib/runner-autoupdate.js's stageAndSwap after a
|
|
868
|
+
* successful self-update, so a client's `npx auxilo-mcp@<pin>` tracks the
|
|
869
|
+
* runner version the self-update just installed instead of drifting stale
|
|
870
|
+
* until the user re-runs `auxilo setup` by hand.
|
|
871
|
+
*
|
|
872
|
+
* Deliberately NEVER registers a client that isn't already registered —
|
|
873
|
+
* that would silently opt a client the user never selected during
|
|
874
|
+
* interactive `auxilo setup` into MCP registration from the unattended
|
|
875
|
+
* self-update path. Reuses registerMcp/MCP_WRITERS, so the same ownership
|
|
876
|
+
* check applies: an existing entry that isn't recognizably ours
|
|
877
|
+
* (isOwnedMcpArgs) is left completely untouched, byte-identical.
|
|
878
|
+
*
|
|
879
|
+
* @param {string} homeDir
|
|
880
|
+
* @param {string} version Target pin (the just-installed package version).
|
|
881
|
+
* @param {object} [opts] Forwarded to detectClients (platform/env for tests).
|
|
882
|
+
* @returns {Array<{id:string, name:string, configPath:string, changed:boolean, status:string, error?:string}>}
|
|
883
|
+
*/
|
|
884
|
+
function rewriteMcpPins(homeDir, version, opts = {}) {
|
|
885
|
+
if (!homeDir) throw new Error('rewriteMcpPins: homeDir is required');
|
|
886
|
+
if (!version) throw new Error('rewriteMcpPins: version is required');
|
|
887
|
+
const results = [];
|
|
888
|
+
for (const client of detectClients(homeDir, opts).filter((c) => c.mcp)) {
|
|
889
|
+
if (!mcpRegistrationPresent(client)) continue; // not already registered — never first-register here
|
|
890
|
+
try {
|
|
891
|
+
const result = registerMcp(client, version);
|
|
892
|
+
results.push({ id: client.id, name: client.name, ...result });
|
|
893
|
+
} catch (err) {
|
|
894
|
+
// B15 discipline: malformed config — skip loudly, never overwrite,
|
|
895
|
+
// and never let one bad client config abort the rest of the rewrite.
|
|
896
|
+
results.push({ id: client.id, name: client.name, configPath: client.configPath, changed: false, status: 'error', error: err.message });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return results;
|
|
900
|
+
}
|
|
901
|
+
|
|
599
902
|
// ─── Credentials (spec §LW-12 step 2) ───────────────────────────────────────
|
|
600
903
|
|
|
601
904
|
function credentialsPath(homeDir) {
|
|
@@ -1837,11 +2140,22 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
1837
2140
|
if (!homeDir) throw new Error('getStatus: homeDir is required');
|
|
1838
2141
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
1839
2142
|
|
|
2143
|
+
// MCP-SERVER-STALENESS (0.9.17): installed runner version, for the
|
|
2144
|
+
// per-client pin-vs-runner comparison below. null when no runner is
|
|
2145
|
+
// installed yet (pre-setup) — mcpPinStale then stays false for everyone,
|
|
2146
|
+
// same as when the pin can't be read at all.
|
|
2147
|
+
const runnerVersion = installedRunnerVersion(homeDir);
|
|
2148
|
+
|
|
1840
2149
|
// 1. Clients: detected + whether MCP registration is present
|
|
1841
2150
|
const clients = detectClients(homeDir, opts).map((c) => {
|
|
1842
2151
|
// null = not applicable (no MCP config); per-format probe otherwise (UC-0).
|
|
1843
2152
|
const registered = c.mcp ? mcpRegistrationPresent(c) : null;
|
|
1844
2153
|
const base = { id: c.id, name: c.name, mcp: c.mcp, registered };
|
|
2154
|
+
if (c.mcp && registered) {
|
|
2155
|
+
const pin = mcpPinnedVersion(c);
|
|
2156
|
+
base.mcpPin = pin; // exact version string, 'unpinned' (legacy), or null (foreign entry)
|
|
2157
|
+
base.mcpPinStale = Boolean(pin && pin !== 'unpinned' && runnerVersion && pin !== runnerVersion);
|
|
2158
|
+
}
|
|
1845
2159
|
// UC-1: capture-hook probe (shim exists AND hook config references it).
|
|
1846
2160
|
if (c.captureHook) {
|
|
1847
2161
|
base.captureHook = true;
|
|
@@ -1915,6 +2229,7 @@ async function getStatus(homeDir, opts = {}) {
|
|
|
1915
2229
|
accountMode,
|
|
1916
2230
|
sentinel: sentinelPresent(homeDir),
|
|
1917
2231
|
runnerInstalled,
|
|
2232
|
+
runnerVersion,
|
|
1918
2233
|
hookInstalled,
|
|
1919
2234
|
hookScriptInstalled: fs.existsSync(hookPath),
|
|
1920
2235
|
hookRegistered,
|
|
@@ -2005,6 +2320,12 @@ module.exports = {
|
|
|
2005
2320
|
detectClients,
|
|
2006
2321
|
registerMcp,
|
|
2007
2322
|
mcpRegistrationPresent,
|
|
2323
|
+
mcpPinnedVersion,
|
|
2324
|
+
rewriteMcpPins,
|
|
2325
|
+
writeJsonAtomic,
|
|
2326
|
+
isOwnedMcpArgs,
|
|
2327
|
+
parseMcpPin,
|
|
2328
|
+
MCP_PACKAGE_NAME,
|
|
2008
2329
|
readClientConfig,
|
|
2009
2330
|
RULES_MARKER_BEGIN,
|
|
2010
2331
|
RULES_MARKER_END,
|
package/lib/runner-autoupdate.js
CHANGED
|
@@ -609,6 +609,27 @@ function stageAndSwap(homeDir, extractedPackageDir, installer) {
|
|
|
609
609
|
}
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
+
// MCP-SERVER-STALENESS (0.9.17): the runner stack install above just
|
|
613
|
+
// stamped binRoot/VERSION = result.version — rewrite every ALREADY
|
|
614
|
+
// REGISTERED client's Auxilo MCP pin to match, so `npx auxilo-mcp@<pin>`
|
|
615
|
+
// tracks the runner version this self-update just installed instead of
|
|
616
|
+
// drifting stale until the user re-runs `auxilo setup` by hand (the
|
|
617
|
+
// INSTALL-0828 vector, one level up the stack: an unpinned entry only
|
|
618
|
+
// refreshed when npx's own cache happened to expire).
|
|
619
|
+
//
|
|
620
|
+
// Uses the EXTRACTED tree's own installer (same B2 reasoning as
|
|
621
|
+
// installRunnerAtomic above — a newer release may register a client
|
|
622
|
+
// format this running copy doesn't know about yet) and is a BEST-EFFORT
|
|
623
|
+
// step: the runner swap itself already succeeded by this point and must
|
|
624
|
+
// not be reported as failed over a client-config quirk. Optional
|
|
625
|
+
// capability — an extracted tree that predates this function (or a test
|
|
626
|
+
// fixture's minimal installer stub) simply skips the rewrite.
|
|
627
|
+
if (typeof extractedInstaller.rewriteMcpPins === 'function') {
|
|
628
|
+
try {
|
|
629
|
+
extractedInstaller.rewriteMcpPins(homeDir, result.version);
|
|
630
|
+
} catch { /* best-effort — the runner swap itself already succeeded */ }
|
|
631
|
+
}
|
|
632
|
+
|
|
612
633
|
return binRoot;
|
|
613
634
|
}
|
|
614
635
|
|
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.
|
|
201
|
+
{ name: 'auxilo', version: '0.9.18' },
|
|
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.
|
|
@@ -1037,6 +1037,25 @@ if (process.argv[2] === 'login') {
|
|
|
1037
1037
|
}
|
|
1038
1038
|
})();
|
|
1039
1039
|
} else {
|
|
1040
|
+
// MCP-SERVER-STALENESS (0.9.17): ONE stderr notice, printed once at
|
|
1041
|
+
// startup, when this running package's own version differs from the
|
|
1042
|
+
// runner version installer.js last stamped into ~/.auxilo/bin/VERSION —
|
|
1043
|
+
// e.g. this process is a stale cached `npx auxilo-mcp` build while the
|
|
1044
|
+
// runner has already self-updated past it (or vice versa, right after a
|
|
1045
|
+
// fresh `auxilo setup`). No network call, no behaviour change — advisory
|
|
1046
|
+
// only, and silent when the VERSION stamp is absent (no runner installed
|
|
1047
|
+
// yet) or matches.
|
|
1048
|
+
try {
|
|
1049
|
+
const installer = require('./lib/installer.js');
|
|
1050
|
+
const ownVersion = installer.packageVersion();
|
|
1051
|
+
const installedVersion = installer.installedRunnerVersion(os.homedir());
|
|
1052
|
+
if (installedVersion && installedVersion !== ownVersion) {
|
|
1053
|
+
console.error(
|
|
1054
|
+
`[auxilo-mcp] Note: this MCP server is running v${ownVersion}, but the installed runner is v${installedVersion}. Run \`npx auxilo setup\` to re-pin.`
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
} catch { /* advisory only — must never block startup */ }
|
|
1058
|
+
|
|
1040
1059
|
// Normal MCP server startup
|
|
1041
1060
|
async function main() {
|
|
1042
1061
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auxilo-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18",
|
|
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",
|
package/scripts/extract-local.js
CHANGED
|
@@ -400,37 +400,37 @@ function judgeUsage(usage, prompt, completion) {
|
|
|
400
400
|
|
|
401
401
|
/**
|
|
402
402
|
* PART C — resolve the extraction_model identity for a runModel result.
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* never
|
|
403
|
+
*
|
|
404
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1): this function used to fall
|
|
405
|
+
* back to a FRESH, INDEPENDENT providers.resolveProvider() call whenever the
|
|
406
|
+
* result carried no `identity` — a re-detect decoupled from which provider
|
|
407
|
+
* actually produced `runModelResult`, which is what "what would run now"
|
|
408
|
+
* answers, not "what ran". That re-resolve could also silently rewrite
|
|
409
|
+
* `~/.auxilo/providers.json` (resolveProvider's full-scan path calls
|
|
410
|
+
* persistSelected) from what should have been a read-only identity lookup.
|
|
411
|
+
* Both are gone. `identity` is now ALWAYS attached by
|
|
412
|
+
* scripts/providers/index.js's runModel() itself — centrally, because that
|
|
413
|
+
* registry is the only thing that knows which module it actually invoked
|
|
414
|
+
* for this call (see its withIdentity()/deriveIdentity() — claude-code's own
|
|
415
|
+
* cliVersion is used there when present, richer than the null/null/null
|
|
416
|
+
* triple this function used to guess). This function's job shrinks to: use
|
|
417
|
+
* the identity the result actually carries, or admit the honest
|
|
418
|
+
* `provider:'unknown'` when none exists (the `no-usable-provider` /
|
|
419
|
+
* bad-override-name aggregate failures — nothing actually ran to
|
|
420
|
+
* completion, so there is nothing to attribute). Never re-derives, never
|
|
421
|
+
* writes, never blocks extraction on failure.
|
|
422
422
|
*/
|
|
423
|
-
|
|
424
|
-
if (
|
|
423
|
+
function resolveExtractionModelIdentity(runModelResult) {
|
|
424
|
+
if (
|
|
425
|
+
runModelResult
|
|
426
|
+
&& runModelResult.identity
|
|
427
|
+
&& typeof runModelResult.identity === 'object'
|
|
428
|
+
&& typeof runModelResult.identity.provider === 'string'
|
|
429
|
+
&& runModelResult.identity.provider
|
|
430
|
+
) {
|
|
425
431
|
return runModelResult.identity;
|
|
426
432
|
}
|
|
427
|
-
|
|
428
|
-
const resolved = await providers.resolveProvider(opts);
|
|
429
|
-
if (resolved && resolved.ok && resolved.id) {
|
|
430
|
-
return { provider: resolved.id, model: null, version: null, vendor: null };
|
|
431
|
-
}
|
|
432
|
-
} catch { /* identity is best-effort; never block extraction on it */ }
|
|
433
|
-
return null;
|
|
433
|
+
return { provider: 'unknown', model: null, version: null, vendor: null };
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
/**
|
|
@@ -456,7 +456,7 @@ async function defaultInvokeModel(transcript, invokeOpts, opts) {
|
|
|
456
456
|
reason: result.reason,
|
|
457
457
|
reasonCode: result.reasonCode,
|
|
458
458
|
authStatus: result.authStatus,
|
|
459
|
-
extractionModel:
|
|
459
|
+
extractionModel: resolveExtractionModelIdentity(result),
|
|
460
460
|
...(result.authDiscrepancy !== undefined && { authDiscrepancy: result.authDiscrepancy }),
|
|
461
461
|
// EXTRACTION-RUN-LOG (0.9.15): additive passthrough for the one-line-per-run
|
|
462
462
|
// provider summary logged at the end of extractLocally() below. Only
|
|
@@ -704,12 +704,53 @@ function formatArgvForLog(argv) {
|
|
|
704
704
|
* (spawned) this run, not whether it succeeded — a spawn that ran and then
|
|
705
705
|
* hit a model error still counts as "ran" (it happened; the failure is in
|
|
706
706
|
* `reason`, not in whether isolation applied). `hooks` is `claude-code`-
|
|
707
|
-
* specific
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
707
|
+
* specific and EVIDENCE-DERIVED (EXTRACT-LOG-HOOKS-EVIDENCE, PUNCH-LIST P2):
|
|
708
|
+
* it reads the argv this run actually captured rather than inferring
|
|
709
|
+
* isolation from the provider name plus the absence of a reason code —
|
|
710
|
+
* 'isolated' ONLY when that argv literally contains --setting-sources (the
|
|
711
|
+
* flag scripts/providers/claude-code.js's fail-closed gate never spawns
|
|
712
|
+
* without), 'unsupported' when the CLI was found not to support the flag at
|
|
713
|
+
* all (existing reason-code path, unchanged), 'unknown' when no argv was
|
|
714
|
+
* captured this run (finder skipped pre-spawn, or the run that actually
|
|
715
|
+
* produced this result fell through to a different/no provider — see the
|
|
716
|
+
* EXTRACT-LOG-HOOKS-EVIDENCE root-cause note below), and 'n/a' for a
|
|
717
|
+
* non-claude-code provider (codex-cli/byo-key isolate by a different
|
|
718
|
+
* mechanism entirely, out of this row's scope). Never 'isolated' without an
|
|
719
|
+
* argv carrying the flag in hand — a safety claim needs evidence, not an
|
|
720
|
+
* absence of contrary evidence.
|
|
721
|
+
*
|
|
722
|
+
* Root cause of the missing-argv runs (EXTRACT-LOG-HOOKS-EVIDENCE
|
|
723
|
+
* investigation): claude-code.js's own runExtractMode() never omits argv on
|
|
724
|
+
* a claude-code result that actually reached this line — every return after
|
|
725
|
+
* `const argv = EXTRACT_MODE_ARGV` carries it, and the two pre-spawn
|
|
726
|
+
* short-circuits that don't (cached --setting-sources-unsupported,
|
|
727
|
+
* cli-unauthenticated) both carry reasonCodes already in
|
|
728
|
+
* PRE_SPAWN_SKIP_REASON_CODES, so they render finder=skipped, not ran. The
|
|
729
|
+
* observed defect lines (finder=ran, flags=n/a) come from a DIFFERENT case:
|
|
730
|
+
* scripts/providers/index.js's runModel() falls through from claude-code to
|
|
731
|
+
* the next configured provider (e.g. codex-cli) whenever claude-code's own
|
|
732
|
+
* attempt fails with a NON_RETRYABLE_FOR_THIS_PROVIDER reasonCode, and
|
|
733
|
+
* returns that OTHER provider's result directly when it stops there. That
|
|
734
|
+
* provider's result carries no `argv` field at all (argv is a
|
|
735
|
+
* claude-code-only concept), so there is no shipped argv being hidden here —
|
|
736
|
+
* 'unknown' remains the correct, honest `hooks` value regardless of which
|
|
737
|
+
* provider is named.
|
|
738
|
+
*
|
|
739
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1) closed the mismatch this
|
|
740
|
+
* docblock used to describe as a known, deferred gap: a fallthrough
|
|
741
|
+
* provider's result used to reach this function carrying no `identity` on
|
|
742
|
+
* failure (codex-cli/byo-key only self-stamped on success), so
|
|
743
|
+
* resolveExtractionModelIdentity()'s old fallback re-resolved the label via
|
|
744
|
+
* a FRESH, INDEPENDENT providers.resolveProvider() call — decoupled from
|
|
745
|
+
* which provider's runModel() result was actually being logged, and prone to
|
|
746
|
+
* landing back on 'claude-code' (its detect() only checks the
|
|
747
|
+
* billing-helper gate + auth status, not whether the earlier attempt
|
|
748
|
+
* actually spawned). That fallback is gone. `identity` is now attached
|
|
749
|
+
* centrally by providers/index.js's runModel() to EVERY result it returns —
|
|
750
|
+
* success or failure, fallthrough or not — because that registry alone
|
|
751
|
+
* knows which module it actually invoked for a given attempt. The line this
|
|
752
|
+
* function renders now names the provider that actually ran (or 'unknown'
|
|
753
|
+
* only when nothing did), not a guess.
|
|
713
754
|
*/
|
|
714
755
|
function logProviderRunSummary(opts, runId, modelResult, judged) {
|
|
715
756
|
try {
|
|
@@ -725,8 +766,11 @@ function logProviderRunSummary(opts, runId, modelResult, judged) {
|
|
|
725
766
|
const cliVersion = modelResult.cliVersion || (judged && judged.judgeCliVersion) || null;
|
|
726
767
|
const finderUnsupported = modelResult.reasonCode === 'cli-settings-isolation-unsupported';
|
|
727
768
|
const judgeUnsupported = Boolean(judged && judged.judgeReasonCode === 'cli-settings-isolation-unsupported');
|
|
769
|
+
const hasSettingSourcesArgv = Array.isArray(argv) && argv.includes('--setting-sources');
|
|
728
770
|
const hooks = provider === 'claude-code'
|
|
729
|
-
? ((finderUnsupported || judgeUnsupported)
|
|
771
|
+
? ((finderUnsupported || judgeUnsupported)
|
|
772
|
+
? 'unsupported'
|
|
773
|
+
: (hasSettingSourcesArgv ? 'isolated' : 'unknown'))
|
|
730
774
|
: 'n/a';
|
|
731
775
|
log(
|
|
732
776
|
`[providers] run=${runId || 'unknown'} provider=${provider} cli=${cliVersion || '-'} ` +
|
|
@@ -875,4 +919,7 @@ module.exports = {
|
|
|
875
919
|
resolveClaudeBin: claudeCodeProvider.resolveClaudeBin,
|
|
876
920
|
// EXTRACTION-RUN-LOG (0.9.15) — exported for direct unit coverage.
|
|
877
921
|
formatArgvForLog, logProviderRunSummary, PRE_SPAWN_SKIP_REASON_CODES,
|
|
922
|
+
// EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1) — exported for direct unit
|
|
923
|
+
// coverage of the "never guess, fail closed to unknown" contract.
|
|
924
|
+
resolveExtractionModelIdentity,
|
|
878
925
|
};
|
|
@@ -245,6 +245,92 @@ const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
|
|
|
245
245
|
'cli-settings-isolation-unsupported',
|
|
246
246
|
]);
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1, follow-up to the
|
|
250
|
+
* EXTRACT-LOG-HOOKS-EVIDENCE row): `identity` on a runModel() result is
|
|
251
|
+
* provenance — a record of which provider actually produced this text — not
|
|
252
|
+
* a guess. It must never be re-derived after the fact from a fresh detect(),
|
|
253
|
+
* because a fresh detect() answers "what would run now", not "what ran".
|
|
254
|
+
* This registry is the only thing that KNOWS which module's runModel() it
|
|
255
|
+
* just invoked for a given attempt, so identity is enforced HERE, centrally,
|
|
256
|
+
* rather than trusted to per-provider convention (the gap this row closes:
|
|
257
|
+
* before this fix, a provider `ok:true` return that forgot to attach
|
|
258
|
+
* `identity` would silently fall through to extract-local.js's
|
|
259
|
+
* resolveExtractionModelIdentity() re-detecting via resolveProvider() —
|
|
260
|
+
* decoupled from which module actually ran).
|
|
261
|
+
*/
|
|
262
|
+
function hasUsableIdentity(identity) {
|
|
263
|
+
return Boolean(
|
|
264
|
+
identity
|
|
265
|
+
&& typeof identity === 'object'
|
|
266
|
+
&& typeof identity.provider === 'string'
|
|
267
|
+
&& identity.provider
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Derive an identity for the module actually invoked as `id`, used only when
|
|
273
|
+
* that module's own result didn't already carry one. The derivation differs
|
|
274
|
+
* by outcome, because a SUCCESS identity can reach a published learning and
|
|
275
|
+
* the clean-lane calibration gate, while a FAILURE identity is purely
|
|
276
|
+
* diagnostic (extract-local.js returns before stamping anything onto a
|
|
277
|
+
* candidate on `ok:false` — see its `:824-834`):
|
|
278
|
+
*
|
|
279
|
+
* - claude-code: always gets a real, provider-specific identity — it
|
|
280
|
+
* already has `cliVersion` in hand on every returned result (success or
|
|
281
|
+
* failure), richer than the null/null/null triple this used to guess, and
|
|
282
|
+
* claude-code is the one provider documented to never self-stamp
|
|
283
|
+
* `identity` at all, so this is filling a KNOWN, structural gap, not
|
|
284
|
+
* papering over a violated contract.
|
|
285
|
+
* - Every other provider on a FAILURE: `{provider:id, model:null,
|
|
286
|
+
* version:null, vendor:null}` — naming the module we actually invoked is
|
|
287
|
+
* a plain structural fact (we chose to call it), not a guess, and it is
|
|
288
|
+
* what lets the per-run `[providers]` log name the provider that actually
|
|
289
|
+
* ran even when that provider's own failure return carries no identity
|
|
290
|
+
* (codex-cli and byo-key only self-stamp on their SINGLE success return —
|
|
291
|
+
* this is the exact fall-through failure case the investigation traced:
|
|
292
|
+
* claude-code skipped, codex-cli ran and failed, no identity of its own,
|
|
293
|
+
* the label used to be re-guessed as claude-code by the old fallback).
|
|
294
|
+
* - Every other provider on a SUCCESS: `{provider:'unknown', ...}` —
|
|
295
|
+
* byo-key.js and codex-cli.js both self-stamp `identity` on their only
|
|
296
|
+
* success return BY CONTRACT; a success with none means that contract was
|
|
297
|
+
* violated, so this registry has no provider-reported basis for the
|
|
298
|
+
* claim. Confidently naming the module here would look like provenance
|
|
299
|
+
* without being backed by anything the provider itself reported — since
|
|
300
|
+
* this stamp CAN reach a published learning, the fail-closed, honest
|
|
301
|
+
* answer is 'unknown', never a guess dressed up as a fact.
|
|
302
|
+
*/
|
|
303
|
+
function deriveIdentity(id, result) {
|
|
304
|
+
if (id === 'claude-code') {
|
|
305
|
+
return {
|
|
306
|
+
provider: 'claude-code',
|
|
307
|
+
model: null,
|
|
308
|
+
version: (result && result.cliVersion) || null,
|
|
309
|
+
vendor: 'anthropic',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!(result && result.ok)) {
|
|
313
|
+
return { provider: id, model: null, version: null, vendor: null };
|
|
314
|
+
}
|
|
315
|
+
return { provider: 'unknown', model: null, version: null, vendor: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Attach a derived identity to `result` IFF it doesn't already carry a
|
|
320
|
+
* usable one — never overwrites a provider-reported identity (e.g.
|
|
321
|
+
* byo-key's real model name). Applied to every result this registry
|
|
322
|
+
* returns, success or failure, so the per-run `[providers]` log
|
|
323
|
+
* (scripts/extract-local.js's logProviderRunSummary) names the provider
|
|
324
|
+
* that actually ran even on a failure — that is the fall-through failure
|
|
325
|
+
* case this row's investigation traced (claude-code skipped, codex-cli ran
|
|
326
|
+
* and failed with no identity of its own, the stamp used to be re-guessed
|
|
327
|
+
* as claude-code by the caller).
|
|
328
|
+
*/
|
|
329
|
+
function withIdentity(id, result) {
|
|
330
|
+
if (hasUsableIdentity(result && result.identity)) return result;
|
|
331
|
+
return { ...result, identity: deriveIdentity(id, result) };
|
|
332
|
+
}
|
|
333
|
+
|
|
248
334
|
/**
|
|
249
335
|
* runModel(opts) — resolve a starting provider via resolveProvider(), then
|
|
250
336
|
* walk PROVIDER_ORDER from there, calling each candidate's OWN runModel()
|
|
@@ -262,7 +348,9 @@ const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
|
|
|
262
348
|
* as-is. When every provider tried is exhausted, returns reasonCode
|
|
263
349
|
* 'no-usable-provider' with a bounded summary of every provider's reason in
|
|
264
350
|
* `reason` (no secrets — each provider's own reason string is already
|
|
265
|
-
* secret-free by contract)
|
|
351
|
+
* secret-free by contract) and NO identity — nothing actually ran to
|
|
352
|
+
* completion, so the caller (extract-local.js) stamps `provider:'unknown'`
|
|
353
|
+
* rather than have this registry guess one. Never throws.
|
|
266
354
|
*/
|
|
267
355
|
async function runModel(opts = {}) {
|
|
268
356
|
const mode = opts.mode === 'judge' ? 'judge' : 'extract';
|
|
@@ -281,7 +369,8 @@ async function runModel(opts = {}) {
|
|
|
281
369
|
authStatus: 'unknown',
|
|
282
370
|
};
|
|
283
371
|
}
|
|
284
|
-
|
|
372
|
+
const result = await resolved.module.runModel({ ...opts, mode });
|
|
373
|
+
return withIdentity(resolved.id, result);
|
|
285
374
|
}
|
|
286
375
|
|
|
287
376
|
const log = typeof opts.log === 'function' ? opts.log : console.error;
|
|
@@ -293,7 +382,8 @@ async function runModel(opts = {}) {
|
|
|
293
382
|
for (const id of order) {
|
|
294
383
|
const mod = PROVIDERS[id];
|
|
295
384
|
// eslint-disable-next-line no-await-in-loop
|
|
296
|
-
const
|
|
385
|
+
const rawResult = await mod.runModel({ ...opts, mode });
|
|
386
|
+
const result = withIdentity(id, rawResult);
|
|
297
387
|
if (result.ok) return result;
|
|
298
388
|
attempts.push({ id, reasonCode: result.reasonCode, reason: result.reason });
|
|
299
389
|
if (!NON_RETRYABLE_FOR_THIS_PROVIDER.has(result.reasonCode)) {
|