@haven_ai/connect 0.0.0-dev.202609182134.2018100 → 0.0.0-dev.202609190328.55ffe63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,7 +109,7 @@ report stays `version: 1`; `level` is additive.
109
109
  | Level | Meaning | Example |
110
110
  | --- | --- | --- |
111
111
  | `ok` | Nothing to say. | The installed signer matches the connector's pin. |
112
- | `advisory` | Worth reading; nothing is broken. Exit 0. | `signer_runtime`: the install is intact but behind the pinned version ("intact, but outdated" — both versions named, `--repair` offered). `superseded_agents` on a recognised runtime with no config file the connector can read (Claude Code, `other`): a second live key is reported, and the check says why "wired" cannot be verified from this machine. |
112
+ | `advisory` | Worth reading; nothing is broken. Exit 0. | `signer_runtime`: the install is intact but behind the pinned version ("intact, but outdated" — both versions named, `--repair` offered); `signer_runtime_unused` (#3123): runtime directories nothing references. `superseded_agents` on a recognised runtime with no config file the connector can read (Claude Code, `other`): a second live key is reported, and the check says why "wired" cannot be verified from this machine. |
113
113
  | `failed` | A real failure with one repair action. Exit 1. | A stale or empty runtime directory; a live key in a directory the runtime's config demonstrably does not use. |
114
114
 
115
115
  What stays blocking is live spend authority: a directory whose stored key
@@ -194,10 +194,74 @@ It tombstone-first (so a stale long-lived host still hears `HAVEN-TOMBSTONE`,
194
194
  never a masked `ENOENT`), then removes THAT agent's hosted + signer pair from
195
195
  every runtime config it appears in (Hermes YAML, Codex TOML, the Cursor / VS
196
196
  Code / Insiders / Claude Desktop JSON configs), plus the Hermes dotenv API-key
197
- line — bare `MCP_HAVEN_API_KEY` or named `MCP_HAVEN_<SLUG>_API_KEY`. Finally it
198
- tears down the target directory's local key material (signer key, any abandoned
199
- re-key, the stored API key) so `--doctor` reports `retired`, not the
200
- still-spend-capable `superseded`; the #2155 tombstone mirror keeps the record.
197
+ line — bare `MCP_HAVEN_API_KEY` or named `MCP_HAVEN_<SLUG>_API_KEY`. Then it
198
+ decides about the target directory's local key material (signer key, any
199
+ abandoned re-key, the stored API key) — and since #3123 it **asks before it
200
+ destroys**.
201
+
202
+ #### Teardown refuses before destroying the recovery credential (#3123)
203
+
204
+ A revoked agent's API key and delegate signature are exactly what the
205
+ sweep-recovery routes still accept — they are the only local means of
206
+ recovering a stranded delegate balance. So after the wiring is removed,
207
+ `--unwire` runs the one read it has (the identity probe, `GET
208
+ /machine-payments/agent` with the stored key — no new network call, no
209
+ backend change) and **refuses to destroy the key material on every answer**:
210
+
211
+ | Probe | What it means | What `--unwire` does |
212
+ | --- | --- | --- |
213
+ | `ok` | The agent is still active: its key still spends. | Refuses; tells you to revoke on the Haven agent page (connect never revokes), then re-run. |
214
+ | `unauthorized` | The key no longer authenticates on normal routes (revoked, archived, paused, pending approval, rotated, or not a key the backend knows — it does not say which). | Refuses; says plainly that a stranded balance **may** exist and the connector **cannot check**; recover first (`haven_sweep_delegate`, or the agent page). |
215
+ | `network_error` / `bad_response` | Could not verify. | Refuses: unknown is not "safe to delete". Retry. |
216
+ | *(no stored API key + URL)* | Nothing the recovery routes would accept. | Proceeds, unprobed — the pre-#3123 shape. |
217
+
218
+ The refusal is exit 1 with the wiring already gone; the key stays in the
219
+ 0o600 credential file only (the config and Hermes-env copies are scrubbed
220
+ first, so a refusal leaves the key in the credential file and in any config
221
+ this run could not clean — those are reported `✗` above, never silently),
222
+ and `--doctor` keeps reporting the directory as `superseded` until the key is
223
+ revoked or destroyed — that is the honest state. **`--destroy-key-material`**
224
+ proceeds on every answer, states what it destroyed and that local recovery
225
+ of a stranded balance ends with it. In `--json` the record carries an
226
+ additive `teardown: { status: destroyed | retained | forced, probe, detail,
227
+ remedy? }`.
228
+
229
+ The `claude-code` copy of the key (written by `claude mcp add`, into a config
230
+ the connector does not own) is out of scope for `--unwire`, as it always was:
231
+ remove it with `claude mcp remove haven` / `haven-signer` (or the named
232
+ pair) yourself; `--doctor --runtime claude-code` cannot see it either.
233
+
234
+ ### Pruning signer-runtime directories (`--prune-signer-runtimes`, #3123)
235
+
236
+ Every pinned version — and, since #2424, every `HAVEN_*_SPEC` override — gets
237
+ its own `~/.haven/signer-runtime/<key>/`, and nothing reclaimed them:
238
+
239
+ ```
240
+ npx @haven_ai/connect@<channel> --prune-signer-runtimes --dry-run
241
+ npx @haven_ai/connect@<channel> --prune-signer-runtimes
242
+ ```
243
+
244
+ It walks the root (so override-keyed `override-<hash>` directories are seen,
245
+ not only manifest versions), **keeps** every directory any credential
246
+ directory names — through its `signer-runtime.json` or through the path its
247
+ wrapper launches — wired, superseded or retired; the prune never decides who
248
+ is live — and the connector's current pin (what
249
+ `--repair` installs), and removes the rest, reporting each with a #3121
250
+ level: kept/removed are `ok`, a dry-run candidate is an `advisory`, a removal
251
+ that failed (a signer process still holding the directory open on a platform
252
+ that refuses the unlink) is `failed` and the only thing that exits 1. On
253
+ POSIX a running signer keeps its open files until it restarts, and since
254
+ only directories no sidecar and no wrapper names go, a configured agent is
255
+ not started against a removed one. The prune trusts those two reference
256
+ sources (the sidecar's `runtime_directory` and the path the wrapper
257
+ launches — so a directory whose sidecar is missing or corrupt but whose
258
+ wrapper is intact keeps its runtime); it reads the default agents root AND
259
+ the parent of an explicit `--credentials-dir`, a union, never either/or. It
260
+ is its own flag — never part of `--repair`, never automatic; `--doctor`
261
+ reports unused directories as an advisory (`signer_runtime_unused`) that
262
+ names this command — names only; sizes come from `--dry-run`, and a kept
263
+ directory is never sized (its row says "not sized"). `--json` emits
264
+ `{ pruned: true, version: 1, root, dry_run, level, removed, reclaimed_bytes, entries[] }`.
201
265
 
202
266
  An **unnamed** pair (`haven` / `haven-signer`) is shared by every unnamed agent
203
267
  and is only removed when this directory's wrapper is the one the config
@@ -387,9 +451,11 @@ already-configured machine behaves as follows (characterized in
387
451
  managed Codex/Hermes equivalents) and re-points them at the new agent's
388
452
  credentials; unrelated MCP servers and configuration are preserved. Once
389
453
  the runtime install has actually completed, each superseded directory is
390
- tombstoned and its local key files removed — the same teardown `--unwire`
391
- performs — so `--doctor` reads it as `retired` rather than still
392
- spend-capable. If the install ends with an error code the retirement is
454
+ tombstoned and its local key files removed — the unconditional key-material
455
+ teardown; `--unwire` itself runs that teardown only when its #3123 probe says
456
+ there is nothing to preserve, and `--replace` deliberately does not probe
457
+ (the owner has just chosen to overwrite; see the #3119 follow-up) — so
458
+ `--doctor` reads it as `retired` rather than still spend-capable. If the install ends with an error code the retirement is
393
459
  **skipped**, because the old wiring may still be the only working one; the
394
460
  outcome's `superseded_agents_retired_locally` says which happened and
395
461
  `retired_agent_ids` names exactly the directories it reached. With
package/dist/cli.cjs CHANGED
@@ -567,9 +567,9 @@ var init_runtime_manifest = __esm({
567
567
  mcpPackage: "@haven_ai/mcp",
568
568
  mcpVersion: mcp.MCP_VERSION,
569
569
  sdkPackage: "@haven_ai/sdk",
570
- sdkVersion: "0.0.0-dev.202609182134.2018100",
570
+ sdkVersion: "0.0.0-dev.202609190328.55ffe63",
571
571
  signerPackage: "@haven_ai/signer",
572
- signerVersion: "0.0.0-dev.202609182134.2018100",
572
+ signerVersion: "0.0.0-dev.202609190328.55ffe63",
573
573
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
574
574
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
575
575
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -1538,7 +1538,7 @@ async function probeLocalSignerCredential(signerPath) {
1538
1538
  }
1539
1539
  }
1540
1540
  async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
1541
- return new Promise((resolve9) => {
1541
+ return new Promise((resolve10) => {
1542
1542
  const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
1543
1543
  let stdout = "";
1544
1544
  let settled = false;
@@ -1550,7 +1550,7 @@ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4)
1550
1550
  settled = true;
1551
1551
  clearTimeout(timeout);
1552
1552
  child.kill();
1553
- resolve9(result);
1553
+ resolve10(result);
1554
1554
  };
1555
1555
  const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1556
1556
  child.on("error", () => finish({ status: "process_error" }));
@@ -3195,8 +3195,51 @@ async function unwireAgent(input) {
3195
3195
  }
3196
3196
  }
3197
3197
  }
3198
- await teardownLocalKeyMaterial(input.directory, identity);
3199
- return { directory: input.directory, agentId, slug, tombstoned, runtimes };
3198
+ const teardown = await decideTeardown(identity, input);
3199
+ if (teardown.status !== "retained") await teardownLocalKeyMaterial(input.directory, identity);
3200
+ return { directory: input.directory, agentId, slug, tombstoned, runtimes, teardown };
3201
+ }
3202
+ async function decideTeardown(identity, input) {
3203
+ 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.";
3204
+ if (!identity?.api_key || !identity.api_url) {
3205
+ return {
3206
+ status: input.destroyKeyMaterial ? "forced" : "destroyed",
3207
+ probe: "not_probed",
3208
+ detail: "No stored API key + API URL to probe with \u2014 nothing the sweep-recovery routes would accept, so nothing to preserve."
3209
+ };
3210
+ }
3211
+ const probe = await (input.probeHostedIdentity ?? probeHostedAgentIdentity)(identity.api_key, identity.api_url, input.fetch);
3212
+ if (input.destroyKeyMaterial) {
3213
+ return {
3214
+ status: "forced",
3215
+ probe: probe.status,
3216
+ detail: `Key material destroyed under ${DESTROY_FLAG} (probe: ${probe.status}): signer.json, any parked re-key, and the API key in identity.json.`,
3217
+ remedy: ended
3218
+ };
3219
+ }
3220
+ switch (probe.status) {
3221
+ case "ok":
3222
+ return {
3223
+ status: "retained",
3224
+ probe: "ok",
3225
+ 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.",
3226
+ 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}.`
3227
+ };
3228
+ case "unauthorized":
3229
+ return {
3230
+ status: "retained",
3231
+ probe: "unauthorized",
3232
+ 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.",
3233
+ 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.`
3234
+ };
3235
+ default:
3236
+ return {
3237
+ status: "retained",
3238
+ probe: probe.status,
3239
+ 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.`,
3240
+ remedy: `Retry when the backend is reachable, or re-run with ${DESTROY_FLAG} to remove the key material regardless.`
3241
+ };
3242
+ }
3200
3243
  }
3201
3244
  async function tombstoneDirectoryIfAbsent(input) {
3202
3245
  if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
@@ -3217,9 +3260,10 @@ async function teardownLocalKeyMaterial(directory, identity) {
3217
3260
  async function readIdentityFile(directory) {
3218
3261
  return identityAt(directory);
3219
3262
  }
3220
- var RUNTIMES;
3263
+ var RUNTIMES, DESTROY_FLAG;
3221
3264
  var init_unwire = __esm({
3222
3265
  "src/unwire.ts"() {
3266
+ init_probes();
3223
3267
  init_config_writers();
3224
3268
  init_server_names();
3225
3269
  init_signer_runtime();
@@ -3233,6 +3277,7 @@ var init_unwire = __esm({
3233
3277
  { runtime: "vscode-insiders", label: "VS Code Insiders MCP config", kind: "json", serverRoot: "servers" },
3234
3278
  { runtime: "claude-desktop", label: "Claude Desktop config", kind: "json", serverRoot: "mcpServers" }
3235
3279
  ];
3280
+ DESTROY_FLAG = "--destroy-key-material";
3236
3281
  }
3237
3282
  });
3238
3283
 
@@ -3244,6 +3289,174 @@ var init_rekey_messages = __esm({
3244
3289
  }
3245
3290
  });
3246
3291
 
3292
+ // src/prune-runtimes.ts
3293
+ var prune_runtimes_exports = {};
3294
+ __export(prune_runtimes_exports, {
3295
+ normalizeRuntimePath: () => normalizeRuntimePath,
3296
+ pruneSignerRuntimes: () => pruneSignerRuntimes,
3297
+ referencedRuntimeDirectories: () => referencedRuntimeDirectories,
3298
+ rollUpPruneLevel: () => rollUpPruneLevel,
3299
+ signerRuntimeRoot: () => signerRuntimeRoot
3300
+ });
3301
+ function signerRuntimeRoot(homeDir) {
3302
+ return path.join(homeDir, ".haven", "signer-runtime");
3303
+ }
3304
+ function classifyKey(key) {
3305
+ if (key.startsWith("override-")) return "override";
3306
+ if (key === MCP_RUNTIME_MANIFEST.signerVersion || /^\d+\.\d+\.\d+/.test(key)) return "version";
3307
+ return "unknown";
3308
+ }
3309
+ async function directoryBytes(directory) {
3310
+ let total = 0;
3311
+ const walk = async (dir) => {
3312
+ let entries = [];
3313
+ try {
3314
+ entries = await promises.readdir(dir, { withFileTypes: true });
3315
+ } catch {
3316
+ return;
3317
+ }
3318
+ for (const entry of entries) {
3319
+ const path$1 = path.join(dir, entry.name);
3320
+ if (entry.isDirectory()) await walk(path$1);
3321
+ else if (entry.isFile()) {
3322
+ try {
3323
+ total += (await promises.stat(path$1)).size;
3324
+ } catch {
3325
+ }
3326
+ }
3327
+ }
3328
+ };
3329
+ await walk(directory);
3330
+ return total;
3331
+ }
3332
+ async function normalizeRuntimePath(path$1) {
3333
+ const resolved = path.resolve(path$1).replace(/[\\/]+$/, "");
3334
+ try {
3335
+ return await promises.realpath(resolved);
3336
+ } catch {
3337
+ return resolved;
3338
+ }
3339
+ }
3340
+ function escapeRegExp(text) {
3341
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3342
+ }
3343
+ async function referencedRuntimeDirectories(homeDir, credentialsDir) {
3344
+ const roots = /* @__PURE__ */ new Set([path.join(homeDir, ".haven", "agents")]);
3345
+ if (credentialsDir) roots.add(path.dirname(path.resolve(credentialsDir)));
3346
+ const referenced = /* @__PURE__ */ new Map();
3347
+ const runtimeRoot = await normalizeRuntimePath(signerRuntimeRoot(homeDir));
3348
+ const rootSpellings = [.../* @__PURE__ */ new Set([path.resolve(signerRuntimeRoot(homeDir)), runtimeRoot])];
3349
+ const wrapperRe = new RegExp(`(?:${rootSpellings.map(escapeRegExp).join("|")})[\\/]+([^\\/'"\\s]+)`, "g");
3350
+ const add = async (runtimeDirectory, by) => {
3351
+ const key = await normalizeRuntimePath(runtimeDirectory);
3352
+ const list = referenced.get(key) ?? [];
3353
+ if (!list.includes(by)) list.push(by);
3354
+ referenced.set(key, list);
3355
+ };
3356
+ for (const root of roots) {
3357
+ let entries = [];
3358
+ try {
3359
+ entries = await promises.readdir(root);
3360
+ } catch {
3361
+ continue;
3362
+ }
3363
+ for (const entry of entries) {
3364
+ const directory = path.join(root, entry);
3365
+ try {
3366
+ const sidecar = JSON.parse(await promises.readFile(path.join(directory, "signer-runtime.json"), "utf8"));
3367
+ if (typeof sidecar.runtime_directory === "string" && sidecar.runtime_directory.length > 0) {
3368
+ await add(sidecar.runtime_directory, directory);
3369
+ }
3370
+ } catch {
3371
+ }
3372
+ try {
3373
+ const wrapper = await promises.readFile(path.join(directory, "bin", "haven-signer.mjs"), "utf8");
3374
+ for (const match of wrapper.matchAll(wrapperRe)) await add(path.join(runtimeRoot, match[1]), directory);
3375
+ } catch {
3376
+ }
3377
+ }
3378
+ }
3379
+ return referenced;
3380
+ }
3381
+ function rollUpPruneLevel(entries) {
3382
+ if (entries.some((e) => e.level === "failed")) return "failed";
3383
+ if (entries.some((e) => e.level === "advisory")) return "advisory";
3384
+ return "ok";
3385
+ }
3386
+ async function pruneSignerRuntimes(input, deps = {}) {
3387
+ const homeDir = deps.homeDir ?? os.homedir();
3388
+ const root = signerRuntimeRoot(homeDir);
3389
+ const measure = input.measure ?? true;
3390
+ const referenced = await referencedRuntimeDirectories(homeDir, deps.credentialsDir);
3391
+ const pin = await normalizeRuntimePath(path.join(root, MCP_RUNTIME_MANIFEST.signerVersion));
3392
+ const remove = deps.rm ?? (async (directory) => promises.rm(directory, { recursive: true, force: false }));
3393
+ const entries = [];
3394
+ let keys = [];
3395
+ try {
3396
+ keys = (await promises.readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
3397
+ } catch {
3398
+ return { version: 1, root, dryRun: input.dryRun, entries, removed: 0, reclaimedBytes: 0, level: "ok" };
3399
+ }
3400
+ let removed = 0;
3401
+ let reclaimedBytes = 0;
3402
+ for (const key of keys) {
3403
+ const directory = path.join(root, key);
3404
+ const normalized = await normalizeRuntimePath(directory);
3405
+ const referencedBy = referenced.get(normalized) ?? [];
3406
+ const kind = classifyKey(key);
3407
+ if (referencedBy.length > 0 || normalized === pin) {
3408
+ entries.push({
3409
+ directory,
3410
+ key,
3411
+ kind,
3412
+ bytes: 0,
3413
+ referencedBy,
3414
+ action: "kept",
3415
+ level: "ok",
3416
+ 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)"
3417
+ });
3418
+ continue;
3419
+ }
3420
+ const bytes = measure ? await directoryBytes(directory) : 0;
3421
+ if (input.dryRun) {
3422
+ entries.push({
3423
+ directory,
3424
+ key,
3425
+ kind,
3426
+ bytes,
3427
+ referencedBy,
3428
+ action: "would_remove",
3429
+ level: "advisory",
3430
+ detail: `would remove \u2014 no credential directory names it (${kind}-keyed)`
3431
+ });
3432
+ continue;
3433
+ }
3434
+ try {
3435
+ await remove(directory);
3436
+ removed += 1;
3437
+ reclaimedBytes += bytes;
3438
+ entries.push({ directory, key, kind, bytes, referencedBy, action: "removed", level: "ok", detail: `removed \u2014 no credential directory named it (${kind}-keyed)` });
3439
+ } catch (err) {
3440
+ entries.push({
3441
+ directory,
3442
+ key,
3443
+ kind,
3444
+ bytes,
3445
+ referencedBy,
3446
+ action: "failed",
3447
+ level: "failed",
3448
+ detail: `removal failed: ${err instanceof Error ? err.message : String(err)} \u2014 a signer process may still hold it open; stop it and re-run`
3449
+ });
3450
+ }
3451
+ }
3452
+ return { version: 1, root, dryRun: input.dryRun, entries, removed, reclaimedBytes, level: rollUpPruneLevel(entries) };
3453
+ }
3454
+ var init_prune_runtimes = __esm({
3455
+ "src/prune-runtimes.ts"() {
3456
+ init_runtime_manifest();
3457
+ }
3458
+ });
3459
+
3247
3460
  // src/rekey.ts
3248
3461
  var rekey_exports = {};
3249
3462
  __export(rekey_exports, {
@@ -4099,6 +4312,17 @@ async function runDoctor(input, deps = {}) {
4099
4312
  }
4100
4313
  const signerProcess = primaryChecksById.get("signer_process");
4101
4314
  if (signerProcess) checks.push(signerProcess);
4315
+ const prune = await (deps.pruneSignerRuntimes ?? pruneSignerRuntimes)({ dryRun: true, measure: false }, { homeDir, credentialsDir: input.credentialsDir });
4316
+ const unused = prune.entries.filter((entry) => entry.action === "would_remove");
4317
+ if (unused.length > 0) {
4318
+ checks.push({
4319
+ id: "signer_runtime_unused",
4320
+ label: "Unused signer-runtime directories",
4321
+ level: "advisory",
4322
+ 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).`,
4323
+ repair: `Run: ${RERUN} --prune-signer-runtimes (add --dry-run to list only).`
4324
+ });
4325
+ }
4102
4326
  const restart = restartRequiredForRuntime(input2.runtime, deps.env);
4103
4327
  checks.push({
4104
4328
  id: "restart",
@@ -4205,6 +4429,7 @@ async function runRepair(input, deps = {}) {
4205
4429
  var RERUN;
4206
4430
  var init_doctor = __esm({
4207
4431
  "src/doctor.ts"() {
4432
+ init_prune_runtimes();
4208
4433
  init_runtime_manifest();
4209
4434
  init_probes();
4210
4435
  init_signer_runtime();
@@ -4518,7 +4743,7 @@ async function pathExists2(path) {
4518
4743
  init_unwire();
4519
4744
  init_local_mcp_runtime();
4520
4745
  init_runtime_manifest();
4521
- var CONNECTOR_VERSION = "0.0.0-dev.202609182134.2018100";
4746
+ var CONNECTOR_VERSION = "0.0.0-dev.202609190328.55ffe63";
4522
4747
  var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4523
4748
  var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4524
4749
  function failureOutcomeFor(runtimeHint, error) {
@@ -5104,7 +5329,7 @@ function describeWaitBound(timeoutMs) {
5104
5329
  async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
5105
5330
  const intervalMs = options.intervalMs ?? 5e3;
5106
5331
  const timeoutMs = options.timeoutMs ?? 18e4;
5107
- const sleep = options.sleep ?? ((ms) => new Promise((resolve9) => setTimeout(resolve9, ms)));
5332
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)));
5108
5333
  const maxPolls = Math.max(1, Math.floor(timeoutMs / intervalMs));
5109
5334
  const remindEvery = Math.max(1, Math.floor(3e4 / intervalMs));
5110
5335
  let waitingAnnounced = false;
@@ -5243,6 +5468,9 @@ function parseArgs(argv, env = process.env) {
5243
5468
  let tombstoneReason;
5244
5469
  let tombstoneReplacedBy;
5245
5470
  let unwire;
5471
+ let destroyKeyMaterial = false;
5472
+ let pruneSignerRuntimes2 = false;
5473
+ let dryRun = false;
5246
5474
  let unwireDir;
5247
5475
  let replace = false;
5248
5476
  for (let i = 0; i < argv.length; i += 1) {
@@ -5270,6 +5498,12 @@ function parseArgs(argv, env = process.env) {
5270
5498
  unwireDir = next;
5271
5499
  i += 1;
5272
5500
  }
5501
+ } else if (arg === "--destroy-key-material") {
5502
+ destroyKeyMaterial = true;
5503
+ } else if (arg === "--prune-signer-runtimes") {
5504
+ pruneSignerRuntimes2 = true;
5505
+ } else if (arg === "--dry-run") {
5506
+ dryRun = true;
5273
5507
  } else if (arg === "--reason") {
5274
5508
  tombstoneReason = requireValue(argv, ++i, arg);
5275
5509
  } else if (arg === "--replaced-by") {
@@ -5312,7 +5546,7 @@ function parseArgs(argv, env = process.env) {
5312
5546
  return { options, help, json, doctor, repair, tombstone, rekey };
5313
5547
  }
5314
5548
  if (replace) {
5315
- if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
5549
+ if (rekeyPhase || tombstoneDir || unwire || doctor || repair || pruneSignerRuntimes2) {
5316
5550
  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.");
5317
5551
  }
5318
5552
  if (options.serverName) {
@@ -5322,6 +5556,22 @@ function parseArgs(argv, env = process.env) {
5322
5556
  }
5323
5557
  options.replaceExistingWiring = true;
5324
5558
  }
5559
+ if (destroyKeyMaterial && !unwire) {
5560
+ throw new Error("--destroy-key-material only applies to --unwire.");
5561
+ }
5562
+ if (destroyKeyMaterial && unwire) unwire = { ...unwire, destroyKeyMaterial: true };
5563
+ if (dryRun && !pruneSignerRuntimes2) {
5564
+ throw new Error("--dry-run only applies to --prune-signer-runtimes.");
5565
+ }
5566
+ if (pruneSignerRuntimes2) {
5567
+ if (unwire || tombstoneDir || rekeyPhase || doctor || repair) {
5568
+ throw new Error("--prune-signer-runtimes is its own operation; run it alone.");
5569
+ }
5570
+ if (options.setupToken) {
5571
+ throw new Error("--prune-signer-runtimes takes no --setup token; it reads stored state only.");
5572
+ }
5573
+ return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir, pruneSignerRuntimes: { dryRun } };
5574
+ }
5325
5575
  if (rekey) {
5326
5576
  if (options.setupToken) {
5327
5577
  throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
@@ -5443,6 +5693,12 @@ function helpText() {
5443
5693
  " API key are removed locally (record kept via the #2155 tombstone mirror) and",
5444
5694
  " nothing is ever revoked on the backend \u2014 that stays an owner action on the",
5445
5695
  " Haven agent page.",
5696
+ " --destroy-key-material With --unwire: destroy the signer key + stored API key even when the agent",
5697
+ " is still active or cannot be verified (#3123). Local sweep recovery ends.",
5698
+ " --prune-signer-runtimes Remove ~/.haven/signer-runtime directories no credential directory references",
5699
+ " (version- and override-keyed alike); --dry-run lists them. Exits 1 only on a",
5700
+ " removal that failed (#3123).",
5701
+ " --dry-run With --prune-signer-runtimes: report, remove nothing.",
5446
5702
  " --reason <text> Reason recorded in the tombstone (with --tombstone or --unwire).",
5447
5703
  " --replaced-by <agent-id> Successor agent recorded in the tombstone (with --tombstone or --unwire).",
5448
5704
  " --help Show this help.",
@@ -5516,13 +5772,13 @@ async function runCli(argv, io = {
5516
5772
  }
5517
5773
  if (parsed.tombstone) {
5518
5774
  const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5519
- const { readFile: readFile14 } = await import('fs/promises');
5520
- const { join: join12 } = await import('path');
5775
+ const { readFile: readFile15 } = await import('fs/promises');
5776
+ const { join: join13 } = await import('path');
5521
5777
  try {
5522
5778
  let agentId = "unknown";
5523
5779
  try {
5524
5780
  const identity = JSON.parse(
5525
- await readFile14(join12(parsed.tombstone.directory, "identity.json"), "utf8")
5781
+ await readFile15(join13(parsed.tombstone.directory, "identity.json"), "utf8")
5526
5782
  );
5527
5783
  agentId = identity.agent_id ?? "unknown";
5528
5784
  } catch {
@@ -5560,20 +5816,22 @@ async function runCli(argv, io = {
5560
5816
  }
5561
5817
  if (parsed.unwire) {
5562
5818
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
5563
- const { homedir: homedir10 } = await import('os');
5564
- const { join: join12 } = await import('path');
5565
- const homeDir = homedir10();
5566
- const root = parsed.options.credentialsDir ?? join12(homeDir, ".haven", "agents");
5567
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join12(root, parsed.options.serverName) : root);
5819
+ const { homedir: homedir11 } = await import('os');
5820
+ const { join: join13 } = await import('path');
5821
+ const homeDir = homedir11();
5822
+ const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
5823
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
5568
5824
  try {
5569
5825
  const result = await unwireAgent2({
5570
5826
  directory,
5571
5827
  slug: parsed.options.serverName,
5572
5828
  reason: parsed.unwire.reason,
5573
5829
  replacedBy: parsed.unwire.replacedBy,
5830
+ destroyKeyMaterial: parsed.unwire.destroyKeyMaterial,
5574
5831
  homeDir
5575
5832
  });
5576
5833
  const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
5834
+ const retained = result.teardown.status === "retained";
5577
5835
  if (parsed.json) {
5578
5836
  io.stdout(
5579
5837
  `${redactSecrets(
@@ -5588,7 +5846,14 @@ async function runCli(argv, io = {
5588
5846
  label: r.label,
5589
5847
  status: r.status,
5590
5848
  ...r.detail ? { detail: r.detail } : {}
5591
- }))
5849
+ })),
5850
+ // #3123: additive — what happened to the key material and why.
5851
+ teardown: {
5852
+ status: result.teardown.status,
5853
+ probe: result.teardown.probe,
5854
+ detail: result.teardown.detail,
5855
+ ...result.teardown.remedy ? { remedy: result.teardown.remedy } : {}
5856
+ }
5592
5857
  })
5593
5858
  )}
5594
5859
  `
@@ -5604,14 +5869,19 @@ async function runCli(argv, io = {
5604
5869
  io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
5605
5870
  `));
5606
5871
  }
5872
+ const t = result.teardown;
5873
+ io.stdout(redactSecrets(` ${t.status === "retained" ? "\u2717" : t.status === "forced" ? "!" : "\u2713"} Key material: ${t.status} (probe: ${t.probe}) \u2014 ${t.detail}
5874
+ `));
5875
+ if (t.remedy) io.stdout(redactSecrets(` \u21B3 ${t.remedy}
5876
+ `));
5607
5877
  io.stdout(
5608
- 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"
5878
+ 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"
5609
5879
  );
5610
5880
  io.stdout(
5611
- "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"
5881
+ "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"
5612
5882
  );
5613
5883
  }
5614
- return failures.length > 0 ? 1 : 0;
5884
+ return failures.length > 0 || retained ? 1 : 0;
5615
5885
  } catch (err) {
5616
5886
  return failSubcommand(io, parsed.json, err, { unwired: false }, {
5617
5887
  code: "unwire_failed",
@@ -5619,6 +5889,57 @@ async function runCli(argv, io = {
5619
5889
  });
5620
5890
  }
5621
5891
  }
5892
+ if (parsed.pruneSignerRuntimes) {
5893
+ const { pruneSignerRuntimes: pruneSignerRuntimes2 } = await Promise.resolve().then(() => (init_prune_runtimes(), prune_runtimes_exports));
5894
+ try {
5895
+ const report = await pruneSignerRuntimes2(
5896
+ { dryRun: parsed.pruneSignerRuntimes.dryRun },
5897
+ { credentialsDir: parsed.options.credentialsDir }
5898
+ );
5899
+ if (parsed.json) {
5900
+ io.stdout(`${redactSecrets(JSON.stringify({
5901
+ pruned: true,
5902
+ version: report.version,
5903
+ dry_run: report.dryRun,
5904
+ level: report.level,
5905
+ root: report.root,
5906
+ removed: report.removed,
5907
+ reclaimed_bytes: report.reclaimedBytes,
5908
+ entries: report.entries.map((e) => ({
5909
+ key: e.key,
5910
+ kind: e.kind,
5911
+ bytes: e.bytes,
5912
+ action: e.action,
5913
+ level: e.level,
5914
+ referenced_by: e.referencedBy,
5915
+ detail: e.detail
5916
+ }))
5917
+ }))}
5918
+ `);
5919
+ } else {
5920
+ io.stdout(`Signer runtimes under ${report.root}${report.dryRun ? " (dry run \u2014 nothing removed)" : ""}:
5921
+ `);
5922
+ if (report.entries.length === 0) io.stdout(" (none)\n");
5923
+ for (const e of report.entries) {
5924
+ const mark = e.level === "failed" ? "\u2717" : e.level === "advisory" ? "!" : e.action === "removed" ? "\u2713" : "\u2022";
5925
+ const size = e.bytes > 0 ? `${Math.round(e.bytes / 1024 / 1024)} MB` : e.action === "kept" ? "not sized" : "0 MB";
5926
+ io.stdout(redactSecrets(` ${mark} ${e.key} (${e.kind}, ${size}): ${e.detail}
5927
+ `));
5928
+ }
5929
+ io.stdout(
5930
+ report.dryRun ? `Would remove ${report.entries.filter((e) => e.action === "would_remove").length} director(y/ies); re-run without --dry-run to reclaim.
5931
+ ` : `Removed ${report.removed} director(y/ies), reclaimed ${Math.round(report.reclaimedBytes / 1024 / 1024)} MB.
5932
+ `
5933
+ );
5934
+ }
5935
+ return report.level === "failed" ? 1 : 0;
5936
+ } catch (err) {
5937
+ return failSubcommand(io, parsed.json, err, { pruned: false }, {
5938
+ code: "prune_failed",
5939
+ nextAction: "review_the_error_and_rerun_prune_which_is_idempotent"
5940
+ });
5941
+ }
5942
+ }
5622
5943
  if (parsed.rekey) {
5623
5944
  const { startRekey: startRekey2, finishRekey: finishRekey2 } = await Promise.resolve().then(() => (init_rekey(), rekey_exports));
5624
5945
  const { restartGuidance: restartGuidance2 } = await Promise.resolve().then(() => (init_rekey_restart(), rekey_restart_exports));