@haven_ai/connect 0.2.1-alpha.0 → 0.4.0-alpha.0

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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import crypto, { createHash } from 'crypto';
3
3
  import { Wallet } from 'ethers';
4
- import { mkdir, rm, stat, readdir, readFile, writeFile, chmod, access, unlink, rename } from 'fs/promises';
4
+ import { mkdir, rm, stat, readdir, readFile, writeFile, chmod, access, unlink, realpath, rename } from 'fs/promises';
5
5
  import { homedir, platform } from 'os';
6
6
  import { join, resolve, dirname, basename } from 'path';
7
7
  import { ensureConsent, computeConsentHash, loadCredentials, consentInputFromClient, registeredToolNames, MCP_VERSION } from '@haven_ai/mcp';
@@ -165,6 +165,16 @@ function redactSecrets(value) {
165
165
  function redactForAutomation(value) {
166
166
  return redactSecrets(value).replace(/(?:~|\/)[^\s`"']*\/(?:identity|signer|agent)\.json\b/g, "[credential-file-redacted]").replace(/(?:~|\/)[^\s`"']*\/\.env\b/g, "[credential-env-redacted]");
167
167
  }
168
+ function withoutUserinfo(url) {
169
+ try {
170
+ const parsed = new URL(url);
171
+ parsed.username = "";
172
+ parsed.password = "";
173
+ return parsed.toString().replace(/\/+$/, "");
174
+ } catch {
175
+ return url;
176
+ }
177
+ }
168
178
  function shortAddress(address) {
169
179
  if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return address;
170
180
  return `${address.slice(0, 6)}...${address.slice(-4)}`;
@@ -526,12 +536,64 @@ async function writeConnectOutcomeRecord(directory, outcome, warn) {
526
536
  await restrictPermissions(path, 384, warn);
527
537
  return path;
528
538
  }
529
- var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME;
539
+ async function readConnectOutcomeRuntime(directory) {
540
+ try {
541
+ const parsed = JSON.parse(await readFile(join(directory, CONNECT_OUTCOME_FILENAME), "utf8"));
542
+ if (typeof parsed !== "object" || parsed === null) return null;
543
+ const runtime = parsed.runtime;
544
+ return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+ async function writeMcpServerBinding(directory, binding) {
550
+ const path = join(directory, MCP_SERVER_BINDING_FILENAME);
551
+ await writeFile(path, `${JSON.stringify(binding, null, 2)}
552
+ `, { mode: 384 });
553
+ return path;
554
+ }
555
+ async function readMcpServerBinding(directory) {
556
+ try {
557
+ const parsed = JSON.parse(await readFile(join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
558
+ if (typeof parsed !== "object" || parsed === null) return null;
559
+ const record = parsed;
560
+ if (record.version !== 1 || typeof record.server_name !== "string" || typeof record.agent_id !== "string" || typeof record.api_url !== "string" || typeof record.bound_at !== "string") return null;
561
+ return record;
562
+ } catch {
563
+ return null;
564
+ }
565
+ }
566
+ async function clearMcpServerBinding(directory) {
567
+ try {
568
+ await rm(join(directory, MCP_SERVER_BINDING_FILENAME));
569
+ return true;
570
+ } catch {
571
+ return false;
572
+ }
573
+ }
574
+ async function listMcpServerBindings(baseDir, excludeDirectory) {
575
+ const root = defaultCredentialRoot(baseDir);
576
+ let entries = [];
577
+ try {
578
+ entries = await readdir(root);
579
+ } catch {
580
+ return [];
581
+ }
582
+ const out = [];
583
+ for (const entry of entries) {
584
+ const directory = join(root, entry);
585
+ const binding = await readMcpServerBinding(directory);
586
+ if (binding) out.push({ directory, binding });
587
+ }
588
+ return out;
589
+ }
590
+ var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME, MCP_SERVER_BINDING_FILENAME;
530
591
  var init_storage = __esm({
531
592
  "src/storage.ts"() {
532
593
  REKEY_PENDING_FILENAME = "rekey-pending.json";
533
594
  REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
534
595
  CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
596
+ MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
535
597
  }
536
598
  });
537
599
  function mcpPackageSpec() {
@@ -550,9 +612,9 @@ var init_runtime_manifest = __esm({
550
612
  mcpPackage: "@haven_ai/mcp",
551
613
  mcpVersion: MCP_VERSION,
552
614
  sdkPackage: "@haven_ai/sdk",
553
- sdkVersion: "0.2.1-alpha.0",
615
+ sdkVersion: "0.4.0-alpha.0",
554
616
  signerPackage: "@haven_ai/signer",
555
- signerVersion: "0.2.1-alpha.0",
617
+ signerVersion: "0.4.0-alpha.0",
556
618
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
557
619
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
558
620
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -1440,7 +1502,7 @@ async function buildLocalMcpConsentInput(identityPath, signerPath) {
1440
1502
  apiKey: credentials.apiKey,
1441
1503
  apiUrl: credentials.apiUrl,
1442
1504
  agentId: credentials.agentId,
1443
- safeAddress: credentials.accountAddress ?? credentials.safeAddress,
1505
+ accountAddress: credentials.accountAddress,
1444
1506
  delegateAddress: credentials.delegateAddress,
1445
1507
  chainId: credentials.chainId,
1446
1508
  allowanceSummary: credentials.allowanceSummary
@@ -1521,7 +1583,7 @@ async function probeLocalSignerCredential(signerPath) {
1521
1583
  }
1522
1584
  }
1523
1585
  async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
1524
- return new Promise((resolve9) => {
1586
+ return new Promise((resolve10) => {
1525
1587
  const child = spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
1526
1588
  let stdout = "";
1527
1589
  let settled = false;
@@ -1533,7 +1595,7 @@ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4)
1533
1595
  settled = true;
1534
1596
  clearTimeout(timeout);
1535
1597
  child.kill();
1536
- resolve9(result);
1598
+ resolve10(result);
1537
1599
  };
1538
1600
  const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1539
1601
  child.on("error", () => finish({ status: "process_error" }));
@@ -2482,7 +2544,7 @@ async function buildSignerConsentInput(signerPath) {
2482
2544
  });
2483
2545
  return {
2484
2546
  delegateAddress: signer.delegateAddress,
2485
- safeAddress: credentials.accountAddress ?? credentials.safeAddress,
2547
+ accountAddress: credentials.accountAddress,
2486
2548
  agentId: credentials.agentId,
2487
2549
  chainId: credentials.chainId,
2488
2550
  network: credentials.network,
@@ -3178,8 +3240,52 @@ async function unwireAgent(input) {
3178
3240
  }
3179
3241
  }
3180
3242
  }
3181
- await teardownLocalKeyMaterial(input.directory, identity);
3182
- return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3243
+ const bindingReleased = await clearMcpServerBinding(input.directory);
3244
+ const teardown = await decideTeardown(identity, input);
3245
+ if (teardown.status !== "retained") await teardownLocalKeyMaterial(input.directory, identity);
3246
+ return { directory: input.directory, agentId, slug, tombstoned, runtimes, teardown, bindingReleased };
3247
+ }
3248
+ async function decideTeardown(identity, input) {
3249
+ const ended = "Local recovery of a stranded delegate balance ends with it: the sweep-recovery routes accept only this agent's API key and delegate signature, and neither exists on this machine any more.";
3250
+ if (!identity?.api_key || !identity.api_url) {
3251
+ return {
3252
+ status: input.destroyKeyMaterial ? "forced" : "destroyed",
3253
+ probe: "not_probed",
3254
+ detail: "No stored API key + API URL to probe with \u2014 nothing the sweep-recovery routes would accept, so nothing to preserve."
3255
+ };
3256
+ }
3257
+ const probe = await (input.probeHostedIdentity ?? probeHostedAgentIdentity)(identity.api_key, identity.api_url, input.fetch);
3258
+ if (input.destroyKeyMaterial) {
3259
+ return {
3260
+ status: "forced",
3261
+ probe: probe.status,
3262
+ detail: `Key material destroyed under ${DESTROY_FLAG} (probe: ${probe.status}): signer.json, any parked re-key, and the API key in identity.json.`,
3263
+ remedy: ended
3264
+ };
3265
+ }
3266
+ switch (probe.status) {
3267
+ case "ok":
3268
+ return {
3269
+ status: "retained",
3270
+ probe: "ok",
3271
+ detail: "This agent is still ACTIVE on the backend: its API key and delegate key still carry spend authority, so destroying them would be a live spend-authority change. Key material kept in this directory (0o600); its MCP wiring above is gone.",
3272
+ remedy: `Revoke the agent on the Haven agent page (connect never revokes), then re-run --unwire; or, to delete the key anyway, re-run with ${DESTROY_FLAG}.`
3273
+ };
3274
+ case "unauthorized":
3275
+ return {
3276
+ status: "retained",
3277
+ probe: "unauthorized",
3278
+ detail: "This key no longer authenticates on normal routes (revoked, archived, paused, pending approval, rotated, or not a key the backend knows \u2014 it does not say which). A stranded delegate balance MAY still exist and the connector CANNOT check: this directory may hold the only local credential the sweep-recovery routes would still accept. Key material kept.",
3279
+ remedy: `Recover any stranded balance first (haven_sweep_delegate from a runtime still wired to this agent, or the Haven agent page), then re-run with ${DESTROY_FLAG} to remove the key material.`
3280
+ };
3281
+ default:
3282
+ return {
3283
+ status: "retained",
3284
+ probe: probe.status,
3285
+ detail: `Could not verify what this key is still good for (${probe.status}). Unknown is not "safe to delete": destroying it on a failed check would erase the evidence of a live key. Key material kept.`,
3286
+ remedy: `Retry when the backend is reachable, or re-run with ${DESTROY_FLAG} to remove the key material regardless.`
3287
+ };
3288
+ }
3183
3289
  }
3184
3290
  async function tombstoneDirectoryIfAbsent(input) {
3185
3291
  if (await readOptionalText(join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
@@ -3200,9 +3306,10 @@ async function teardownLocalKeyMaterial(directory, identity) {
3200
3306
  async function readIdentityFile(directory) {
3201
3307
  return identityAt(directory);
3202
3308
  }
3203
- var RUNTIMES;
3309
+ var RUNTIMES, DESTROY_FLAG;
3204
3310
  var init_unwire = __esm({
3205
3311
  "src/unwire.ts"() {
3312
+ init_probes();
3206
3313
  init_config_writers();
3207
3314
  init_server_names();
3208
3315
  init_signer_runtime();
@@ -3216,6 +3323,7 @@ var init_unwire = __esm({
3216
3323
  { runtime: "vscode-insiders", label: "VS Code Insiders MCP config", kind: "json", serverRoot: "servers" },
3217
3324
  { runtime: "claude-desktop", label: "Claude Desktop config", kind: "json", serverRoot: "mcpServers" }
3218
3325
  ];
3326
+ DESTROY_FLAG = "--destroy-key-material";
3219
3327
  }
3220
3328
  });
3221
3329
 
@@ -3227,6 +3335,174 @@ var init_rekey_messages = __esm({
3227
3335
  }
3228
3336
  });
3229
3337
 
3338
+ // src/prune-runtimes.ts
3339
+ var prune_runtimes_exports = {};
3340
+ __export(prune_runtimes_exports, {
3341
+ normalizeRuntimePath: () => normalizeRuntimePath,
3342
+ pruneSignerRuntimes: () => pruneSignerRuntimes,
3343
+ referencedRuntimeDirectories: () => referencedRuntimeDirectories,
3344
+ rollUpPruneLevel: () => rollUpPruneLevel,
3345
+ signerRuntimeRoot: () => signerRuntimeRoot
3346
+ });
3347
+ function signerRuntimeRoot(homeDir) {
3348
+ return join(homeDir, ".haven", "signer-runtime");
3349
+ }
3350
+ function classifyKey(key) {
3351
+ if (key.startsWith("override-")) return "override";
3352
+ if (key === MCP_RUNTIME_MANIFEST.signerVersion || /^\d+\.\d+\.\d+/.test(key)) return "version";
3353
+ return "unknown";
3354
+ }
3355
+ async function directoryBytes(directory) {
3356
+ let total = 0;
3357
+ const walk = async (dir) => {
3358
+ let entries = [];
3359
+ try {
3360
+ entries = await readdir(dir, { withFileTypes: true });
3361
+ } catch {
3362
+ return;
3363
+ }
3364
+ for (const entry of entries) {
3365
+ const path = join(dir, entry.name);
3366
+ if (entry.isDirectory()) await walk(path);
3367
+ else if (entry.isFile()) {
3368
+ try {
3369
+ total += (await stat(path)).size;
3370
+ } catch {
3371
+ }
3372
+ }
3373
+ }
3374
+ };
3375
+ await walk(directory);
3376
+ return total;
3377
+ }
3378
+ async function normalizeRuntimePath(path) {
3379
+ const resolved = resolve(path).replace(/[\\/]+$/, "");
3380
+ try {
3381
+ return await realpath(resolved);
3382
+ } catch {
3383
+ return resolved;
3384
+ }
3385
+ }
3386
+ function escapeRegExp(text) {
3387
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3388
+ }
3389
+ async function referencedRuntimeDirectories(homeDir, credentialsDir) {
3390
+ const roots = /* @__PURE__ */ new Set([join(homeDir, ".haven", "agents")]);
3391
+ if (credentialsDir) roots.add(dirname(resolve(credentialsDir)));
3392
+ const referenced = /* @__PURE__ */ new Map();
3393
+ const runtimeRoot = await normalizeRuntimePath(signerRuntimeRoot(homeDir));
3394
+ const rootSpellings = [.../* @__PURE__ */ new Set([resolve(signerRuntimeRoot(homeDir)), runtimeRoot])];
3395
+ const wrapperRe = new RegExp(`(?:${rootSpellings.map(escapeRegExp).join("|")})[\\/]+([^\\/'"\\s]+)`, "g");
3396
+ const add = async (runtimeDirectory, by) => {
3397
+ const key = await normalizeRuntimePath(runtimeDirectory);
3398
+ const list = referenced.get(key) ?? [];
3399
+ if (!list.includes(by)) list.push(by);
3400
+ referenced.set(key, list);
3401
+ };
3402
+ for (const root of roots) {
3403
+ let entries = [];
3404
+ try {
3405
+ entries = await readdir(root);
3406
+ } catch {
3407
+ continue;
3408
+ }
3409
+ for (const entry of entries) {
3410
+ const directory = join(root, entry);
3411
+ try {
3412
+ const sidecar = JSON.parse(await readFile(join(directory, "signer-runtime.json"), "utf8"));
3413
+ if (typeof sidecar.runtime_directory === "string" && sidecar.runtime_directory.length > 0) {
3414
+ await add(sidecar.runtime_directory, directory);
3415
+ }
3416
+ } catch {
3417
+ }
3418
+ try {
3419
+ const wrapper = await readFile(join(directory, "bin", "haven-signer.mjs"), "utf8");
3420
+ for (const match of wrapper.matchAll(wrapperRe)) await add(join(runtimeRoot, match[1]), directory);
3421
+ } catch {
3422
+ }
3423
+ }
3424
+ }
3425
+ return referenced;
3426
+ }
3427
+ function rollUpPruneLevel(entries) {
3428
+ if (entries.some((e) => e.level === "failed")) return "failed";
3429
+ if (entries.some((e) => e.level === "advisory")) return "advisory";
3430
+ return "ok";
3431
+ }
3432
+ async function pruneSignerRuntimes(input, deps = {}) {
3433
+ const homeDir = deps.homeDir ?? homedir();
3434
+ const root = signerRuntimeRoot(homeDir);
3435
+ const measure = input.measure ?? true;
3436
+ const referenced = await referencedRuntimeDirectories(homeDir, deps.credentialsDir);
3437
+ const pin = await normalizeRuntimePath(join(root, MCP_RUNTIME_MANIFEST.signerVersion));
3438
+ const remove = deps.rm ?? (async (directory) => rm(directory, { recursive: true, force: false }));
3439
+ const entries = [];
3440
+ let keys = [];
3441
+ try {
3442
+ keys = (await readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
3443
+ } catch {
3444
+ return { version: 1, root, dryRun: input.dryRun, entries, removed: 0, reclaimedBytes: 0, level: "ok" };
3445
+ }
3446
+ let removed = 0;
3447
+ let reclaimedBytes = 0;
3448
+ for (const key of keys) {
3449
+ const directory = join(root, key);
3450
+ const normalized = await normalizeRuntimePath(directory);
3451
+ const referencedBy = referenced.get(normalized) ?? [];
3452
+ const kind = classifyKey(key);
3453
+ if (referencedBy.length > 0 || normalized === pin) {
3454
+ entries.push({
3455
+ directory,
3456
+ key,
3457
+ kind,
3458
+ bytes: 0,
3459
+ referencedBy,
3460
+ action: "kept",
3461
+ level: "ok",
3462
+ detail: referencedBy.length > 0 ? `kept \u2014 named by ${referencedBy.length} credential director${referencedBy.length === 1 ? "y" : "ies"} (sidecar or wrapper)` : "kept \u2014 the connector's current pinned version (what --repair installs)"
3463
+ });
3464
+ continue;
3465
+ }
3466
+ const bytes = measure ? await directoryBytes(directory) : 0;
3467
+ if (input.dryRun) {
3468
+ entries.push({
3469
+ directory,
3470
+ key,
3471
+ kind,
3472
+ bytes,
3473
+ referencedBy,
3474
+ action: "would_remove",
3475
+ level: "advisory",
3476
+ detail: `would remove \u2014 no credential directory names it (${kind}-keyed)`
3477
+ });
3478
+ continue;
3479
+ }
3480
+ try {
3481
+ await remove(directory);
3482
+ removed += 1;
3483
+ reclaimedBytes += bytes;
3484
+ entries.push({ directory, key, kind, bytes, referencedBy, action: "removed", level: "ok", detail: `removed \u2014 no credential directory named it (${kind}-keyed)` });
3485
+ } catch (err) {
3486
+ entries.push({
3487
+ directory,
3488
+ key,
3489
+ kind,
3490
+ bytes,
3491
+ referencedBy,
3492
+ action: "failed",
3493
+ level: "failed",
3494
+ detail: `removal failed: ${err instanceof Error ? err.message : String(err)} \u2014 a signer process may still hold it open; stop it and re-run`
3495
+ });
3496
+ }
3497
+ }
3498
+ return { version: 1, root, dryRun: input.dryRun, entries, removed, reclaimedBytes, level: rollUpPruneLevel(entries) };
3499
+ }
3500
+ var init_prune_runtimes = __esm({
3501
+ "src/prune-runtimes.ts"() {
3502
+ init_runtime_manifest();
3503
+ }
3504
+ });
3505
+
3230
3506
  // src/rekey.ts
3231
3507
  var rekey_exports = {};
3232
3508
  __export(rekey_exports, {
@@ -3320,9 +3596,10 @@ async function finishRekey(options, deps = {}) {
3320
3596
  apiKey: options.newApiKey,
3321
3597
  delegateKey: pending.new_delegate_key,
3322
3598
  delegateAddress: pending.new_delegate_address,
3323
- // #2908: the stored value (already new-name-first) wins; the hosted
3324
- // identity's `account_address` twin before its deprecated `safe_address`.
3325
- accountAddress: stored.accountAddress ?? identity.account_address ?? identity.safe_address ?? void 0,
3599
+ // The stored value (already new-name-first, from the credential-FILE
3600
+ // permanent fallback) wins; the hosted identity's `account_address` is a
3601
+ // LIVE server read, which as of #2914 emits only that name.
3602
+ accountAddress: stored.accountAddress ?? identity.account_address ?? void 0,
3326
3603
  chainId: stored.chainId ?? identity.chain_id ?? void 0,
3327
3604
  network: stored.network,
3328
3605
  agentBudget: stored.agentBudget,
@@ -3509,9 +3786,32 @@ var init_rekey_restart = __esm({
3509
3786
  var doctor_exports = {};
3510
3787
  __export(doctor_exports, {
3511
3788
  describeAccountAddressKey: () => describeAccountAddressKey,
3789
+ rollUpLevel: () => rollUpLevel,
3512
3790
  runDoctor: () => runDoctor,
3513
3791
  runRepair: () => runRepair
3514
3792
  });
3793
+ function finalizeCheck(check) {
3794
+ return { ...check, ok: check.level !== "failed" };
3795
+ }
3796
+ function rollUpLevel(checks) {
3797
+ if (checks.some((check) => check.level === "failed")) return "failed";
3798
+ if (checks.some((check) => check.level === "advisory")) return "advisory";
3799
+ return "ok";
3800
+ }
3801
+ function runtimeFlagFor(runtime) {
3802
+ return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
3803
+ }
3804
+ async function resolveDoctorRuntime(input, directory) {
3805
+ const explicit = input.runtime.trim();
3806
+ if (explicit) return { runtime: explicit, origin: "flag" };
3807
+ if (directory) {
3808
+ const recorded = await readConnectOutcomeRuntime(directory);
3809
+ if (recorded !== null && normalizeRuntimeName(recorded)) {
3810
+ return { runtime: recorded, origin: "record" };
3811
+ }
3812
+ }
3813
+ return { runtime: "", origin: "unknown" };
3814
+ }
3515
3815
  async function discoverCredentialDirectory(homeDir, explicit) {
3516
3816
  const root = explicit ? dirname(explicit) : join(homeDir, ".haven", "agents");
3517
3817
  let entries = [];
@@ -3572,13 +3872,16 @@ function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bar
3572
3872
  return isPrimary && Boolean(identity?.hosted_mcp_url && configText.includes(identity.hosted_mcp_url));
3573
3873
  }
3574
3874
  function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3875
+ return finalizeCheck(rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug));
3876
+ }
3877
+ function rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug) {
3575
3878
  const label = "Pending re-key";
3576
3879
  const nameFlag = slug ? ` --name ${slug}` : "";
3577
3880
  if (status.state === "unreadable") {
3578
3881
  return {
3579
3882
  id: "rekey_pending",
3580
3883
  label,
3581
- ok: false,
3884
+ level: "failed",
3582
3885
  detail: `A re-key was started here but ${status.path} does not parse, so neither the address it generated nor when it started can be read. The file still holds what was a private key.`,
3583
3886
  repair: `Delete ${status.path}, then start again: ${RERUN} --rekey${nameFlag}`
3584
3887
  };
@@ -3590,9 +3893,9 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3590
3893
  return {
3591
3894
  id: "rekey_pending",
3592
3895
  label,
3593
- ok: false,
3896
+ level: "failed",
3594
3897
  detail: `A re-key started ${started} has COMPLETED on Haven \u2014 the agent's signing address is already ${address}, the one this machine generated \u2014 but the local half was never finished, so the credential files here still hold the old key. Parked at ${status.path}.` + (status.state === "expired" ? " The local file is also past its 24h TTL, which --rekey-finish refuses, so the finish command below will not accept it any more." : ""),
3595
- repair: status.state === "expired" ? `The parked key expired. Start again \u2014 ${RERUN} --rekey${nameFlag} \u2014 and re-run "Replace signing key" on the Haven agent page with the new address it prints.` : `Run: ${RERUN} --rekey-finish${nameFlag} --api-key <the key the agent page showed you> --runtime ${runtime}`
3898
+ repair: status.state === "expired" ? `The parked key expired. Start again \u2014 ${RERUN} --rekey${nameFlag} \u2014 and re-run "Replace signing key" on the Haven agent page with the new address it prints.` : `Run: ${RERUN} --rekey-finish${nameFlag} --api-key <the key the agent page showed you>${runtimeFlagFor(runtime)}`
3596
3899
  };
3597
3900
  }
3598
3901
  const wedgeNote = "Haven is NOT yet on this address, so the re-key did not complete. This machine cannot tell whether the on-chain revoke on the agent page already ran: if it did not, closing this costs nothing; if it did, the agent's old delegations are revoked, no new ones were issued, and only an owner re-grant restores its spend authority (#1868). Check the agent page before assuming the harmless case.";
@@ -3600,7 +3903,7 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3600
3903
  return {
3601
3904
  id: "rekey_pending",
3602
3905
  label,
3603
- ok: false,
3906
+ level: "failed",
3604
3907
  detail: `A re-key started ${started} EXPIRED ${status.expiresAt ?? ""} without being finished. Its address was ${address}; the private half it generated is still on disk at ${status.path}. ` + wedgeNote,
3605
3908
  repair: `Either delete ${status.path} to drop the parked key, or start over: ${RERUN} --rekey${nameFlag}. Connect never deletes it for you \u2014 an expired TTL is a refusal to USE the key, not a licence to destroy key material you may still be mid-flow on.`
3606
3909
  };
@@ -3608,7 +3911,7 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3608
3911
  return {
3609
3912
  id: "rekey_pending",
3610
3913
  label,
3611
- ok: true,
3914
+ level: "ok",
3612
3915
  detail: `A re-key started ${started} is still open (expires ${status.expiresAt ?? "unknown"}). Paste this address into "Replace signing key" on the Haven agent page: ${address}. Parked at ${status.path}. ` + wedgeNote
3613
3916
  };
3614
3917
  }
@@ -3636,13 +3939,13 @@ async function runtimeSpecOverrideCheck(directory, sidecar, env) {
3636
3939
  if (shell) facts.push(shell);
3637
3940
  if (facts.length === 0) return void 0;
3638
3941
  const variables = Object.values(RUNTIME_SPEC_ENV).join(" / ");
3639
- return {
3942
+ return finalizeCheck({
3640
3943
  id: "runtime_spec_override",
3641
3944
  label: "Runtime spec override",
3642
- ok: false,
3945
+ level: "failed",
3643
3946
  detail: `runtime spec overridden \u2014 not the pinned manifest (${MCP_RUNTIME_MANIFEST.signerPackage}@${MCP_RUNTIME_MANIFEST.signerVersion}, ${MCP_RUNTIME_MANIFEST.sdkPackage}@${MCP_RUNTIME_MANIFEST.sdkVersion}). ${facts.join(". ")}.`,
3644
3947
  repair: `Developer override (#2424). To return to the pinned manifest: unset ${variables}, then run ${RERUN} --doctor --repair --runtime <runtime>. If the override is intentional, this finding is the record of it.`
3645
- };
3948
+ });
3646
3949
  }
3647
3950
  async function readMcpSidecarOverride(directory) {
3648
3951
  try {
@@ -3669,6 +3972,13 @@ function describeAccountAddressKey(identity, signerFile) {
3669
3972
  return "no account address stored";
3670
3973
  }
3671
3974
  async function checksForAgent(entry, input, deps) {
3975
+ const verdicts = await verdictsForAgent(entry, input, deps);
3976
+ return {
3977
+ checks: verdicts.checks.map(finalizeCheck),
3978
+ ...verdicts.signerCapabilities ? { signerCapabilities: verdicts.signerCapabilities } : {}
3979
+ };
3980
+ }
3981
+ async function verdictsForAgent(entry, input, deps) {
3672
3982
  const { directory, identity, sidecar } = entry;
3673
3983
  const checks = [];
3674
3984
  let signerCapabilities;
@@ -3683,7 +3993,7 @@ async function checksForAgent(entry, input, deps) {
3683
3993
  checks.push({
3684
3994
  id: "credentials",
3685
3995
  label: "Agent credentials",
3686
- ok: credentialsOk,
3996
+ level: credentialsOk ? "ok" : "failed",
3687
3997
  detail: credentialsOk ? `identity.json and signer.json parse (agent ${identity?.agent_id ?? "unknown"}; ${describeAccountAddressKey(identity, signerFile)})` : "identity.json or signer.json is missing or unparseable.",
3688
3998
  ...credentialsOk ? {} : { repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` }
3689
3999
  });
@@ -3691,9 +4001,9 @@ async function checksForAgent(entry, input, deps) {
3691
4001
  checks.push({
3692
4002
  id: "signer_runtime",
3693
4003
  label: "Signer runtime (preinstalled wrapper)",
3694
- ok: false,
4004
+ level: "failed",
3695
4005
  detail: "No signer-runtime.json sidecar \u2014 the pinned signer runtime was never prepared (or a pre-#1586 npx config).",
3696
- repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
4006
+ repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
3697
4007
  });
3698
4008
  } else if (sidecar.runtime_spec_override) {
3699
4009
  const matches = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
@@ -3703,9 +4013,9 @@ async function checksForAgent(entry, input, deps) {
3703
4013
  checks.push({
3704
4014
  id: "signer_runtime",
3705
4015
  label: "Signer runtime (preinstalled wrapper)",
3706
- ok: matches,
4016
+ level: matches ? "ok" : "failed",
3707
4017
  detail: matches ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory} (override install \u2014 see runtime_spec_override)` : `Override runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
3708
- ...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime} with the same HAVEN_*_SPEC variables set.` }
4018
+ ...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)} with the same HAVEN_*_SPEC variables set.` }
3709
4019
  });
3710
4020
  } else {
3711
4021
  const intact = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
@@ -3717,9 +4027,9 @@ async function checksForAgent(entry, input, deps) {
3717
4027
  checks.push({
3718
4028
  id: "signer_runtime",
3719
4029
  label: "Signer runtime (preinstalled wrapper)",
3720
- ok,
4030
+ level: ok ? "ok" : intact ? "advisory" : "failed",
3721
4031
  detail: ok ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory}` : intact ? `Installed version ${sidecar.signer_version} does not match the connector's pinned ${MCP_RUNTIME_MANIFEST.signerVersion} \u2014 intact, but outdated.` : `Runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
3722
- ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
4032
+ ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
3723
4033
  });
3724
4034
  }
3725
4035
  const overrideCheck = await runtimeSpecOverrideCheck(directory, sidecar, deps.env ?? process.env);
@@ -3730,7 +4040,7 @@ async function checksForAgent(entry, input, deps) {
3730
4040
  checks.push({
3731
4041
  id: "hosted_mcp",
3732
4042
  label: "Hosted Haven MCP",
3733
- ok: probe.status === "ok",
4043
+ level: probe.status === "ok" ? "ok" : "failed",
3734
4044
  detail: probe.status === "ok" ? `MCP tools endpoint is reachable (${hostedUrl}).` : `MCP tools endpoint probe failed: ${probe.status} (${hostedUrl}).`,
3735
4045
  ...probe.status === "ok" ? {} : {
3736
4046
  repair: "Check network access and runtime configuration for the hosted MCP URL, then re-run --doctor."
@@ -3740,7 +4050,7 @@ async function checksForAgent(entry, input, deps) {
3740
4050
  checks.push({
3741
4051
  id: "hosted_mcp",
3742
4052
  label: "Hosted Haven MCP",
3743
- ok: false,
4053
+ level: "failed",
3744
4054
  detail: "No stored API key / hosted MCP URL to probe with.",
3745
4055
  repair: `Re-run the full setup: ${RERUN} --setup <token>.`
3746
4056
  });
@@ -3758,15 +4068,15 @@ async function checksForAgent(entry, input, deps) {
3758
4068
  checks.push({
3759
4069
  id: "identity_match",
3760
4070
  label: "Hosted identity matches the local signing key",
3761
- ok: false,
4071
+ level: "failed",
3762
4072
  detail: probe.status === "unauthorized" ? "The stored API key was rejected, so the agent it authenticates as cannot be compared with the local signing key." : `Could not read the hosted identity (${probe.status}) \u2014 the comparison did not happen, so it cannot be reported as a match.`,
3763
- repair: probe.status === "unauthorized" ? `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` : `Restore network access to the Haven API, then re-run: ${RERUN} --doctor --runtime ${input.runtime}`
4073
+ repair: probe.status === "unauthorized" ? `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` : `Restore network access to the Haven API, then re-run: ${RERUN} --doctor${runtimeFlagFor(input.runtime)}`
3764
4074
  });
3765
4075
  } else if (!localDelegate) {
3766
4076
  checks.push({
3767
4077
  id: "identity_match",
3768
4078
  label: "Hosted identity matches the local signing key",
3769
- ok: false,
4079
+ level: "failed",
3770
4080
  detail: "signer.json holds no delegate_address to compare against the hosted identity.",
3771
4081
  repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.`
3772
4082
  });
@@ -3775,7 +4085,7 @@ async function checksForAgent(entry, input, deps) {
3775
4085
  checks.push({
3776
4086
  id: "identity_match",
3777
4087
  label: "Hosted identity matches the local signing key",
3778
- ok: same,
4088
+ level: same ? "ok" : "failed",
3779
4089
  detail: same ? `The stored API key authenticates as the agent whose signing key is in this directory (${shortAddress(localDelegate)}).` : `MISMATCH: the stored API key authenticates as agent ${probe.agentId ?? "unknown"} with delegate ${shortAddress(probe.delegateAddress ?? "unknown")}, but signer.json here holds ${shortAddress(localDelegate)}. This runtime would quote as one agent and sign as another.`,
3780
4090
  ...same ? {} : {
3781
4091
  repair: `Re-run setup for this agent so its API key and signing key come from one run: ${RERUN} --setup <token>. Do not hand-edit either file.`
@@ -3793,7 +4103,7 @@ async function checksForAgent(entry, input, deps) {
3793
4103
  checks.push({
3794
4104
  id: "signer_process",
3795
4105
  label: "Signer stdio handshake",
3796
- ok: false,
4106
+ level: "failed",
3797
4107
  detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
3798
4108
  repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
3799
4109
  });
@@ -3810,18 +4120,18 @@ async function checksForAgent(entry, input, deps) {
3810
4120
  checks.push({
3811
4121
  id: "signer_process",
3812
4122
  label: "Signer stdio handshake",
3813
- ok: probe.status === "ok",
4123
+ level: probe.status === "ok" ? "ok" : "failed",
3814
4124
  detail: probe.status === "ok" ? `Signer started, listed ${probe.toolNames?.length ?? 0} tools${probe.serverInfo?.version ? ` (v${probe.serverInfo.version})` : ""}.${compatDetail}` : `Handshake failed: ${probe.status}.`,
3815
- ...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
4125
+ ...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
3816
4126
  });
3817
4127
  }
3818
4128
  } else {
3819
4129
  checks.push({
3820
4130
  id: "signer_process",
3821
4131
  label: "Signer stdio handshake",
3822
- ok: false,
4132
+ level: "failed",
3823
4133
  detail: "Skipped \u2014 no prepared signer runtime to probe.",
3824
- repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
4134
+ repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
3825
4135
  });
3826
4136
  }
3827
4137
  return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
@@ -3831,7 +4141,11 @@ async function runDoctor(input, deps = {}) {
3831
4141
  const checks = [];
3832
4142
  let signerCapabilities;
3833
4143
  const { directory, others, parkedOnly } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
3834
- const configPath = runtimeConfigPathFor(input.runtime, homeDir);
4144
+ const resolution = await resolveDoctorRuntime(input, directory);
4145
+ const runtime = resolution.runtime;
4146
+ const input2 = { ...input, runtime };
4147
+ const normalizedRuntime = normalizeRuntimeName(input2.runtime);
4148
+ const configPath = runtimeConfigPathFor(normalizedRuntime ?? input2.runtime, homeDir);
3835
4149
  let configText = null;
3836
4150
  if (configPath !== null) {
3837
4151
  try {
@@ -3868,7 +4182,7 @@ async function runDoctor(input, deps = {}) {
3868
4182
  // A tombstone is a deliberate record and outranks the discovery tell:
3869
4183
  // a retired directory that also holds a parked key stays `retired`.
3870
4184
  classification: tombstone ? "retired" : parkedOnly.has(dir) ? "parked" : "orphaned",
3871
- checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input.runtime, slug)] : [],
4185
+ checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)] : [],
3872
4186
  ...rekeyPending ? { rekeyPending } : {}
3873
4187
  });
3874
4188
  continue;
@@ -3883,11 +4197,11 @@ async function runDoctor(input, deps = {}) {
3883
4197
  ...rekeyPending ? { rekeyPending } : {}
3884
4198
  };
3885
4199
  if (wired) {
3886
- const result = await checksForAgent({ directory: dir, identity, sidecar }, input, deps);
4200
+ const result = await checksForAgent({ directory: dir, identity, sidecar }, input2, deps);
3887
4201
  entry.checks = result.checks;
3888
4202
  capabilitiesByDirectory.set(dir, result.signerCapabilities);
3889
4203
  } else if (rekeyPending) {
3890
- entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input.runtime, slug)];
4204
+ entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)];
3891
4205
  }
3892
4206
  inventory.push(entry);
3893
4207
  }
@@ -3902,7 +4216,7 @@ async function runDoctor(input, deps = {}) {
3902
4216
  checks.push({
3903
4217
  id: "credentials",
3904
4218
  label: "Agent credentials",
3905
- ok: false,
4219
+ level: "failed",
3906
4220
  detail: "No agent credential directory with an identity.json under ~/.haven/agents.",
3907
4221
  repair: `Run the full setup once: ${RERUN} --setup <token from the Haven dashboard>.`
3908
4222
  });
@@ -3912,7 +4226,7 @@ async function runDoctor(input, deps = {}) {
3912
4226
  if (!primaryChecksById.has("credentials")) {
3913
4227
  const result = await checksForAgent(
3914
4228
  { directory: primaryDirectory, identity: primaryIdentity, sidecar: primarySidecar },
3915
- input,
4229
+ input2,
3916
4230
  deps
3917
4231
  );
3918
4232
  signerCapabilities = result.signerCapabilities;
@@ -3923,20 +4237,37 @@ async function runDoctor(input, deps = {}) {
3923
4237
  if (check) checks.push(check);
3924
4238
  }
3925
4239
  }
3926
- if (configPath === null) {
4240
+ const runtimeOwnsNoConfig = normalizedRuntime !== null && configPath === null;
4241
+ if (configPath === null && input2.runtime !== "" && normalizedRuntime === null) {
3927
4242
  checks.push({
3928
4243
  id: "runtime_config",
3929
4244
  label: "Runtime MCP config",
3930
- ok: true,
3931
- detail: `Runtime '${input.runtime}' has no file-based config the connector owns (CLI-managed) \u2014 skipping the file check.`
4245
+ level: "failed",
4246
+ detail: `Runtime '${input2.runtime}' is not one the connector recognises. The runtime config was NOT checked. Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`,
4247
+ repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4248
+ });
4249
+ } else if (configPath === null && input2.runtime === "") {
4250
+ checks.push({
4251
+ id: "runtime_config",
4252
+ label: "Runtime MCP config",
4253
+ level: "failed",
4254
+ detail: `Runtime is unknown \u2014 no runtime flag was given and the connector's record in ${primaryDirectory ?? input.credentialsDir ?? "~/.haven/agents"} carries no resolvable ${CONNECT_OUTCOME_FILENAME} runtime. The runtime config was NOT checked. Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`,
4255
+ repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4256
+ });
4257
+ } else if (configPath === null) {
4258
+ checks.push({
4259
+ id: "runtime_config",
4260
+ label: "Runtime MCP config",
4261
+ level: "ok",
4262
+ detail: `Runtime '${input2.runtime}' is configured through its own CLI or by hand (${input2.runtime === "other" ? "manual runtime" : "CLI-managed"}) and has no file-based config the connector owns \u2014 skipping the file check.`
3932
4263
  });
3933
4264
  } else if (configText === null) {
3934
4265
  checks.push({
3935
4266
  id: "runtime_config",
3936
4267
  label: "Runtime MCP config",
3937
- ok: false,
4268
+ level: "failed",
3938
4269
  detail: `No runtime config at ${configPath}.`,
3939
- repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}`
4270
+ repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}`
3940
4271
  });
3941
4272
  } else {
3942
4273
  const primaryIdentity = await readIdentity(primaryDirectory ?? "");
@@ -3948,9 +4279,9 @@ async function runDoctor(input, deps = {}) {
3948
4279
  checks.push({
3949
4280
  id: "runtime_config",
3950
4281
  label: "Runtime MCP config",
3951
- ok,
4282
+ level: ok ? "ok" : "failed",
3952
4283
  detail: ok ? `Config at ${configPath} references the hosted server and the prepared signer wrapper.` : signerViaNpx ? `Config at ${configPath} still launches the signer via npx \u2014 the pre-#1586 shape that cannot start under a 120s startup timeout.` : `Config at ${configPath} is missing the Haven entries${primarySidecar && !wrapperReferenced ? " (or references a different signer wrapper)" : ""}.`,
3953
- ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair --runtime ${input.runtime}` }
4284
+ ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}` }
3954
4285
  });
3955
4286
  }
3956
4287
  for (const id of ["hosted_mcp", "identity_match", "rekey_pending"]) {
@@ -3997,16 +4328,44 @@ async function runDoctor(input, deps = {}) {
3997
4328
  );
3998
4329
  }
3999
4330
  const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
4331
+ const classificationUnreliable = configText === null && runtimeOwnsNoConfig;
4332
+ const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
4000
4333
  checks.push({
4001
4334
  id: "superseded_agents",
4002
4335
  label: "Superseded agent credentials",
4003
- ok: supersededLive.length === 0,
4004
- detail: supersededLive.length > 0 ? `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}. A host started before your latest setup keeps authenticating (and spending) as the old agent.` : `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}.`,
4005
- ...supersededLive.length > 0 ? {
4336
+ level: supersededLevel,
4337
+ detail: supersededLevel === "failed" ? `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}. A host started before your latest setup keeps authenticating (and spending) as the old agent.` : supersededLevel === "advisory" ? `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}. Runtime '${input2.runtime}' has no config file the connector can read, so which of these agents are wired cannot be verified from this machine: a live key here may be an agent you use deliberately, or one a host started before your latest setup is still spending as.` : `${otherEntries.length} other credential dir(s) found \u2014 ${parts.join("; ")}.`,
4338
+ ...supersededLevel === "failed" ? {
4006
4339
  repair: `Revoke ${supersededLive.join(", ")} on the Haven agent page, then remove the old director(y/ies) under ~/.haven/agents. Connect never revokes or deletes for you.`
4340
+ } : supersededLevel === "advisory" ? {
4341
+ repair: `Check ${supersededLive.join(", ")} on the Haven agent page: revoke the ones you no longer use, then remove their director(y/ies) under ~/.haven/agents. Connect never revokes or deletes for you.`
4007
4342
  } : {}
4008
4343
  });
4009
4344
  }
4345
+ const bindings = (await Promise.all(
4346
+ inventory.filter((entry) => entry.classification !== "retired").map(async (entry) => ({ entry, binding: await readMcpServerBinding(entry.directory) }))
4347
+ )).filter((item) => item.binding !== null);
4348
+ const byName = /* @__PURE__ */ new Map();
4349
+ for (const item of bindings) {
4350
+ const list = byName.get(item.binding.server_name) ?? [];
4351
+ list.push(item);
4352
+ byName.set(item.binding.server_name, list);
4353
+ }
4354
+ const rebound = [...byName.entries()].filter(([, items]) => items.length > 1);
4355
+ if (rebound.length > 0) {
4356
+ const parts = rebound.map(([name, items]) => {
4357
+ const ordered = [...items].sort((a, b) => a.binding.bound_at < b.binding.bound_at ? -1 : a.binding.bound_at > b.binding.bound_at ? 1 : 0);
4358
+ const backends = new Set(ordered.map((i) => withoutUserinfo(i.binding.api_url)));
4359
+ return `'${name}': ` + ordered.map((i) => `${i.binding.agent_id} on ${withoutUserinfo(i.binding.api_url)} at ${i.binding.bound_at} [${i.entry.classification}]`).join(" \u2192 ") + (backends.size > 1 ? " (BACKEND CHANGED)" : "");
4360
+ });
4361
+ checks.push({
4362
+ id: "mcp_server_name_rebound",
4363
+ label: "MCP server names bound more than once",
4364
+ level: "advisory",
4365
+ detail: `${rebound.length} MCP server name${rebound.length === 1 ? "" : "s"} changed hands on this machine (locally recorded bindings; the backend's own record is the authority for the same backend): ${parts.join("; ")}. A saved session, script or document naming the server may still mean the earlier agent.`,
4366
+ repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
4367
+ });
4368
+ }
4010
4369
  const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
4011
4370
  if (parkedElsewhere.length > 0) {
4012
4371
  const abandoned = parkedElsewhere.filter((item) => item.pending.state !== "pending");
@@ -4014,7 +4373,7 @@ async function runDoctor(input, deps = {}) {
4014
4373
  checks.push({
4015
4374
  id: "rekey_pending_elsewhere",
4016
4375
  label: "Parked re-keys in other credential directories",
4017
- ok: abandoned.length === 0,
4376
+ level: abandoned.length === 0 ? "ok" : "failed",
4018
4377
  detail: abandoned.length > 0 ? `ABANDONED re-key key material outside the agent this report describes: ${abandoned.map(describe).join(", ")}. Each holds a private key that was generated for a re-key nobody finished.` : `${parkedElsewhere.length} other director(y/ies) hold an open pending re-key: ${parkedElsewhere.map(describe).join(", ")}.`,
4019
4378
  ...abandoned.length > 0 ? {
4020
4379
  repair: "Check the Haven agent page for each before deleting: if its on-chain revoke already ran, the agent has no spend authority until you re-grant it (#1868), and that is not visible from this machine. Connect never deletes key material for you."
@@ -4023,20 +4382,34 @@ async function runDoctor(input, deps = {}) {
4023
4382
  }
4024
4383
  const signerProcess = primaryChecksById.get("signer_process");
4025
4384
  if (signerProcess) checks.push(signerProcess);
4026
- const restart = restartRequiredForRuntime(input.runtime, deps.env);
4385
+ const prune = await (deps.pruneSignerRuntimes ?? pruneSignerRuntimes)({ dryRun: true, measure: false }, { homeDir, credentialsDir: input.credentialsDir });
4386
+ const unused = prune.entries.filter((entry) => entry.action === "would_remove");
4387
+ if (unused.length > 0) {
4388
+ checks.push({
4389
+ id: "signer_runtime_unused",
4390
+ label: "Unused signer-runtime directories",
4391
+ level: "advisory",
4392
+ detail: `${unused.length} signer-runtime director${unused.length === 1 ? "y" : "ies"} under ${prune.root} that no credential directory names: ${unused.map((entry) => entry.key).join(", ")}. Nothing is broken; they are left over from earlier pins or overrides (sizes: --prune-signer-runtimes --dry-run).`,
4393
+ repair: `Run: ${RERUN} --prune-signer-runtimes (add --dry-run to list only).`
4394
+ });
4395
+ }
4396
+ const restart = restartRequiredForRuntime(input2.runtime, deps.env);
4027
4397
  checks.push({
4028
4398
  id: "restart",
4029
4399
  label: "Runtime restart",
4030
- ok: true,
4031
- detail: restart ? "This runtime loads MCP config at startup \u2014 restart it after any repair before expecting the tools to appear." : "No restart requirement known for this runtime."
4400
+ level: "ok",
4401
+ detail: restart ? "This runtime loads MCP config at startup \u2014 restart it after any repair before expecting the tools to appear." : input2.runtime === "" ? "Runtime is unknown \u2014 whether a restart is needed cannot be determined. Re-run the doctor naming the runtime for a definitive answer." : "No restart requirement known for this runtime."
4032
4402
  });
4033
- const wiredOk = inventory.filter((entry) => entry.classification === "wired").every((entry) => entry.checks.every((check) => check.ok));
4403
+ const wiredChecks = inventory.filter((entry) => entry.classification === "wired").flatMap((entry) => entry.checks);
4404
+ const finalChecks = checks.map(finalizeCheck);
4405
+ const level = rollUpLevel([...finalChecks, ...wiredChecks]);
4034
4406
  return {
4035
4407
  version: 1,
4036
- ok: checks.every((check) => check.ok) && wiredOk,
4037
- runtime: input.runtime,
4408
+ ok: level !== "failed",
4409
+ level,
4410
+ runtime: input2.runtime,
4038
4411
  credentialDirectory: primaryDirectory,
4039
- checks,
4412
+ checks: finalChecks,
4040
4413
  agents: inventory,
4041
4414
  ...signerCapabilities ? { signerCapabilities } : {}
4042
4415
  };
@@ -4054,6 +4427,21 @@ async function runRepair(input, deps = {}) {
4054
4427
  messages: [`No agent credentials found to repair \u2014 run the full setup: ${RERUN} --setup <token>.`]
4055
4428
  };
4056
4429
  }
4430
+ const resolution = await resolveDoctorRuntime(input, directory);
4431
+ if (resolution.origin === "record") {
4432
+ messages.push(`Runtime not given \u2014 resolved '${resolution.runtime}' from ${join(directory, CONNECT_OUTCOME_FILENAME)}.`);
4433
+ }
4434
+ const runtime = resolution.runtime;
4435
+ const input2 = { ...input, runtime };
4436
+ if (runtime === "") {
4437
+ return {
4438
+ ok: false,
4439
+ messages: [
4440
+ "Runtime is unknown \u2014 no runtime flag was given and the connector record carries no resolvable runtime.",
4441
+ `Repair rewrites the runtime config, so it will not guess. Re-run repair naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4442
+ ]
4443
+ };
4444
+ }
4057
4445
  let identity;
4058
4446
  try {
4059
4447
  identity = JSON.parse(await readFile(join(directory, "identity.json"), "utf8"));
@@ -4063,7 +4451,7 @@ async function runRepair(input, deps = {}) {
4063
4451
  if (!identity.api_key || !(identity.hosted_mcp_url || identity.api_url)) {
4064
4452
  return { ok: false, messages: ["identity.json lacks the stored API key / hosted URL \u2014 re-run the full setup."] };
4065
4453
  }
4066
- const configPath = runtimeConfigPathFor(input.runtime, homeDir);
4454
+ const configPath = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir);
4067
4455
  if (configPath) {
4068
4456
  try {
4069
4457
  const existing = await readFile(configPath, "utf8");
@@ -4090,7 +4478,10 @@ async function runRepair(input, deps = {}) {
4090
4478
  const names = serverNamesFor(serverName);
4091
4479
  messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
4092
4480
  const configResult = await writeRuntimeConfig({
4093
- runtime: input.runtime,
4481
+ // Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
4482
+ // switches on the id, and the raw alias fell to its "manual runtime" arm
4483
+ // — a repair that reported success while writing nothing.
4484
+ runtime: normalizeRuntimeName(input2.runtime) ?? input2.runtime,
4094
4485
  hostedMcpUrl: identity.hosted_mcp_url ?? `${identity.api_url}/mcp`,
4095
4486
  apiKey: identity.api_key,
4096
4487
  identityPath: join(directory, "identity.json"),
@@ -4108,6 +4499,7 @@ async function runRepair(input, deps = {}) {
4108
4499
  var RERUN;
4109
4500
  var init_doctor = __esm({
4110
4501
  "src/doctor.ts"() {
4502
+ init_prune_runtimes();
4111
4503
  init_runtime_manifest();
4112
4504
  init_probes();
4113
4505
  init_signer_runtime();
@@ -4421,7 +4813,7 @@ async function pathExists2(path) {
4421
4813
  init_unwire();
4422
4814
  init_local_mcp_runtime();
4423
4815
  init_runtime_manifest();
4424
- var CONNECTOR_VERSION = "0.2.1-alpha.0";
4816
+ var CONNECTOR_VERSION = "0.4.0-alpha.0";
4425
4817
  var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4426
4818
  var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4427
4819
  function failureOutcomeFor(runtimeHint, error) {
@@ -4548,6 +4940,27 @@ async function executeConnect(options, deps, trace) {
4548
4940
  }
4549
4941
  }
4550
4942
  }
4943
+ const existingAgents = await listExistingKeyedAgents(options.credentialsDir);
4944
+ if (existingAgents.length > 0) {
4945
+ log("");
4946
+ log(
4947
+ `Heads-up (before anything is written to this machine): it already carries ${existingAgents.length} agent director${existingAgents.length === 1 ? "y" : "ies"} with stored keys \u2014 ` + existingAgents.map((a) => `${a.agentId}${a.accountAddress ? ` (spends from ${shortAddress(a.accountAddress)})` : ""}`).join(", ") + ". This setup creates a NEW agent alongside them and revokes nothing."
4948
+ );
4949
+ }
4950
+ const hostedNameForRun = serverNamesFor(serverName).hosted;
4951
+ let reboundFrom;
4952
+ const holders = (await listMcpServerBindings(options.credentialsDir)).filter(({ binding }) => binding.server_name === hostedNameForRun).sort((a, b) => a.binding.bound_at < b.binding.bound_at ? 1 : a.binding.bound_at > b.binding.bound_at ? -1 : 0);
4953
+ const newest = holders[0];
4954
+ if (newest) {
4955
+ const { binding, directory } = newest;
4956
+ const backendChanged = withoutUserinfo(binding.api_url) !== withoutUserinfo(options.apiBaseUrl);
4957
+ const previousApiUrl = withoutUserinfo(binding.api_url);
4958
+ reboundFrom = { server_name: binding.server_name, agent_id: binding.agent_id, api_url: previousApiUrl, bound_at: binding.bound_at, backend_changed: backendChanged };
4959
+ const beingReplaced = replacing?.superseded.some((entry) => entry.directory === directory) ?? false;
4960
+ log(
4961
+ `Heads-up: the MCP server name '${binding.server_name}' was bound to agent ${binding.agent_id} on ${previousApiUrl} at ${binding.bound_at} (locally recorded${holders.length > 1 ? `; ${holders.length - 1} older record${holders.length > 2 ? "s" : ""} also claim${holders.length > 2 ? "" : "s"} it` : ""}). ` + (beingReplaced ? `This run replaces that wiring, as you chose, with a new agent on ${withoutUserinfo(options.apiBaseUrl)}` : `This run rebinds it to a new agent on ${withoutUserinfo(options.apiBaseUrl)}`) + (backendChanged ? " \u2014 a DIFFERENT backend: any saved session, script or document naming this MCP server now resolves to a different backend and agent." : ".")
4962
+ );
4963
+ }
4551
4964
  const localKey = generateKey();
4552
4965
  const localApiKey = generateLocalApiKey();
4553
4966
  log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
@@ -4611,6 +5024,21 @@ async function executeConnect(options, deps, trace) {
4611
5024
  warn: log
4612
5025
  });
4613
5026
  trace.directory = credentialPaths.directory;
5027
+ try {
5028
+ const bindingRecord = {
5029
+ version: 1,
5030
+ server_name: hostedNameForRun,
5031
+ signer_name: serverNamesFor(serverName).signer,
5032
+ agent_id: registration.agent_id,
5033
+ api_url: withoutUserinfo(options.apiBaseUrl),
5034
+ // never persist `user:pass@` (#3154 doc review r2)
5035
+ ...registration.hosted_mcp_url ? { hosted_mcp_url: registration.hosted_mcp_url } : {},
5036
+ bound_at: (/* @__PURE__ */ new Date()).toISOString()
5037
+ };
5038
+ await writeMcpServerBinding(credentialPaths.directory, bindingRecord);
5039
+ } catch {
5040
+ log("Could not record the MCP server-name binding locally (non-fatal; the next setup cannot see this binding).");
5041
+ }
4614
5042
  log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
4615
5043
  log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
4616
5044
  log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
@@ -4690,6 +5118,7 @@ async function executeConnect(options, deps, trace) {
4690
5118
  replacedBy: registration.agent_id
4691
5119
  });
4692
5120
  await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
5121
+ await clearMcpServerBinding(entry.directory);
4693
5122
  retiredAgentIds.push(entry.agentId);
4694
5123
  log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
4695
5124
  } catch (err) {
@@ -4759,6 +5188,8 @@ async function executeConnect(options, deps, trace) {
4759
5188
  supersededAgentIds,
4760
5189
  supersededAgentsRetiredLocally,
4761
5190
  ...replacing ? { retiredAgentIds } : {},
5191
+ existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
5192
+ ...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
4762
5193
  setupChallengeExpiresAt: setup.challenge.expires_at,
4763
5194
  approvalRequired: registration.agent_status === "pending_approval",
4764
5195
  approvalUrl: registration.approval_url
@@ -4823,6 +5254,10 @@ function completionOutcome(input) {
4823
5254
  superseded_agent_ids: input.supersededAgentIds ?? [],
4824
5255
  ...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
4825
5256
  ...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
5257
+ // #3122: always emitted on a completed run (empty list included), for the
5258
+ // same reason as superseded_agent_ids above.
5259
+ existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
5260
+ ...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
4826
5261
  ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4827
5262
  ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
4828
5263
  };
@@ -5007,7 +5442,7 @@ function describeWaitBound(timeoutMs) {
5007
5442
  async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
5008
5443
  const intervalMs = options.intervalMs ?? 5e3;
5009
5444
  const timeoutMs = options.timeoutMs ?? 18e4;
5010
- const sleep = options.sleep ?? ((ms) => new Promise((resolve9) => setTimeout(resolve9, ms)));
5445
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)));
5011
5446
  const maxPolls = Math.max(1, Math.floor(timeoutMs / intervalMs));
5012
5447
  const remindEvery = Math.max(1, Math.floor(3e4 / intervalMs));
5013
5448
  let waitingAnnounced = false;
@@ -5098,6 +5533,32 @@ function activationInstructionWithWhy(profile) {
5098
5533
  return profile.activationInstruction;
5099
5534
  }
5100
5535
  var RERUN_HINT = connectorRerunCommand();
5536
+ async function listExistingKeyedAgents(baseDir) {
5537
+ const root = defaultCredentialRoot(baseDir);
5538
+ let entries = [];
5539
+ try {
5540
+ entries = await readdir(root);
5541
+ } catch {
5542
+ return [];
5543
+ }
5544
+ const out = [];
5545
+ for (const entry of entries) {
5546
+ const directory = join(root, entry);
5547
+ try {
5548
+ const identity = JSON.parse(await readFile(join(directory, "identity.json"), "utf8"));
5549
+ if (typeof identity.api_key !== "string" || identity.api_key.length === 0) continue;
5550
+ let agent = {};
5551
+ try {
5552
+ agent = JSON.parse(await readFile(join(directory, "agent.json"), "utf8"));
5553
+ } catch {
5554
+ }
5555
+ const { accountAddress } = readStoredAccountAddress(identity, agent);
5556
+ out.push({ agentId: typeof identity.agent_id === "string" ? identity.agent_id : entry, directory, accountAddress: accountAddress ?? null });
5557
+ } catch {
5558
+ }
5559
+ }
5560
+ return out;
5561
+ }
5101
5562
  async function listOtherAgentIds(baseDir, currentDirectory) {
5102
5563
  const root = defaultCredentialRoot(baseDir);
5103
5564
  let entries = [];
@@ -5146,6 +5607,9 @@ function parseArgs(argv, env = process.env) {
5146
5607
  let tombstoneReason;
5147
5608
  let tombstoneReplacedBy;
5148
5609
  let unwire;
5610
+ let destroyKeyMaterial = false;
5611
+ let pruneSignerRuntimes2 = false;
5612
+ let dryRun = false;
5149
5613
  let unwireDir;
5150
5614
  let replace = false;
5151
5615
  for (let i = 0; i < argv.length; i += 1) {
@@ -5173,6 +5637,12 @@ function parseArgs(argv, env = process.env) {
5173
5637
  unwireDir = next;
5174
5638
  i += 1;
5175
5639
  }
5640
+ } else if (arg === "--destroy-key-material") {
5641
+ destroyKeyMaterial = true;
5642
+ } else if (arg === "--prune-signer-runtimes") {
5643
+ pruneSignerRuntimes2 = true;
5644
+ } else if (arg === "--dry-run") {
5645
+ dryRun = true;
5176
5646
  } else if (arg === "--reason") {
5177
5647
  tombstoneReason = requireValue(argv, ++i, arg);
5178
5648
  } else if (arg === "--replaced-by") {
@@ -5215,7 +5685,7 @@ function parseArgs(argv, env = process.env) {
5215
5685
  return { options, help, json, doctor, repair, tombstone, rekey };
5216
5686
  }
5217
5687
  if (replace) {
5218
- if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
5688
+ if (rekeyPhase || tombstoneDir || unwire || doctor || repair || pruneSignerRuntimes2) {
5219
5689
  throw new Error("--replace belongs to a --setup run: it says what to do when the bare haven / haven-signer pair is already wired to another agent.");
5220
5690
  }
5221
5691
  if (options.serverName) {
@@ -5225,6 +5695,22 @@ function parseArgs(argv, env = process.env) {
5225
5695
  }
5226
5696
  options.replaceExistingWiring = true;
5227
5697
  }
5698
+ if (destroyKeyMaterial && !unwire) {
5699
+ throw new Error("--destroy-key-material only applies to --unwire.");
5700
+ }
5701
+ if (destroyKeyMaterial && unwire) unwire = { ...unwire, destroyKeyMaterial: true };
5702
+ if (dryRun && !pruneSignerRuntimes2) {
5703
+ throw new Error("--dry-run only applies to --prune-signer-runtimes.");
5704
+ }
5705
+ if (pruneSignerRuntimes2) {
5706
+ if (unwire || tombstoneDir || rekeyPhase || doctor || repair) {
5707
+ throw new Error("--prune-signer-runtimes is its own operation; run it alone.");
5708
+ }
5709
+ if (options.setupToken) {
5710
+ throw new Error("--prune-signer-runtimes takes no --setup token; it reads stored state only.");
5711
+ }
5712
+ return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir, pruneSignerRuntimes: { dryRun } };
5713
+ }
5228
5714
  if (rekey) {
5229
5715
  if (options.setupToken) {
5230
5716
  throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
@@ -5319,7 +5805,7 @@ function helpText() {
5319
5805
  " Only available for Claude Code and Codex. Default is hosted MCP + local signer.",
5320
5806
  " --json Emit one versioned, secret-free result object on stdout; progress stays on stderr.",
5321
5807
  " --doctor Diagnose an existing setup (read-only, no token): config, credentials,",
5322
- " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero on any failure.",
5808
+ " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero only on a failed check; an advisory (!) exits 0.",
5323
5809
  " --repair Repair, then re-diagnose (implies --doctor): reinstall the pinned signer",
5324
5810
  " runtime, rewrite the wrapper and runtime config from stored credentials.",
5325
5811
  " Hosted topology only (refuses to touch a --local config). No keys, no token.",
@@ -5346,6 +5832,12 @@ function helpText() {
5346
5832
  " API key are removed locally (record kept via the #2155 tombstone mirror) and",
5347
5833
  " nothing is ever revoked on the backend \u2014 that stays an owner action on the",
5348
5834
  " Haven agent page.",
5835
+ " --destroy-key-material With --unwire: destroy the signer key + stored API key even when the agent",
5836
+ " is still active or cannot be verified (#3123). Local sweep recovery ends.",
5837
+ " --prune-signer-runtimes Remove ~/.haven/signer-runtime directories no credential directory references",
5838
+ " (version- and override-keyed alike); --dry-run lists them. Exits 1 only on a",
5839
+ " removal that failed (#3123).",
5840
+ " --dry-run With --prune-signer-runtimes: report, remove nothing.",
5349
5841
  " --reason <text> Reason recorded in the tombstone (with --tombstone or --unwire).",
5350
5842
  " --replaced-by <agent-id> Successor agent recorded in the tombstone (with --tombstone or --unwire).",
5351
5843
  " --help Show this help.",
@@ -5385,6 +5877,13 @@ function failSubcommand(io, json, err, envelope, fallback) {
5385
5877
  }
5386
5878
  return 1;
5387
5879
  }
5880
+ function levelMarker(level) {
5881
+ return level === "ok" ? "\u2713" : level === "advisory" ? "!" : "\u2717";
5882
+ }
5883
+ function advisoryCount(report) {
5884
+ const others = report.agents.filter((agent) => agent.classification === "wired" && agent.directory !== report.credentialDirectory).flatMap((agent) => agent.checks);
5885
+ return [...report.checks, ...others].filter((check) => check.level === "advisory").length;
5886
+ }
5388
5887
  async function runCli(argv, io = {
5389
5888
  stdout: (message) => process.stdout.write(message),
5390
5889
  stderr: (message) => process.stderr.write(message)
@@ -5412,13 +5911,13 @@ async function runCli(argv, io = {
5412
5911
  }
5413
5912
  if (parsed.tombstone) {
5414
5913
  const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5415
- const { readFile: readFile14 } = await import('fs/promises');
5416
- const { join: join12 } = await import('path');
5914
+ const { readFile: readFile15 } = await import('fs/promises');
5915
+ const { join: join13 } = await import('path');
5417
5916
  try {
5418
5917
  let agentId = "unknown";
5419
5918
  try {
5420
5919
  const identity = JSON.parse(
5421
- await readFile14(join12(parsed.tombstone.directory, "identity.json"), "utf8")
5920
+ await readFile15(join13(parsed.tombstone.directory, "identity.json"), "utf8")
5422
5921
  );
5423
5922
  agentId = identity.agent_id ?? "unknown";
5424
5923
  } catch {
@@ -5456,20 +5955,22 @@ async function runCli(argv, io = {
5456
5955
  }
5457
5956
  if (parsed.unwire) {
5458
5957
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
5459
- const { homedir: homedir10 } = await import('os');
5460
- const { join: join12 } = await import('path');
5461
- const homeDir = homedir10();
5462
- const root = parsed.options.credentialsDir ?? join12(homeDir, ".haven", "agents");
5463
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join12(root, parsed.options.serverName) : root);
5958
+ const { homedir: homedir11 } = await import('os');
5959
+ const { join: join13 } = await import('path');
5960
+ const homeDir = homedir11();
5961
+ const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
5962
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
5464
5963
  try {
5465
5964
  const result = await unwireAgent2({
5466
5965
  directory,
5467
5966
  slug: parsed.options.serverName,
5468
5967
  reason: parsed.unwire.reason,
5469
5968
  replacedBy: parsed.unwire.replacedBy,
5969
+ destroyKeyMaterial: parsed.unwire.destroyKeyMaterial,
5470
5970
  homeDir
5471
5971
  });
5472
5972
  const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
5973
+ const retained = result.teardown.status === "retained";
5473
5974
  if (parsed.json) {
5474
5975
  io.stdout(
5475
5976
  `${redactSecrets(
@@ -5484,7 +5985,16 @@ async function runCli(argv, io = {
5484
5985
  label: r.label,
5485
5986
  status: r.status,
5486
5987
  ...r.detail ? { detail: r.detail } : {}
5487
- }))
5988
+ })),
5989
+ // #3122: additive — whether the local server-name binding record was released.
5990
+ binding_released: result.bindingReleased,
5991
+ // #3123: additive — what happened to the key material and why.
5992
+ teardown: {
5993
+ status: result.teardown.status,
5994
+ probe: result.teardown.probe,
5995
+ detail: result.teardown.detail,
5996
+ ...result.teardown.remedy ? { remedy: result.teardown.remedy } : {}
5997
+ }
5488
5998
  })
5489
5999
  )}
5490
6000
  `
@@ -5500,14 +6010,20 @@ async function runCli(argv, io = {
5500
6010
  io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
5501
6011
  `));
5502
6012
  }
6013
+ io.stdout(result.bindingReleased ? " \u2713 MCP server-name binding: released (the name is free for the next setup).\n" : " \u2013 MCP server-name binding: none recorded for this directory.\n");
6014
+ const t = result.teardown;
6015
+ io.stdout(redactSecrets(` ${t.status === "retained" ? "\u2717" : t.status === "forced" ? "!" : "\u2713"} Key material: ${t.status} (probe: ${t.probe}) \u2014 ${t.detail}
6016
+ `));
6017
+ if (t.remedy) io.stdout(redactSecrets(` \u21B3 ${t.remedy}
6018
+ `));
5503
6019
  io.stdout(
5504
- failures.length > 0 ? " Some entries were NOT removed (\u2717 above). Re-run `--unwire` after resolving each refusal \u2014\n it is idempotent.\n" : " Verify: `--doctor --runtime <runtime>` per host should report this agent as `retired` with a\n clean runtime-config check.\n"
6020
+ failures.length > 0 ? " Some entries were NOT removed (\u2717 above). Re-run `--unwire` after resolving each refusal \u2014\n it is idempotent.\n" : retained ? " The wiring is gone; the key material is not (\u2717 above). `--doctor` will keep reporting this directory\n as `superseded` until the key is revoked or destroyed \u2014 that is the honest state.\n" : " Verify: `--doctor --runtime <runtime>` per host should report this agent as `retired` with a\n clean runtime-config check.\n"
5505
6021
  );
5506
6022
  io.stdout(
5507
- "Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot\nfrom its own start time. This directory\u2019s local key material was removed and the tombstone\nrecord + #2155 mirror survive \u2014 but nothing was REVOKED on the backend. If you have not\nalready, revoke the agent on the Haven agent page to stop it spending entirely.\n"
6023
+ "Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot\nfrom its own start time. " + (retained ? "This directory\u2019s local key material was KEPT (see above); the tombstone\n" : "This directory\u2019s local key material was removed and the tombstone\n") + "record + #2155 mirror survive \u2014 but nothing was REVOKED on the backend. If you have not\nalready, revoke the agent on the Haven agent page to stop it spending entirely.\n"
5508
6024
  );
5509
6025
  }
5510
- return failures.length > 0 ? 1 : 0;
6026
+ return failures.length > 0 || retained ? 1 : 0;
5511
6027
  } catch (err) {
5512
6028
  return failSubcommand(io, parsed.json, err, { unwired: false }, {
5513
6029
  code: "unwire_failed",
@@ -5515,6 +6031,57 @@ async function runCli(argv, io = {
5515
6031
  });
5516
6032
  }
5517
6033
  }
6034
+ if (parsed.pruneSignerRuntimes) {
6035
+ const { pruneSignerRuntimes: pruneSignerRuntimes2 } = await Promise.resolve().then(() => (init_prune_runtimes(), prune_runtimes_exports));
6036
+ try {
6037
+ const report = await pruneSignerRuntimes2(
6038
+ { dryRun: parsed.pruneSignerRuntimes.dryRun },
6039
+ { credentialsDir: parsed.options.credentialsDir }
6040
+ );
6041
+ if (parsed.json) {
6042
+ io.stdout(`${redactSecrets(JSON.stringify({
6043
+ pruned: true,
6044
+ version: report.version,
6045
+ dry_run: report.dryRun,
6046
+ level: report.level,
6047
+ root: report.root,
6048
+ removed: report.removed,
6049
+ reclaimed_bytes: report.reclaimedBytes,
6050
+ entries: report.entries.map((e) => ({
6051
+ key: e.key,
6052
+ kind: e.kind,
6053
+ bytes: e.bytes,
6054
+ action: e.action,
6055
+ level: e.level,
6056
+ referenced_by: e.referencedBy,
6057
+ detail: e.detail
6058
+ }))
6059
+ }))}
6060
+ `);
6061
+ } else {
6062
+ io.stdout(`Signer runtimes under ${report.root}${report.dryRun ? " (dry run \u2014 nothing removed)" : ""}:
6063
+ `);
6064
+ if (report.entries.length === 0) io.stdout(" (none)\n");
6065
+ for (const e of report.entries) {
6066
+ const mark = e.level === "failed" ? "\u2717" : e.level === "advisory" ? "!" : e.action === "removed" ? "\u2713" : "\u2022";
6067
+ const size = e.bytes > 0 ? `${Math.round(e.bytes / 1024 / 1024)} MB` : e.action === "kept" ? "not sized" : "0 MB";
6068
+ io.stdout(redactSecrets(` ${mark} ${e.key} (${e.kind}, ${size}): ${e.detail}
6069
+ `));
6070
+ }
6071
+ io.stdout(
6072
+ report.dryRun ? `Would remove ${report.entries.filter((e) => e.action === "would_remove").length} director(y/ies); re-run without --dry-run to reclaim.
6073
+ ` : `Removed ${report.removed} director(y/ies), reclaimed ${Math.round(report.reclaimedBytes / 1024 / 1024)} MB.
6074
+ `
6075
+ );
6076
+ }
6077
+ return report.level === "failed" ? 1 : 0;
6078
+ } catch (err) {
6079
+ return failSubcommand(io, parsed.json, err, { pruned: false }, {
6080
+ code: "prune_failed",
6081
+ nextAction: "review_the_error_and_rerun_prune_which_is_idempotent"
6082
+ });
6083
+ }
6084
+ }
5518
6085
  if (parsed.rekey) {
5519
6086
  const { startRekey: startRekey2, finishRekey: finishRekey2 } = await Promise.resolve().then(() => (init_rekey(), rekey_exports));
5520
6087
  const { restartGuidance: restartGuidance2 } = await Promise.resolve().then(() => (init_rekey_restart(), rekey_restart_exports));
@@ -5591,7 +6158,7 @@ async function runCli(argv, io = {
5591
6158
  `);
5592
6159
  } else {
5593
6160
  for (const check of report.checks) {
5594
- io.stdout(redactSecrets(`${check.ok ? "\u2713" : "\u2717"} ${check.label}: ${check.detail}
6161
+ io.stdout(redactSecrets(`${levelMarker(check.level)} ${check.label}: ${check.detail}
5595
6162
  `));
5596
6163
  if (check.repair) io.stdout(redactSecrets(` \u21B3 repair: ${check.repair}
5597
6164
  `));
@@ -5601,21 +6168,25 @@ async function runCli(argv, io = {
5601
6168
  io.stdout("\nOther agents on this machine:\n");
5602
6169
  for (const agent of otherAgents) {
5603
6170
  const name = agent.slug ? `${agent.slug} (${agent.agentId ?? "unknown"})` : agent.agentId ?? "unknown";
5604
- const failed = agent.checks.filter((check) => !check.ok);
5605
- const verdict = agent.classification === "wired" ? failed.length === 0 ? "wired, all checks passed" : `wired, ${failed.length} check(s) FAILED` : agent.classification === "parked" ? "parked re-key only \u2014 no identity.json in this directory, but key material is still there" : agent.classification;
5606
- io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : "\u2022"} ${name}: ${verdict}
6171
+ const failed = agent.checks.filter((check) => check.level === "failed");
6172
+ const advised = agent.checks.filter((check) => check.level === "advisory");
6173
+ const verdict = agent.classification === "wired" ? failed.length > 0 ? `wired, ${failed.length} check(s) FAILED` : advised.length > 0 ? `wired, ${advised.length} advisory finding(s)` : "wired, all checks passed" : agent.classification === "parked" ? "parked re-key only \u2014 no identity.json in this directory, but key material is still there" : agent.classification;
6174
+ io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : advised.length > 0 ? "!" : "\u2022"} ${name}: ${verdict}
5607
6175
  `));
5608
- for (const check of failed) {
5609
- io.stdout(redactSecrets(` \u2717 ${check.label}: ${check.detail}
6176
+ for (const check of [...failed, ...advised]) {
6177
+ io.stdout(redactSecrets(` ${levelMarker(check.level)} ${check.label}: ${check.detail}
5610
6178
  `));
5611
6179
  if (check.repair) io.stdout(redactSecrets(` \u21B3 repair: ${check.repair}
5612
6180
  `));
5613
6181
  }
5614
6182
  }
5615
6183
  }
5616
- io.stdout(report.ok ? "All checks passed.\n" : "One or more checks FAILED \u2014 see repairs above.\n");
6184
+ io.stdout(
6185
+ report.level === "failed" ? "One or more checks FAILED \u2014 see repairs above.\n" : report.level === "advisory" ? `No failures. ${advisoryCount(report)} advisory finding(s) \u2014 see the ! line(s) above.
6186
+ ` : "All checks passed.\n"
6187
+ );
5617
6188
  }
5618
- return report.ok ? 0 : 1;
6189
+ return report.level === "failed" ? 1 : 0;
5619
6190
  } catch (err) {
5620
6191
  return failSubcommand(io, parsed.json, err, { doctor: "failed" }, {
5621
6192
  code: "doctor_failed",