@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 +74 -8
- package/dist/cli.cjs +343 -22
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +344 -23
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +343 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -4
- package/dist/index.d.ts +9 -4
- package/dist/index.js +344 -23
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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`.
|
|
198
|
-
|
|
199
|
-
re-key, the stored API key)
|
|
200
|
-
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
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.
|
|
570
|
+
sdkVersion: "0.0.0-dev.202609190328.55ffe63",
|
|
571
571
|
signerPackage: "@haven_ai/signer",
|
|
572
|
-
signerVersion: "0.0.0-dev.
|
|
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((
|
|
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
|
-
|
|
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
|
|
3199
|
-
|
|
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.
|
|
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((
|
|
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:
|
|
5520
|
-
const { join:
|
|
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
|
|
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:
|
|
5564
|
-
const { join:
|
|
5565
|
-
const homeDir =
|
|
5566
|
-
const root = parsed.options.credentialsDir ??
|
|
5567
|
-
const directory = parsed.unwireDir ?? (parsed.options.serverName ?
|
|
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\
|
|
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));
|