@haven_ai/connect 0.3.0-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/README.md +174 -15
- package/dist/cli.cjs +654 -84
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +655 -85
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +654 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -4
- package/dist/index.d.ts +44 -4
- package/dist/index.js +655 -85
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/cli.cjs
CHANGED
|
@@ -172,6 +172,16 @@ function redactSecrets(value) {
|
|
|
172
172
|
function redactForAutomation(value) {
|
|
173
173
|
return redactSecrets(value).replace(/(?:~|\/)[^\s`"']*\/(?:identity|signer|agent)\.json\b/g, "[credential-file-redacted]").replace(/(?:~|\/)[^\s`"']*\/\.env\b/g, "[credential-env-redacted]");
|
|
174
174
|
}
|
|
175
|
+
function withoutUserinfo(url) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = new URL(url);
|
|
178
|
+
parsed.username = "";
|
|
179
|
+
parsed.password = "";
|
|
180
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
181
|
+
} catch {
|
|
182
|
+
return url;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
175
185
|
function shortAddress(address) {
|
|
176
186
|
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return address;
|
|
177
187
|
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
@@ -533,12 +543,64 @@ async function writeConnectOutcomeRecord(directory, outcome, warn) {
|
|
|
533
543
|
await restrictPermissions(path$1, 384, warn);
|
|
534
544
|
return path$1;
|
|
535
545
|
}
|
|
536
|
-
|
|
546
|
+
async function readConnectOutcomeRuntime(directory) {
|
|
547
|
+
try {
|
|
548
|
+
const parsed = JSON.parse(await promises.readFile(path.join(directory, CONNECT_OUTCOME_FILENAME), "utf8"));
|
|
549
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
550
|
+
const runtime = parsed.runtime;
|
|
551
|
+
return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
async function writeMcpServerBinding(directory, binding) {
|
|
557
|
+
const path$1 = path.join(directory, MCP_SERVER_BINDING_FILENAME);
|
|
558
|
+
await promises.writeFile(path$1, `${JSON.stringify(binding, null, 2)}
|
|
559
|
+
`, { mode: 384 });
|
|
560
|
+
return path$1;
|
|
561
|
+
}
|
|
562
|
+
async function readMcpServerBinding(directory) {
|
|
563
|
+
try {
|
|
564
|
+
const parsed = JSON.parse(await promises.readFile(path.join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
|
|
565
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
566
|
+
const record = parsed;
|
|
567
|
+
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;
|
|
568
|
+
return record;
|
|
569
|
+
} catch {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function clearMcpServerBinding(directory) {
|
|
574
|
+
try {
|
|
575
|
+
await promises.rm(path.join(directory, MCP_SERVER_BINDING_FILENAME));
|
|
576
|
+
return true;
|
|
577
|
+
} catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function listMcpServerBindings(baseDir, excludeDirectory) {
|
|
582
|
+
const root = defaultCredentialRoot(baseDir);
|
|
583
|
+
let entries = [];
|
|
584
|
+
try {
|
|
585
|
+
entries = await promises.readdir(root);
|
|
586
|
+
} catch {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
const out = [];
|
|
590
|
+
for (const entry of entries) {
|
|
591
|
+
const directory = path.join(root, entry);
|
|
592
|
+
const binding = await readMcpServerBinding(directory);
|
|
593
|
+
if (binding) out.push({ directory, binding });
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
}
|
|
597
|
+
var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME, MCP_SERVER_BINDING_FILENAME;
|
|
537
598
|
var init_storage = __esm({
|
|
538
599
|
"src/storage.ts"() {
|
|
539
600
|
REKEY_PENDING_FILENAME = "rekey-pending.json";
|
|
540
601
|
REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
541
602
|
CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
|
|
603
|
+
MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
|
|
542
604
|
}
|
|
543
605
|
});
|
|
544
606
|
function mcpPackageSpec() {
|
|
@@ -557,9 +619,9 @@ var init_runtime_manifest = __esm({
|
|
|
557
619
|
mcpPackage: "@haven_ai/mcp",
|
|
558
620
|
mcpVersion: mcp.MCP_VERSION,
|
|
559
621
|
sdkPackage: "@haven_ai/sdk",
|
|
560
|
-
sdkVersion: "0.
|
|
622
|
+
sdkVersion: "0.4.0-alpha.0",
|
|
561
623
|
signerPackage: "@haven_ai/signer",
|
|
562
|
-
signerVersion: "0.
|
|
624
|
+
signerVersion: "0.4.0-alpha.0",
|
|
563
625
|
// Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
|
|
564
626
|
// while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
|
|
565
627
|
// so the guard that was supposed to enforce the floor waved Node v23 through
|
|
@@ -1528,7 +1590,7 @@ async function probeLocalSignerCredential(signerPath) {
|
|
|
1528
1590
|
}
|
|
1529
1591
|
}
|
|
1530
1592
|
async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
|
|
1531
|
-
return new Promise((
|
|
1593
|
+
return new Promise((resolve10) => {
|
|
1532
1594
|
const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
|
|
1533
1595
|
let stdout = "";
|
|
1534
1596
|
let settled = false;
|
|
@@ -1540,7 +1602,7 @@ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4)
|
|
|
1540
1602
|
settled = true;
|
|
1541
1603
|
clearTimeout(timeout);
|
|
1542
1604
|
child.kill();
|
|
1543
|
-
|
|
1605
|
+
resolve10(result);
|
|
1544
1606
|
};
|
|
1545
1607
|
const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
|
|
1546
1608
|
child.on("error", () => finish({ status: "process_error" }));
|
|
@@ -3185,8 +3247,52 @@ async function unwireAgent(input) {
|
|
|
3185
3247
|
}
|
|
3186
3248
|
}
|
|
3187
3249
|
}
|
|
3188
|
-
await
|
|
3189
|
-
|
|
3250
|
+
const bindingReleased = await clearMcpServerBinding(input.directory);
|
|
3251
|
+
const teardown = await decideTeardown(identity, input);
|
|
3252
|
+
if (teardown.status !== "retained") await teardownLocalKeyMaterial(input.directory, identity);
|
|
3253
|
+
return { directory: input.directory, agentId, slug, tombstoned, runtimes, teardown, bindingReleased };
|
|
3254
|
+
}
|
|
3255
|
+
async function decideTeardown(identity, input) {
|
|
3256
|
+
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.";
|
|
3257
|
+
if (!identity?.api_key || !identity.api_url) {
|
|
3258
|
+
return {
|
|
3259
|
+
status: input.destroyKeyMaterial ? "forced" : "destroyed",
|
|
3260
|
+
probe: "not_probed",
|
|
3261
|
+
detail: "No stored API key + API URL to probe with \u2014 nothing the sweep-recovery routes would accept, so nothing to preserve."
|
|
3262
|
+
};
|
|
3263
|
+
}
|
|
3264
|
+
const probe = await (input.probeHostedIdentity ?? probeHostedAgentIdentity)(identity.api_key, identity.api_url, input.fetch);
|
|
3265
|
+
if (input.destroyKeyMaterial) {
|
|
3266
|
+
return {
|
|
3267
|
+
status: "forced",
|
|
3268
|
+
probe: probe.status,
|
|
3269
|
+
detail: `Key material destroyed under ${DESTROY_FLAG} (probe: ${probe.status}): signer.json, any parked re-key, and the API key in identity.json.`,
|
|
3270
|
+
remedy: ended
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
switch (probe.status) {
|
|
3274
|
+
case "ok":
|
|
3275
|
+
return {
|
|
3276
|
+
status: "retained",
|
|
3277
|
+
probe: "ok",
|
|
3278
|
+
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.",
|
|
3279
|
+
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}.`
|
|
3280
|
+
};
|
|
3281
|
+
case "unauthorized":
|
|
3282
|
+
return {
|
|
3283
|
+
status: "retained",
|
|
3284
|
+
probe: "unauthorized",
|
|
3285
|
+
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.",
|
|
3286
|
+
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.`
|
|
3287
|
+
};
|
|
3288
|
+
default:
|
|
3289
|
+
return {
|
|
3290
|
+
status: "retained",
|
|
3291
|
+
probe: probe.status,
|
|
3292
|
+
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.`,
|
|
3293
|
+
remedy: `Retry when the backend is reachable, or re-run with ${DESTROY_FLAG} to remove the key material regardless.`
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3190
3296
|
}
|
|
3191
3297
|
async function tombstoneDirectoryIfAbsent(input) {
|
|
3192
3298
|
if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
|
|
@@ -3207,9 +3313,10 @@ async function teardownLocalKeyMaterial(directory, identity) {
|
|
|
3207
3313
|
async function readIdentityFile(directory) {
|
|
3208
3314
|
return identityAt(directory);
|
|
3209
3315
|
}
|
|
3210
|
-
var RUNTIMES;
|
|
3316
|
+
var RUNTIMES, DESTROY_FLAG;
|
|
3211
3317
|
var init_unwire = __esm({
|
|
3212
3318
|
"src/unwire.ts"() {
|
|
3319
|
+
init_probes();
|
|
3213
3320
|
init_config_writers();
|
|
3214
3321
|
init_server_names();
|
|
3215
3322
|
init_signer_runtime();
|
|
@@ -3223,6 +3330,7 @@ var init_unwire = __esm({
|
|
|
3223
3330
|
{ runtime: "vscode-insiders", label: "VS Code Insiders MCP config", kind: "json", serverRoot: "servers" },
|
|
3224
3331
|
{ runtime: "claude-desktop", label: "Claude Desktop config", kind: "json", serverRoot: "mcpServers" }
|
|
3225
3332
|
];
|
|
3333
|
+
DESTROY_FLAG = "--destroy-key-material";
|
|
3226
3334
|
}
|
|
3227
3335
|
});
|
|
3228
3336
|
|
|
@@ -3234,6 +3342,174 @@ var init_rekey_messages = __esm({
|
|
|
3234
3342
|
}
|
|
3235
3343
|
});
|
|
3236
3344
|
|
|
3345
|
+
// src/prune-runtimes.ts
|
|
3346
|
+
var prune_runtimes_exports = {};
|
|
3347
|
+
__export(prune_runtimes_exports, {
|
|
3348
|
+
normalizeRuntimePath: () => normalizeRuntimePath,
|
|
3349
|
+
pruneSignerRuntimes: () => pruneSignerRuntimes,
|
|
3350
|
+
referencedRuntimeDirectories: () => referencedRuntimeDirectories,
|
|
3351
|
+
rollUpPruneLevel: () => rollUpPruneLevel,
|
|
3352
|
+
signerRuntimeRoot: () => signerRuntimeRoot
|
|
3353
|
+
});
|
|
3354
|
+
function signerRuntimeRoot(homeDir) {
|
|
3355
|
+
return path.join(homeDir, ".haven", "signer-runtime");
|
|
3356
|
+
}
|
|
3357
|
+
function classifyKey(key) {
|
|
3358
|
+
if (key.startsWith("override-")) return "override";
|
|
3359
|
+
if (key === MCP_RUNTIME_MANIFEST.signerVersion || /^\d+\.\d+\.\d+/.test(key)) return "version";
|
|
3360
|
+
return "unknown";
|
|
3361
|
+
}
|
|
3362
|
+
async function directoryBytes(directory) {
|
|
3363
|
+
let total = 0;
|
|
3364
|
+
const walk = async (dir) => {
|
|
3365
|
+
let entries = [];
|
|
3366
|
+
try {
|
|
3367
|
+
entries = await promises.readdir(dir, { withFileTypes: true });
|
|
3368
|
+
} catch {
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
for (const entry of entries) {
|
|
3372
|
+
const path$1 = path.join(dir, entry.name);
|
|
3373
|
+
if (entry.isDirectory()) await walk(path$1);
|
|
3374
|
+
else if (entry.isFile()) {
|
|
3375
|
+
try {
|
|
3376
|
+
total += (await promises.stat(path$1)).size;
|
|
3377
|
+
} catch {
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
};
|
|
3382
|
+
await walk(directory);
|
|
3383
|
+
return total;
|
|
3384
|
+
}
|
|
3385
|
+
async function normalizeRuntimePath(path$1) {
|
|
3386
|
+
const resolved = path.resolve(path$1).replace(/[\\/]+$/, "");
|
|
3387
|
+
try {
|
|
3388
|
+
return await promises.realpath(resolved);
|
|
3389
|
+
} catch {
|
|
3390
|
+
return resolved;
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
function escapeRegExp(text) {
|
|
3394
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3395
|
+
}
|
|
3396
|
+
async function referencedRuntimeDirectories(homeDir, credentialsDir) {
|
|
3397
|
+
const roots = /* @__PURE__ */ new Set([path.join(homeDir, ".haven", "agents")]);
|
|
3398
|
+
if (credentialsDir) roots.add(path.dirname(path.resolve(credentialsDir)));
|
|
3399
|
+
const referenced = /* @__PURE__ */ new Map();
|
|
3400
|
+
const runtimeRoot = await normalizeRuntimePath(signerRuntimeRoot(homeDir));
|
|
3401
|
+
const rootSpellings = [.../* @__PURE__ */ new Set([path.resolve(signerRuntimeRoot(homeDir)), runtimeRoot])];
|
|
3402
|
+
const wrapperRe = new RegExp(`(?:${rootSpellings.map(escapeRegExp).join("|")})[\\/]+([^\\/'"\\s]+)`, "g");
|
|
3403
|
+
const add = async (runtimeDirectory, by) => {
|
|
3404
|
+
const key = await normalizeRuntimePath(runtimeDirectory);
|
|
3405
|
+
const list = referenced.get(key) ?? [];
|
|
3406
|
+
if (!list.includes(by)) list.push(by);
|
|
3407
|
+
referenced.set(key, list);
|
|
3408
|
+
};
|
|
3409
|
+
for (const root of roots) {
|
|
3410
|
+
let entries = [];
|
|
3411
|
+
try {
|
|
3412
|
+
entries = await promises.readdir(root);
|
|
3413
|
+
} catch {
|
|
3414
|
+
continue;
|
|
3415
|
+
}
|
|
3416
|
+
for (const entry of entries) {
|
|
3417
|
+
const directory = path.join(root, entry);
|
|
3418
|
+
try {
|
|
3419
|
+
const sidecar = JSON.parse(await promises.readFile(path.join(directory, "signer-runtime.json"), "utf8"));
|
|
3420
|
+
if (typeof sidecar.runtime_directory === "string" && sidecar.runtime_directory.length > 0) {
|
|
3421
|
+
await add(sidecar.runtime_directory, directory);
|
|
3422
|
+
}
|
|
3423
|
+
} catch {
|
|
3424
|
+
}
|
|
3425
|
+
try {
|
|
3426
|
+
const wrapper = await promises.readFile(path.join(directory, "bin", "haven-signer.mjs"), "utf8");
|
|
3427
|
+
for (const match of wrapper.matchAll(wrapperRe)) await add(path.join(runtimeRoot, match[1]), directory);
|
|
3428
|
+
} catch {
|
|
3429
|
+
}
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
return referenced;
|
|
3433
|
+
}
|
|
3434
|
+
function rollUpPruneLevel(entries) {
|
|
3435
|
+
if (entries.some((e) => e.level === "failed")) return "failed";
|
|
3436
|
+
if (entries.some((e) => e.level === "advisory")) return "advisory";
|
|
3437
|
+
return "ok";
|
|
3438
|
+
}
|
|
3439
|
+
async function pruneSignerRuntimes(input, deps = {}) {
|
|
3440
|
+
const homeDir = deps.homeDir ?? os.homedir();
|
|
3441
|
+
const root = signerRuntimeRoot(homeDir);
|
|
3442
|
+
const measure = input.measure ?? true;
|
|
3443
|
+
const referenced = await referencedRuntimeDirectories(homeDir, deps.credentialsDir);
|
|
3444
|
+
const pin = await normalizeRuntimePath(path.join(root, MCP_RUNTIME_MANIFEST.signerVersion));
|
|
3445
|
+
const remove = deps.rm ?? (async (directory) => promises.rm(directory, { recursive: true, force: false }));
|
|
3446
|
+
const entries = [];
|
|
3447
|
+
let keys = [];
|
|
3448
|
+
try {
|
|
3449
|
+
keys = (await promises.readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
3450
|
+
} catch {
|
|
3451
|
+
return { version: 1, root, dryRun: input.dryRun, entries, removed: 0, reclaimedBytes: 0, level: "ok" };
|
|
3452
|
+
}
|
|
3453
|
+
let removed = 0;
|
|
3454
|
+
let reclaimedBytes = 0;
|
|
3455
|
+
for (const key of keys) {
|
|
3456
|
+
const directory = path.join(root, key);
|
|
3457
|
+
const normalized = await normalizeRuntimePath(directory);
|
|
3458
|
+
const referencedBy = referenced.get(normalized) ?? [];
|
|
3459
|
+
const kind = classifyKey(key);
|
|
3460
|
+
if (referencedBy.length > 0 || normalized === pin) {
|
|
3461
|
+
entries.push({
|
|
3462
|
+
directory,
|
|
3463
|
+
key,
|
|
3464
|
+
kind,
|
|
3465
|
+
bytes: 0,
|
|
3466
|
+
referencedBy,
|
|
3467
|
+
action: "kept",
|
|
3468
|
+
level: "ok",
|
|
3469
|
+
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)"
|
|
3470
|
+
});
|
|
3471
|
+
continue;
|
|
3472
|
+
}
|
|
3473
|
+
const bytes = measure ? await directoryBytes(directory) : 0;
|
|
3474
|
+
if (input.dryRun) {
|
|
3475
|
+
entries.push({
|
|
3476
|
+
directory,
|
|
3477
|
+
key,
|
|
3478
|
+
kind,
|
|
3479
|
+
bytes,
|
|
3480
|
+
referencedBy,
|
|
3481
|
+
action: "would_remove",
|
|
3482
|
+
level: "advisory",
|
|
3483
|
+
detail: `would remove \u2014 no credential directory names it (${kind}-keyed)`
|
|
3484
|
+
});
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
try {
|
|
3488
|
+
await remove(directory);
|
|
3489
|
+
removed += 1;
|
|
3490
|
+
reclaimedBytes += bytes;
|
|
3491
|
+
entries.push({ directory, key, kind, bytes, referencedBy, action: "removed", level: "ok", detail: `removed \u2014 no credential directory named it (${kind}-keyed)` });
|
|
3492
|
+
} catch (err) {
|
|
3493
|
+
entries.push({
|
|
3494
|
+
directory,
|
|
3495
|
+
key,
|
|
3496
|
+
kind,
|
|
3497
|
+
bytes,
|
|
3498
|
+
referencedBy,
|
|
3499
|
+
action: "failed",
|
|
3500
|
+
level: "failed",
|
|
3501
|
+
detail: `removal failed: ${err instanceof Error ? err.message : String(err)} \u2014 a signer process may still hold it open; stop it and re-run`
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
return { version: 1, root, dryRun: input.dryRun, entries, removed, reclaimedBytes, level: rollUpPruneLevel(entries) };
|
|
3506
|
+
}
|
|
3507
|
+
var init_prune_runtimes = __esm({
|
|
3508
|
+
"src/prune-runtimes.ts"() {
|
|
3509
|
+
init_runtime_manifest();
|
|
3510
|
+
}
|
|
3511
|
+
});
|
|
3512
|
+
|
|
3237
3513
|
// src/rekey.ts
|
|
3238
3514
|
var rekey_exports = {};
|
|
3239
3515
|
__export(rekey_exports, {
|
|
@@ -3517,9 +3793,32 @@ var init_rekey_restart = __esm({
|
|
|
3517
3793
|
var doctor_exports = {};
|
|
3518
3794
|
__export(doctor_exports, {
|
|
3519
3795
|
describeAccountAddressKey: () => describeAccountAddressKey,
|
|
3796
|
+
rollUpLevel: () => rollUpLevel,
|
|
3520
3797
|
runDoctor: () => runDoctor,
|
|
3521
3798
|
runRepair: () => runRepair
|
|
3522
3799
|
});
|
|
3800
|
+
function finalizeCheck(check) {
|
|
3801
|
+
return { ...check, ok: check.level !== "failed" };
|
|
3802
|
+
}
|
|
3803
|
+
function rollUpLevel(checks) {
|
|
3804
|
+
if (checks.some((check) => check.level === "failed")) return "failed";
|
|
3805
|
+
if (checks.some((check) => check.level === "advisory")) return "advisory";
|
|
3806
|
+
return "ok";
|
|
3807
|
+
}
|
|
3808
|
+
function runtimeFlagFor(runtime) {
|
|
3809
|
+
return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
|
|
3810
|
+
}
|
|
3811
|
+
async function resolveDoctorRuntime(input, directory) {
|
|
3812
|
+
const explicit = input.runtime.trim();
|
|
3813
|
+
if (explicit) return { runtime: explicit, origin: "flag" };
|
|
3814
|
+
if (directory) {
|
|
3815
|
+
const recorded = await readConnectOutcomeRuntime(directory);
|
|
3816
|
+
if (recorded !== null && normalizeRuntimeName(recorded)) {
|
|
3817
|
+
return { runtime: recorded, origin: "record" };
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
return { runtime: "", origin: "unknown" };
|
|
3821
|
+
}
|
|
3523
3822
|
async function discoverCredentialDirectory(homeDir, explicit) {
|
|
3524
3823
|
const root = explicit ? path.dirname(explicit) : path.join(homeDir, ".haven", "agents");
|
|
3525
3824
|
let entries = [];
|
|
@@ -3580,13 +3879,16 @@ function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bar
|
|
|
3580
3879
|
return isPrimary && Boolean(identity?.hosted_mcp_url && configText.includes(identity.hosted_mcp_url));
|
|
3581
3880
|
}
|
|
3582
3881
|
function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
|
|
3882
|
+
return finalizeCheck(rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug));
|
|
3883
|
+
}
|
|
3884
|
+
function rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug) {
|
|
3583
3885
|
const label = "Pending re-key";
|
|
3584
3886
|
const nameFlag = slug ? ` --name ${slug}` : "";
|
|
3585
3887
|
if (status.state === "unreadable") {
|
|
3586
3888
|
return {
|
|
3587
3889
|
id: "rekey_pending",
|
|
3588
3890
|
label,
|
|
3589
|
-
|
|
3891
|
+
level: "failed",
|
|
3590
3892
|
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.`,
|
|
3591
3893
|
repair: `Delete ${status.path}, then start again: ${RERUN} --rekey${nameFlag}`
|
|
3592
3894
|
};
|
|
@@ -3598,9 +3900,9 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
|
|
|
3598
3900
|
return {
|
|
3599
3901
|
id: "rekey_pending",
|
|
3600
3902
|
label,
|
|
3601
|
-
|
|
3903
|
+
level: "failed",
|
|
3602
3904
|
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." : ""),
|
|
3603
|
-
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
|
|
3905
|
+
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)}`
|
|
3604
3906
|
};
|
|
3605
3907
|
}
|
|
3606
3908
|
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.";
|
|
@@ -3608,7 +3910,7 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
|
|
|
3608
3910
|
return {
|
|
3609
3911
|
id: "rekey_pending",
|
|
3610
3912
|
label,
|
|
3611
|
-
|
|
3913
|
+
level: "failed",
|
|
3612
3914
|
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,
|
|
3613
3915
|
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.`
|
|
3614
3916
|
};
|
|
@@ -3616,7 +3918,7 @@ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
|
|
|
3616
3918
|
return {
|
|
3617
3919
|
id: "rekey_pending",
|
|
3618
3920
|
label,
|
|
3619
|
-
|
|
3921
|
+
level: "ok",
|
|
3620
3922
|
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
|
|
3621
3923
|
};
|
|
3622
3924
|
}
|
|
@@ -3644,13 +3946,13 @@ async function runtimeSpecOverrideCheck(directory, sidecar, env) {
|
|
|
3644
3946
|
if (shell) facts.push(shell);
|
|
3645
3947
|
if (facts.length === 0) return void 0;
|
|
3646
3948
|
const variables = Object.values(RUNTIME_SPEC_ENV).join(" / ");
|
|
3647
|
-
return {
|
|
3949
|
+
return finalizeCheck({
|
|
3648
3950
|
id: "runtime_spec_override",
|
|
3649
3951
|
label: "Runtime spec override",
|
|
3650
|
-
|
|
3952
|
+
level: "failed",
|
|
3651
3953
|
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(". ")}.`,
|
|
3652
3954
|
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.`
|
|
3653
|
-
};
|
|
3955
|
+
});
|
|
3654
3956
|
}
|
|
3655
3957
|
async function readMcpSidecarOverride(directory) {
|
|
3656
3958
|
try {
|
|
@@ -3677,6 +3979,13 @@ function describeAccountAddressKey(identity, signerFile) {
|
|
|
3677
3979
|
return "no account address stored";
|
|
3678
3980
|
}
|
|
3679
3981
|
async function checksForAgent(entry, input, deps) {
|
|
3982
|
+
const verdicts = await verdictsForAgent(entry, input, deps);
|
|
3983
|
+
return {
|
|
3984
|
+
checks: verdicts.checks.map(finalizeCheck),
|
|
3985
|
+
...verdicts.signerCapabilities ? { signerCapabilities: verdicts.signerCapabilities } : {}
|
|
3986
|
+
};
|
|
3987
|
+
}
|
|
3988
|
+
async function verdictsForAgent(entry, input, deps) {
|
|
3680
3989
|
const { directory, identity, sidecar } = entry;
|
|
3681
3990
|
const checks = [];
|
|
3682
3991
|
let signerCapabilities;
|
|
@@ -3691,7 +4000,7 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3691
4000
|
checks.push({
|
|
3692
4001
|
id: "credentials",
|
|
3693
4002
|
label: "Agent credentials",
|
|
3694
|
-
ok:
|
|
4003
|
+
level: credentialsOk ? "ok" : "failed",
|
|
3695
4004
|
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.",
|
|
3696
4005
|
...credentialsOk ? {} : { repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` }
|
|
3697
4006
|
});
|
|
@@ -3699,9 +4008,9 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3699
4008
|
checks.push({
|
|
3700
4009
|
id: "signer_runtime",
|
|
3701
4010
|
label: "Signer runtime (preinstalled wrapper)",
|
|
3702
|
-
|
|
4011
|
+
level: "failed",
|
|
3703
4012
|
detail: "No signer-runtime.json sidecar \u2014 the pinned signer runtime was never prepared (or a pre-#1586 npx config).",
|
|
3704
|
-
repair: `Run: ${RERUN} --doctor --repair
|
|
4013
|
+
repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
|
|
3705
4014
|
});
|
|
3706
4015
|
} else if (sidecar.runtime_spec_override) {
|
|
3707
4016
|
const matches = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
|
|
@@ -3711,9 +4020,9 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3711
4020
|
checks.push({
|
|
3712
4021
|
id: "signer_runtime",
|
|
3713
4022
|
label: "Signer runtime (preinstalled wrapper)",
|
|
3714
|
-
ok:
|
|
4023
|
+
level: matches ? "ok" : "failed",
|
|
3715
4024
|
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.`,
|
|
3716
|
-
...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair
|
|
4025
|
+
...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)} with the same HAVEN_*_SPEC variables set.` }
|
|
3717
4026
|
});
|
|
3718
4027
|
} else {
|
|
3719
4028
|
const intact = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
|
|
@@ -3725,9 +4034,9 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3725
4034
|
checks.push({
|
|
3726
4035
|
id: "signer_runtime",
|
|
3727
4036
|
label: "Signer runtime (preinstalled wrapper)",
|
|
3728
|
-
ok,
|
|
4037
|
+
level: ok ? "ok" : intact ? "advisory" : "failed",
|
|
3729
4038
|
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.`,
|
|
3730
|
-
...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair
|
|
4039
|
+
...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
|
|
3731
4040
|
});
|
|
3732
4041
|
}
|
|
3733
4042
|
const overrideCheck = await runtimeSpecOverrideCheck(directory, sidecar, deps.env ?? process.env);
|
|
@@ -3738,7 +4047,7 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3738
4047
|
checks.push({
|
|
3739
4048
|
id: "hosted_mcp",
|
|
3740
4049
|
label: "Hosted Haven MCP",
|
|
3741
|
-
|
|
4050
|
+
level: probe.status === "ok" ? "ok" : "failed",
|
|
3742
4051
|
detail: probe.status === "ok" ? `MCP tools endpoint is reachable (${hostedUrl}).` : `MCP tools endpoint probe failed: ${probe.status} (${hostedUrl}).`,
|
|
3743
4052
|
...probe.status === "ok" ? {} : {
|
|
3744
4053
|
repair: "Check network access and runtime configuration for the hosted MCP URL, then re-run --doctor."
|
|
@@ -3748,7 +4057,7 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3748
4057
|
checks.push({
|
|
3749
4058
|
id: "hosted_mcp",
|
|
3750
4059
|
label: "Hosted Haven MCP",
|
|
3751
|
-
|
|
4060
|
+
level: "failed",
|
|
3752
4061
|
detail: "No stored API key / hosted MCP URL to probe with.",
|
|
3753
4062
|
repair: `Re-run the full setup: ${RERUN} --setup <token>.`
|
|
3754
4063
|
});
|
|
@@ -3766,15 +4075,15 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3766
4075
|
checks.push({
|
|
3767
4076
|
id: "identity_match",
|
|
3768
4077
|
label: "Hosted identity matches the local signing key",
|
|
3769
|
-
|
|
4078
|
+
level: "failed",
|
|
3770
4079
|
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.`,
|
|
3771
|
-
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
|
|
4080
|
+
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)}`
|
|
3772
4081
|
});
|
|
3773
4082
|
} else if (!localDelegate) {
|
|
3774
4083
|
checks.push({
|
|
3775
4084
|
id: "identity_match",
|
|
3776
4085
|
label: "Hosted identity matches the local signing key",
|
|
3777
|
-
|
|
4086
|
+
level: "failed",
|
|
3778
4087
|
detail: "signer.json holds no delegate_address to compare against the hosted identity.",
|
|
3779
4088
|
repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.`
|
|
3780
4089
|
});
|
|
@@ -3783,7 +4092,7 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3783
4092
|
checks.push({
|
|
3784
4093
|
id: "identity_match",
|
|
3785
4094
|
label: "Hosted identity matches the local signing key",
|
|
3786
|
-
ok:
|
|
4095
|
+
level: same ? "ok" : "failed",
|
|
3787
4096
|
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.`,
|
|
3788
4097
|
...same ? {} : {
|
|
3789
4098
|
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.`
|
|
@@ -3801,7 +4110,7 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3801
4110
|
checks.push({
|
|
3802
4111
|
id: "signer_process",
|
|
3803
4112
|
label: "Signer stdio handshake",
|
|
3804
|
-
|
|
4113
|
+
level: "failed",
|
|
3805
4114
|
detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
|
|
3806
4115
|
repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
|
|
3807
4116
|
});
|
|
@@ -3818,18 +4127,18 @@ async function checksForAgent(entry, input, deps) {
|
|
|
3818
4127
|
checks.push({
|
|
3819
4128
|
id: "signer_process",
|
|
3820
4129
|
label: "Signer stdio handshake",
|
|
3821
|
-
|
|
4130
|
+
level: probe.status === "ok" ? "ok" : "failed",
|
|
3822
4131
|
detail: probe.status === "ok" ? `Signer started, listed ${probe.toolNames?.length ?? 0} tools${probe.serverInfo?.version ? ` (v${probe.serverInfo.version})` : ""}.${compatDetail}` : `Handshake failed: ${probe.status}.`,
|
|
3823
|
-
...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair
|
|
4132
|
+
...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
|
|
3824
4133
|
});
|
|
3825
4134
|
}
|
|
3826
4135
|
} else {
|
|
3827
4136
|
checks.push({
|
|
3828
4137
|
id: "signer_process",
|
|
3829
4138
|
label: "Signer stdio handshake",
|
|
3830
|
-
|
|
4139
|
+
level: "failed",
|
|
3831
4140
|
detail: "Skipped \u2014 no prepared signer runtime to probe.",
|
|
3832
|
-
repair: `Run: ${RERUN} --doctor --repair
|
|
4141
|
+
repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
|
|
3833
4142
|
});
|
|
3834
4143
|
}
|
|
3835
4144
|
return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
|
|
@@ -3839,7 +4148,11 @@ async function runDoctor(input, deps = {}) {
|
|
|
3839
4148
|
const checks = [];
|
|
3840
4149
|
let signerCapabilities;
|
|
3841
4150
|
const { directory, others, parkedOnly } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
|
|
3842
|
-
const
|
|
4151
|
+
const resolution = await resolveDoctorRuntime(input, directory);
|
|
4152
|
+
const runtime = resolution.runtime;
|
|
4153
|
+
const input2 = { ...input, runtime };
|
|
4154
|
+
const normalizedRuntime = normalizeRuntimeName(input2.runtime);
|
|
4155
|
+
const configPath = runtimeConfigPathFor(normalizedRuntime ?? input2.runtime, homeDir);
|
|
3843
4156
|
let configText = null;
|
|
3844
4157
|
if (configPath !== null) {
|
|
3845
4158
|
try {
|
|
@@ -3876,7 +4189,7 @@ async function runDoctor(input, deps = {}) {
|
|
|
3876
4189
|
// A tombstone is a deliberate record and outranks the discovery tell:
|
|
3877
4190
|
// a retired directory that also holds a parked key stays `retired`.
|
|
3878
4191
|
classification: tombstone ? "retired" : parkedOnly.has(dir) ? "parked" : "orphaned",
|
|
3879
|
-
checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0,
|
|
4192
|
+
checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)] : [],
|
|
3880
4193
|
...rekeyPending ? { rekeyPending } : {}
|
|
3881
4194
|
});
|
|
3882
4195
|
continue;
|
|
@@ -3891,11 +4204,11 @@ async function runDoctor(input, deps = {}) {
|
|
|
3891
4204
|
...rekeyPending ? { rekeyPending } : {}
|
|
3892
4205
|
};
|
|
3893
4206
|
if (wired) {
|
|
3894
|
-
const result = await checksForAgent({ directory: dir, identity, sidecar },
|
|
4207
|
+
const result = await checksForAgent({ directory: dir, identity, sidecar }, input2, deps);
|
|
3895
4208
|
entry.checks = result.checks;
|
|
3896
4209
|
capabilitiesByDirectory.set(dir, result.signerCapabilities);
|
|
3897
4210
|
} else if (rekeyPending) {
|
|
3898
|
-
entry.checks = [rekeyPendingCheck(rekeyPending, void 0,
|
|
4211
|
+
entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)];
|
|
3899
4212
|
}
|
|
3900
4213
|
inventory.push(entry);
|
|
3901
4214
|
}
|
|
@@ -3910,7 +4223,7 @@ async function runDoctor(input, deps = {}) {
|
|
|
3910
4223
|
checks.push({
|
|
3911
4224
|
id: "credentials",
|
|
3912
4225
|
label: "Agent credentials",
|
|
3913
|
-
|
|
4226
|
+
level: "failed",
|
|
3914
4227
|
detail: "No agent credential directory with an identity.json under ~/.haven/agents.",
|
|
3915
4228
|
repair: `Run the full setup once: ${RERUN} --setup <token from the Haven dashboard>.`
|
|
3916
4229
|
});
|
|
@@ -3920,7 +4233,7 @@ async function runDoctor(input, deps = {}) {
|
|
|
3920
4233
|
if (!primaryChecksById.has("credentials")) {
|
|
3921
4234
|
const result = await checksForAgent(
|
|
3922
4235
|
{ directory: primaryDirectory, identity: primaryIdentity, sidecar: primarySidecar },
|
|
3923
|
-
|
|
4236
|
+
input2,
|
|
3924
4237
|
deps
|
|
3925
4238
|
);
|
|
3926
4239
|
signerCapabilities = result.signerCapabilities;
|
|
@@ -3931,20 +4244,37 @@ async function runDoctor(input, deps = {}) {
|
|
|
3931
4244
|
if (check) checks.push(check);
|
|
3932
4245
|
}
|
|
3933
4246
|
}
|
|
3934
|
-
|
|
4247
|
+
const runtimeOwnsNoConfig = normalizedRuntime !== null && configPath === null;
|
|
4248
|
+
if (configPath === null && input2.runtime !== "" && normalizedRuntime === null) {
|
|
3935
4249
|
checks.push({
|
|
3936
4250
|
id: "runtime_config",
|
|
3937
4251
|
label: "Runtime MCP config",
|
|
3938
|
-
|
|
3939
|
-
detail: `Runtime '${
|
|
4252
|
+
level: "failed",
|
|
4253
|
+
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(", ")}.`,
|
|
4254
|
+
repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
|
|
4255
|
+
});
|
|
4256
|
+
} else if (configPath === null && input2.runtime === "") {
|
|
4257
|
+
checks.push({
|
|
4258
|
+
id: "runtime_config",
|
|
4259
|
+
label: "Runtime MCP config",
|
|
4260
|
+
level: "failed",
|
|
4261
|
+
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(", ")}.`,
|
|
4262
|
+
repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
|
|
4263
|
+
});
|
|
4264
|
+
} else if (configPath === null) {
|
|
4265
|
+
checks.push({
|
|
4266
|
+
id: "runtime_config",
|
|
4267
|
+
label: "Runtime MCP config",
|
|
4268
|
+
level: "ok",
|
|
4269
|
+
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.`
|
|
3940
4270
|
});
|
|
3941
4271
|
} else if (configText === null) {
|
|
3942
4272
|
checks.push({
|
|
3943
4273
|
id: "runtime_config",
|
|
3944
4274
|
label: "Runtime MCP config",
|
|
3945
|
-
|
|
4275
|
+
level: "failed",
|
|
3946
4276
|
detail: `No runtime config at ${configPath}.`,
|
|
3947
|
-
repair: `Run: ${RERUN} --doctor --repair
|
|
4277
|
+
repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}`
|
|
3948
4278
|
});
|
|
3949
4279
|
} else {
|
|
3950
4280
|
const primaryIdentity = await readIdentity(primaryDirectory ?? "");
|
|
@@ -3956,9 +4286,9 @@ async function runDoctor(input, deps = {}) {
|
|
|
3956
4286
|
checks.push({
|
|
3957
4287
|
id: "runtime_config",
|
|
3958
4288
|
label: "Runtime MCP config",
|
|
3959
|
-
ok,
|
|
4289
|
+
level: ok ? "ok" : "failed",
|
|
3960
4290
|
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)" : ""}.`,
|
|
3961
|
-
...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair
|
|
4291
|
+
...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}` }
|
|
3962
4292
|
});
|
|
3963
4293
|
}
|
|
3964
4294
|
for (const id of ["hosted_mcp", "identity_match", "rekey_pending"]) {
|
|
@@ -4005,16 +4335,44 @@ async function runDoctor(input, deps = {}) {
|
|
|
4005
4335
|
);
|
|
4006
4336
|
}
|
|
4007
4337
|
const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
|
|
4338
|
+
const classificationUnreliable = configText === null && runtimeOwnsNoConfig;
|
|
4339
|
+
const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
|
|
4008
4340
|
checks.push({
|
|
4009
4341
|
id: "superseded_agents",
|
|
4010
4342
|
label: "Superseded agent credentials",
|
|
4011
|
-
|
|
4012
|
-
detail:
|
|
4013
|
-
...
|
|
4343
|
+
level: supersededLevel,
|
|
4344
|
+
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("; ")}.`,
|
|
4345
|
+
...supersededLevel === "failed" ? {
|
|
4014
4346
|
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.`
|
|
4347
|
+
} : supersededLevel === "advisory" ? {
|
|
4348
|
+
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.`
|
|
4015
4349
|
} : {}
|
|
4016
4350
|
});
|
|
4017
4351
|
}
|
|
4352
|
+
const bindings = (await Promise.all(
|
|
4353
|
+
inventory.filter((entry) => entry.classification !== "retired").map(async (entry) => ({ entry, binding: await readMcpServerBinding(entry.directory) }))
|
|
4354
|
+
)).filter((item) => item.binding !== null);
|
|
4355
|
+
const byName = /* @__PURE__ */ new Map();
|
|
4356
|
+
for (const item of bindings) {
|
|
4357
|
+
const list = byName.get(item.binding.server_name) ?? [];
|
|
4358
|
+
list.push(item);
|
|
4359
|
+
byName.set(item.binding.server_name, list);
|
|
4360
|
+
}
|
|
4361
|
+
const rebound = [...byName.entries()].filter(([, items]) => items.length > 1);
|
|
4362
|
+
if (rebound.length > 0) {
|
|
4363
|
+
const parts = rebound.map(([name, items]) => {
|
|
4364
|
+
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);
|
|
4365
|
+
const backends = new Set(ordered.map((i) => withoutUserinfo(i.binding.api_url)));
|
|
4366
|
+
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)" : "");
|
|
4367
|
+
});
|
|
4368
|
+
checks.push({
|
|
4369
|
+
id: "mcp_server_name_rebound",
|
|
4370
|
+
label: "MCP server names bound more than once",
|
|
4371
|
+
level: "advisory",
|
|
4372
|
+
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.`,
|
|
4373
|
+
repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
|
|
4374
|
+
});
|
|
4375
|
+
}
|
|
4018
4376
|
const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
|
|
4019
4377
|
if (parkedElsewhere.length > 0) {
|
|
4020
4378
|
const abandoned = parkedElsewhere.filter((item) => item.pending.state !== "pending");
|
|
@@ -4022,7 +4380,7 @@ async function runDoctor(input, deps = {}) {
|
|
|
4022
4380
|
checks.push({
|
|
4023
4381
|
id: "rekey_pending_elsewhere",
|
|
4024
4382
|
label: "Parked re-keys in other credential directories",
|
|
4025
|
-
|
|
4383
|
+
level: abandoned.length === 0 ? "ok" : "failed",
|
|
4026
4384
|
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(", ")}.`,
|
|
4027
4385
|
...abandoned.length > 0 ? {
|
|
4028
4386
|
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."
|
|
@@ -4031,20 +4389,34 @@ async function runDoctor(input, deps = {}) {
|
|
|
4031
4389
|
}
|
|
4032
4390
|
const signerProcess = primaryChecksById.get("signer_process");
|
|
4033
4391
|
if (signerProcess) checks.push(signerProcess);
|
|
4034
|
-
const
|
|
4392
|
+
const prune = await (deps.pruneSignerRuntimes ?? pruneSignerRuntimes)({ dryRun: true, measure: false }, { homeDir, credentialsDir: input.credentialsDir });
|
|
4393
|
+
const unused = prune.entries.filter((entry) => entry.action === "would_remove");
|
|
4394
|
+
if (unused.length > 0) {
|
|
4395
|
+
checks.push({
|
|
4396
|
+
id: "signer_runtime_unused",
|
|
4397
|
+
label: "Unused signer-runtime directories",
|
|
4398
|
+
level: "advisory",
|
|
4399
|
+
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).`,
|
|
4400
|
+
repair: `Run: ${RERUN} --prune-signer-runtimes (add --dry-run to list only).`
|
|
4401
|
+
});
|
|
4402
|
+
}
|
|
4403
|
+
const restart = restartRequiredForRuntime(input2.runtime, deps.env);
|
|
4035
4404
|
checks.push({
|
|
4036
4405
|
id: "restart",
|
|
4037
4406
|
label: "Runtime restart",
|
|
4038
|
-
|
|
4039
|
-
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."
|
|
4407
|
+
level: "ok",
|
|
4408
|
+
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."
|
|
4040
4409
|
});
|
|
4041
|
-
const
|
|
4410
|
+
const wiredChecks = inventory.filter((entry) => entry.classification === "wired").flatMap((entry) => entry.checks);
|
|
4411
|
+
const finalChecks = checks.map(finalizeCheck);
|
|
4412
|
+
const level = rollUpLevel([...finalChecks, ...wiredChecks]);
|
|
4042
4413
|
return {
|
|
4043
4414
|
version: 1,
|
|
4044
|
-
ok:
|
|
4045
|
-
|
|
4415
|
+
ok: level !== "failed",
|
|
4416
|
+
level,
|
|
4417
|
+
runtime: input2.runtime,
|
|
4046
4418
|
credentialDirectory: primaryDirectory,
|
|
4047
|
-
checks,
|
|
4419
|
+
checks: finalChecks,
|
|
4048
4420
|
agents: inventory,
|
|
4049
4421
|
...signerCapabilities ? { signerCapabilities } : {}
|
|
4050
4422
|
};
|
|
@@ -4062,6 +4434,21 @@ async function runRepair(input, deps = {}) {
|
|
|
4062
4434
|
messages: [`No agent credentials found to repair \u2014 run the full setup: ${RERUN} --setup <token>.`]
|
|
4063
4435
|
};
|
|
4064
4436
|
}
|
|
4437
|
+
const resolution = await resolveDoctorRuntime(input, directory);
|
|
4438
|
+
if (resolution.origin === "record") {
|
|
4439
|
+
messages.push(`Runtime not given \u2014 resolved '${resolution.runtime}' from ${path.join(directory, CONNECT_OUTCOME_FILENAME)}.`);
|
|
4440
|
+
}
|
|
4441
|
+
const runtime = resolution.runtime;
|
|
4442
|
+
const input2 = { ...input, runtime };
|
|
4443
|
+
if (runtime === "") {
|
|
4444
|
+
return {
|
|
4445
|
+
ok: false,
|
|
4446
|
+
messages: [
|
|
4447
|
+
"Runtime is unknown \u2014 no runtime flag was given and the connector record carries no resolvable runtime.",
|
|
4448
|
+
`Repair rewrites the runtime config, so it will not guess. Re-run repair naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
|
|
4449
|
+
]
|
|
4450
|
+
};
|
|
4451
|
+
}
|
|
4065
4452
|
let identity;
|
|
4066
4453
|
try {
|
|
4067
4454
|
identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
|
|
@@ -4071,7 +4458,7 @@ async function runRepair(input, deps = {}) {
|
|
|
4071
4458
|
if (!identity.api_key || !(identity.hosted_mcp_url || identity.api_url)) {
|
|
4072
4459
|
return { ok: false, messages: ["identity.json lacks the stored API key / hosted URL \u2014 re-run the full setup."] };
|
|
4073
4460
|
}
|
|
4074
|
-
const configPath = runtimeConfigPathFor(
|
|
4461
|
+
const configPath = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir);
|
|
4075
4462
|
if (configPath) {
|
|
4076
4463
|
try {
|
|
4077
4464
|
const existing = await promises.readFile(configPath, "utf8");
|
|
@@ -4098,7 +4485,10 @@ async function runRepair(input, deps = {}) {
|
|
|
4098
4485
|
const names = serverNamesFor(serverName);
|
|
4099
4486
|
messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
|
|
4100
4487
|
const configResult = await writeRuntimeConfig({
|
|
4101
|
-
|
|
4488
|
+
// Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
|
|
4489
|
+
// switches on the id, and the raw alias fell to its "manual runtime" arm
|
|
4490
|
+
// — a repair that reported success while writing nothing.
|
|
4491
|
+
runtime: normalizeRuntimeName(input2.runtime) ?? input2.runtime,
|
|
4102
4492
|
hostedMcpUrl: identity.hosted_mcp_url ?? `${identity.api_url}/mcp`,
|
|
4103
4493
|
apiKey: identity.api_key,
|
|
4104
4494
|
identityPath: path.join(directory, "identity.json"),
|
|
@@ -4116,6 +4506,7 @@ async function runRepair(input, deps = {}) {
|
|
|
4116
4506
|
var RERUN;
|
|
4117
4507
|
var init_doctor = __esm({
|
|
4118
4508
|
"src/doctor.ts"() {
|
|
4509
|
+
init_prune_runtimes();
|
|
4119
4510
|
init_runtime_manifest();
|
|
4120
4511
|
init_probes();
|
|
4121
4512
|
init_signer_runtime();
|
|
@@ -4429,7 +4820,7 @@ async function pathExists2(path) {
|
|
|
4429
4820
|
init_unwire();
|
|
4430
4821
|
init_local_mcp_runtime();
|
|
4431
4822
|
init_runtime_manifest();
|
|
4432
|
-
var CONNECTOR_VERSION = "0.
|
|
4823
|
+
var CONNECTOR_VERSION = "0.4.0-alpha.0";
|
|
4433
4824
|
var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
|
|
4434
4825
|
var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
|
|
4435
4826
|
function failureOutcomeFor(runtimeHint, error) {
|
|
@@ -4556,6 +4947,27 @@ async function executeConnect(options, deps, trace) {
|
|
|
4556
4947
|
}
|
|
4557
4948
|
}
|
|
4558
4949
|
}
|
|
4950
|
+
const existingAgents = await listExistingKeyedAgents(options.credentialsDir);
|
|
4951
|
+
if (existingAgents.length > 0) {
|
|
4952
|
+
log("");
|
|
4953
|
+
log(
|
|
4954
|
+
`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."
|
|
4955
|
+
);
|
|
4956
|
+
}
|
|
4957
|
+
const hostedNameForRun = serverNamesFor(serverName).hosted;
|
|
4958
|
+
let reboundFrom;
|
|
4959
|
+
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);
|
|
4960
|
+
const newest = holders[0];
|
|
4961
|
+
if (newest) {
|
|
4962
|
+
const { binding, directory } = newest;
|
|
4963
|
+
const backendChanged = withoutUserinfo(binding.api_url) !== withoutUserinfo(options.apiBaseUrl);
|
|
4964
|
+
const previousApiUrl = withoutUserinfo(binding.api_url);
|
|
4965
|
+
reboundFrom = { server_name: binding.server_name, agent_id: binding.agent_id, api_url: previousApiUrl, bound_at: binding.bound_at, backend_changed: backendChanged };
|
|
4966
|
+
const beingReplaced = replacing?.superseded.some((entry) => entry.directory === directory) ?? false;
|
|
4967
|
+
log(
|
|
4968
|
+
`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." : ".")
|
|
4969
|
+
);
|
|
4970
|
+
}
|
|
4559
4971
|
const localKey = generateKey();
|
|
4560
4972
|
const localApiKey = generateLocalApiKey();
|
|
4561
4973
|
log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
|
|
@@ -4619,6 +5031,21 @@ async function executeConnect(options, deps, trace) {
|
|
|
4619
5031
|
warn: log
|
|
4620
5032
|
});
|
|
4621
5033
|
trace.directory = credentialPaths.directory;
|
|
5034
|
+
try {
|
|
5035
|
+
const bindingRecord = {
|
|
5036
|
+
version: 1,
|
|
5037
|
+
server_name: hostedNameForRun,
|
|
5038
|
+
signer_name: serverNamesFor(serverName).signer,
|
|
5039
|
+
agent_id: registration.agent_id,
|
|
5040
|
+
api_url: withoutUserinfo(options.apiBaseUrl),
|
|
5041
|
+
// never persist `user:pass@` (#3154 doc review r2)
|
|
5042
|
+
...registration.hosted_mcp_url ? { hosted_mcp_url: registration.hosted_mcp_url } : {},
|
|
5043
|
+
bound_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
5044
|
+
};
|
|
5045
|
+
await writeMcpServerBinding(credentialPaths.directory, bindingRecord);
|
|
5046
|
+
} catch {
|
|
5047
|
+
log("Could not record the MCP server-name binding locally (non-fatal; the next setup cannot see this binding).");
|
|
5048
|
+
}
|
|
4622
5049
|
log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
|
|
4623
5050
|
log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
|
|
4624
5051
|
log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
|
|
@@ -4698,6 +5125,7 @@ async function executeConnect(options, deps, trace) {
|
|
|
4698
5125
|
replacedBy: registration.agent_id
|
|
4699
5126
|
});
|
|
4700
5127
|
await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
|
|
5128
|
+
await clearMcpServerBinding(entry.directory);
|
|
4701
5129
|
retiredAgentIds.push(entry.agentId);
|
|
4702
5130
|
log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
|
|
4703
5131
|
} catch (err) {
|
|
@@ -4767,6 +5195,8 @@ async function executeConnect(options, deps, trace) {
|
|
|
4767
5195
|
supersededAgentIds,
|
|
4768
5196
|
supersededAgentsRetiredLocally,
|
|
4769
5197
|
...replacing ? { retiredAgentIds } : {},
|
|
5198
|
+
existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
|
|
5199
|
+
...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
|
|
4770
5200
|
setupChallengeExpiresAt: setup.challenge.expires_at,
|
|
4771
5201
|
approvalRequired: registration.agent_status === "pending_approval",
|
|
4772
5202
|
approvalUrl: registration.approval_url
|
|
@@ -4831,6 +5261,10 @@ function completionOutcome(input) {
|
|
|
4831
5261
|
superseded_agent_ids: input.supersededAgentIds ?? [],
|
|
4832
5262
|
...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
|
|
4833
5263
|
...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
|
|
5264
|
+
// #3122: always emitted on a completed run (empty list included), for the
|
|
5265
|
+
// same reason as superseded_agent_ids above.
|
|
5266
|
+
existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
|
|
5267
|
+
...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
|
|
4834
5268
|
...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
|
|
4835
5269
|
...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
|
|
4836
5270
|
};
|
|
@@ -5015,7 +5449,7 @@ function describeWaitBound(timeoutMs) {
|
|
|
5015
5449
|
async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
5016
5450
|
const intervalMs = options.intervalMs ?? 5e3;
|
|
5017
5451
|
const timeoutMs = options.timeoutMs ?? 18e4;
|
|
5018
|
-
const sleep = options.sleep ?? ((ms) => new Promise((
|
|
5452
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)));
|
|
5019
5453
|
const maxPolls = Math.max(1, Math.floor(timeoutMs / intervalMs));
|
|
5020
5454
|
const remindEvery = Math.max(1, Math.floor(3e4 / intervalMs));
|
|
5021
5455
|
let waitingAnnounced = false;
|
|
@@ -5106,6 +5540,32 @@ function activationInstructionWithWhy(profile) {
|
|
|
5106
5540
|
return profile.activationInstruction;
|
|
5107
5541
|
}
|
|
5108
5542
|
var RERUN_HINT = sdk.connectorRerunCommand();
|
|
5543
|
+
async function listExistingKeyedAgents(baseDir) {
|
|
5544
|
+
const root = defaultCredentialRoot(baseDir);
|
|
5545
|
+
let entries = [];
|
|
5546
|
+
try {
|
|
5547
|
+
entries = await promises.readdir(root);
|
|
5548
|
+
} catch {
|
|
5549
|
+
return [];
|
|
5550
|
+
}
|
|
5551
|
+
const out = [];
|
|
5552
|
+
for (const entry of entries) {
|
|
5553
|
+
const directory = path.join(root, entry);
|
|
5554
|
+
try {
|
|
5555
|
+
const identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
|
|
5556
|
+
if (typeof identity.api_key !== "string" || identity.api_key.length === 0) continue;
|
|
5557
|
+
let agent = {};
|
|
5558
|
+
try {
|
|
5559
|
+
agent = JSON.parse(await promises.readFile(path.join(directory, "agent.json"), "utf8"));
|
|
5560
|
+
} catch {
|
|
5561
|
+
}
|
|
5562
|
+
const { accountAddress } = readStoredAccountAddress(identity, agent);
|
|
5563
|
+
out.push({ agentId: typeof identity.agent_id === "string" ? identity.agent_id : entry, directory, accountAddress: accountAddress ?? null });
|
|
5564
|
+
} catch {
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
return out;
|
|
5568
|
+
}
|
|
5109
5569
|
async function listOtherAgentIds(baseDir, currentDirectory) {
|
|
5110
5570
|
const root = defaultCredentialRoot(baseDir);
|
|
5111
5571
|
let entries = [];
|
|
@@ -5154,6 +5614,9 @@ function parseArgs(argv, env = process.env) {
|
|
|
5154
5614
|
let tombstoneReason;
|
|
5155
5615
|
let tombstoneReplacedBy;
|
|
5156
5616
|
let unwire;
|
|
5617
|
+
let destroyKeyMaterial = false;
|
|
5618
|
+
let pruneSignerRuntimes2 = false;
|
|
5619
|
+
let dryRun = false;
|
|
5157
5620
|
let unwireDir;
|
|
5158
5621
|
let replace = false;
|
|
5159
5622
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -5181,6 +5644,12 @@ function parseArgs(argv, env = process.env) {
|
|
|
5181
5644
|
unwireDir = next;
|
|
5182
5645
|
i += 1;
|
|
5183
5646
|
}
|
|
5647
|
+
} else if (arg === "--destroy-key-material") {
|
|
5648
|
+
destroyKeyMaterial = true;
|
|
5649
|
+
} else if (arg === "--prune-signer-runtimes") {
|
|
5650
|
+
pruneSignerRuntimes2 = true;
|
|
5651
|
+
} else if (arg === "--dry-run") {
|
|
5652
|
+
dryRun = true;
|
|
5184
5653
|
} else if (arg === "--reason") {
|
|
5185
5654
|
tombstoneReason = requireValue(argv, ++i, arg);
|
|
5186
5655
|
} else if (arg === "--replaced-by") {
|
|
@@ -5223,7 +5692,7 @@ function parseArgs(argv, env = process.env) {
|
|
|
5223
5692
|
return { options, help, json, doctor, repair, tombstone, rekey };
|
|
5224
5693
|
}
|
|
5225
5694
|
if (replace) {
|
|
5226
|
-
if (rekeyPhase || tombstoneDir || unwire || doctor || repair) {
|
|
5695
|
+
if (rekeyPhase || tombstoneDir || unwire || doctor || repair || pruneSignerRuntimes2) {
|
|
5227
5696
|
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.");
|
|
5228
5697
|
}
|
|
5229
5698
|
if (options.serverName) {
|
|
@@ -5233,6 +5702,22 @@ function parseArgs(argv, env = process.env) {
|
|
|
5233
5702
|
}
|
|
5234
5703
|
options.replaceExistingWiring = true;
|
|
5235
5704
|
}
|
|
5705
|
+
if (destroyKeyMaterial && !unwire) {
|
|
5706
|
+
throw new Error("--destroy-key-material only applies to --unwire.");
|
|
5707
|
+
}
|
|
5708
|
+
if (destroyKeyMaterial && unwire) unwire = { ...unwire, destroyKeyMaterial: true };
|
|
5709
|
+
if (dryRun && !pruneSignerRuntimes2) {
|
|
5710
|
+
throw new Error("--dry-run only applies to --prune-signer-runtimes.");
|
|
5711
|
+
}
|
|
5712
|
+
if (pruneSignerRuntimes2) {
|
|
5713
|
+
if (unwire || tombstoneDir || rekeyPhase || doctor || repair) {
|
|
5714
|
+
throw new Error("--prune-signer-runtimes is its own operation; run it alone.");
|
|
5715
|
+
}
|
|
5716
|
+
if (options.setupToken) {
|
|
5717
|
+
throw new Error("--prune-signer-runtimes takes no --setup token; it reads stored state only.");
|
|
5718
|
+
}
|
|
5719
|
+
return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir, pruneSignerRuntimes: { dryRun } };
|
|
5720
|
+
}
|
|
5236
5721
|
if (rekey) {
|
|
5237
5722
|
if (options.setupToken) {
|
|
5238
5723
|
throw new Error("--rekey replaces an existing agent's key; it does not take --setup. Drop one of them.");
|
|
@@ -5327,7 +5812,7 @@ function helpText() {
|
|
|
5327
5812
|
" Only available for Claude Code and Codex. Default is hosted MCP + local signer.",
|
|
5328
5813
|
" --json Emit one versioned, secret-free result object on stdout; progress stays on stderr.",
|
|
5329
5814
|
" --doctor Diagnose an existing setup (read-only, no token): config, credentials,",
|
|
5330
|
-
" signer runtime, hosted MCP, and a live signer handshake. Exits non-zero on
|
|
5815
|
+
" signer runtime, hosted MCP, and a live signer handshake. Exits non-zero only on a failed check; an advisory (!) exits 0.",
|
|
5331
5816
|
" --repair Repair, then re-diagnose (implies --doctor): reinstall the pinned signer",
|
|
5332
5817
|
" runtime, rewrite the wrapper and runtime config from stored credentials.",
|
|
5333
5818
|
" Hosted topology only (refuses to touch a --local config). No keys, no token.",
|
|
@@ -5354,6 +5839,12 @@ function helpText() {
|
|
|
5354
5839
|
" API key are removed locally (record kept via the #2155 tombstone mirror) and",
|
|
5355
5840
|
" nothing is ever revoked on the backend \u2014 that stays an owner action on the",
|
|
5356
5841
|
" Haven agent page.",
|
|
5842
|
+
" --destroy-key-material With --unwire: destroy the signer key + stored API key even when the agent",
|
|
5843
|
+
" is still active or cannot be verified (#3123). Local sweep recovery ends.",
|
|
5844
|
+
" --prune-signer-runtimes Remove ~/.haven/signer-runtime directories no credential directory references",
|
|
5845
|
+
" (version- and override-keyed alike); --dry-run lists them. Exits 1 only on a",
|
|
5846
|
+
" removal that failed (#3123).",
|
|
5847
|
+
" --dry-run With --prune-signer-runtimes: report, remove nothing.",
|
|
5357
5848
|
" --reason <text> Reason recorded in the tombstone (with --tombstone or --unwire).",
|
|
5358
5849
|
" --replaced-by <agent-id> Successor agent recorded in the tombstone (with --tombstone or --unwire).",
|
|
5359
5850
|
" --help Show this help.",
|
|
@@ -5393,6 +5884,13 @@ function failSubcommand(io, json, err, envelope, fallback) {
|
|
|
5393
5884
|
}
|
|
5394
5885
|
return 1;
|
|
5395
5886
|
}
|
|
5887
|
+
function levelMarker(level) {
|
|
5888
|
+
return level === "ok" ? "\u2713" : level === "advisory" ? "!" : "\u2717";
|
|
5889
|
+
}
|
|
5890
|
+
function advisoryCount(report) {
|
|
5891
|
+
const others = report.agents.filter((agent) => agent.classification === "wired" && agent.directory !== report.credentialDirectory).flatMap((agent) => agent.checks);
|
|
5892
|
+
return [...report.checks, ...others].filter((check) => check.level === "advisory").length;
|
|
5893
|
+
}
|
|
5396
5894
|
async function runCli(argv, io = {
|
|
5397
5895
|
stdout: (message) => process.stdout.write(message),
|
|
5398
5896
|
stderr: (message) => process.stderr.write(message)
|
|
@@ -5420,13 +5918,13 @@ async function runCli(argv, io = {
|
|
|
5420
5918
|
}
|
|
5421
5919
|
if (parsed.tombstone) {
|
|
5422
5920
|
const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
|
|
5423
|
-
const { readFile:
|
|
5424
|
-
const { join:
|
|
5921
|
+
const { readFile: readFile15 } = await import('fs/promises');
|
|
5922
|
+
const { join: join13 } = await import('path');
|
|
5425
5923
|
try {
|
|
5426
5924
|
let agentId = "unknown";
|
|
5427
5925
|
try {
|
|
5428
5926
|
const identity = JSON.parse(
|
|
5429
|
-
await
|
|
5927
|
+
await readFile15(join13(parsed.tombstone.directory, "identity.json"), "utf8")
|
|
5430
5928
|
);
|
|
5431
5929
|
agentId = identity.agent_id ?? "unknown";
|
|
5432
5930
|
} catch {
|
|
@@ -5464,20 +5962,22 @@ async function runCli(argv, io = {
|
|
|
5464
5962
|
}
|
|
5465
5963
|
if (parsed.unwire) {
|
|
5466
5964
|
const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
|
|
5467
|
-
const { homedir:
|
|
5468
|
-
const { join:
|
|
5469
|
-
const homeDir =
|
|
5470
|
-
const root = parsed.options.credentialsDir ??
|
|
5471
|
-
const directory = parsed.unwireDir ?? (parsed.options.serverName ?
|
|
5965
|
+
const { homedir: homedir11 } = await import('os');
|
|
5966
|
+
const { join: join13 } = await import('path');
|
|
5967
|
+
const homeDir = homedir11();
|
|
5968
|
+
const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
|
|
5969
|
+
const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
|
|
5472
5970
|
try {
|
|
5473
5971
|
const result = await unwireAgent2({
|
|
5474
5972
|
directory,
|
|
5475
5973
|
slug: parsed.options.serverName,
|
|
5476
5974
|
reason: parsed.unwire.reason,
|
|
5477
5975
|
replacedBy: parsed.unwire.replacedBy,
|
|
5976
|
+
destroyKeyMaterial: parsed.unwire.destroyKeyMaterial,
|
|
5478
5977
|
homeDir
|
|
5479
5978
|
});
|
|
5480
5979
|
const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
|
|
5980
|
+
const retained = result.teardown.status === "retained";
|
|
5481
5981
|
if (parsed.json) {
|
|
5482
5982
|
io.stdout(
|
|
5483
5983
|
`${redactSecrets(
|
|
@@ -5492,7 +5992,16 @@ async function runCli(argv, io = {
|
|
|
5492
5992
|
label: r.label,
|
|
5493
5993
|
status: r.status,
|
|
5494
5994
|
...r.detail ? { detail: r.detail } : {}
|
|
5495
|
-
}))
|
|
5995
|
+
})),
|
|
5996
|
+
// #3122: additive — whether the local server-name binding record was released.
|
|
5997
|
+
binding_released: result.bindingReleased,
|
|
5998
|
+
// #3123: additive — what happened to the key material and why.
|
|
5999
|
+
teardown: {
|
|
6000
|
+
status: result.teardown.status,
|
|
6001
|
+
probe: result.teardown.probe,
|
|
6002
|
+
detail: result.teardown.detail,
|
|
6003
|
+
...result.teardown.remedy ? { remedy: result.teardown.remedy } : {}
|
|
6004
|
+
}
|
|
5496
6005
|
})
|
|
5497
6006
|
)}
|
|
5498
6007
|
`
|
|
@@ -5508,14 +6017,20 @@ async function runCli(argv, io = {
|
|
|
5508
6017
|
io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
|
|
5509
6018
|
`));
|
|
5510
6019
|
}
|
|
6020
|
+
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");
|
|
6021
|
+
const t = result.teardown;
|
|
6022
|
+
io.stdout(redactSecrets(` ${t.status === "retained" ? "\u2717" : t.status === "forced" ? "!" : "\u2713"} Key material: ${t.status} (probe: ${t.probe}) \u2014 ${t.detail}
|
|
6023
|
+
`));
|
|
6024
|
+
if (t.remedy) io.stdout(redactSecrets(` \u21B3 ${t.remedy}
|
|
6025
|
+
`));
|
|
5511
6026
|
io.stdout(
|
|
5512
|
-
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"
|
|
6027
|
+
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"
|
|
5513
6028
|
);
|
|
5514
6029
|
io.stdout(
|
|
5515
|
-
"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\
|
|
6030
|
+
"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"
|
|
5516
6031
|
);
|
|
5517
6032
|
}
|
|
5518
|
-
return failures.length > 0 ? 1 : 0;
|
|
6033
|
+
return failures.length > 0 || retained ? 1 : 0;
|
|
5519
6034
|
} catch (err) {
|
|
5520
6035
|
return failSubcommand(io, parsed.json, err, { unwired: false }, {
|
|
5521
6036
|
code: "unwire_failed",
|
|
@@ -5523,6 +6038,57 @@ async function runCli(argv, io = {
|
|
|
5523
6038
|
});
|
|
5524
6039
|
}
|
|
5525
6040
|
}
|
|
6041
|
+
if (parsed.pruneSignerRuntimes) {
|
|
6042
|
+
const { pruneSignerRuntimes: pruneSignerRuntimes2 } = await Promise.resolve().then(() => (init_prune_runtimes(), prune_runtimes_exports));
|
|
6043
|
+
try {
|
|
6044
|
+
const report = await pruneSignerRuntimes2(
|
|
6045
|
+
{ dryRun: parsed.pruneSignerRuntimes.dryRun },
|
|
6046
|
+
{ credentialsDir: parsed.options.credentialsDir }
|
|
6047
|
+
);
|
|
6048
|
+
if (parsed.json) {
|
|
6049
|
+
io.stdout(`${redactSecrets(JSON.stringify({
|
|
6050
|
+
pruned: true,
|
|
6051
|
+
version: report.version,
|
|
6052
|
+
dry_run: report.dryRun,
|
|
6053
|
+
level: report.level,
|
|
6054
|
+
root: report.root,
|
|
6055
|
+
removed: report.removed,
|
|
6056
|
+
reclaimed_bytes: report.reclaimedBytes,
|
|
6057
|
+
entries: report.entries.map((e) => ({
|
|
6058
|
+
key: e.key,
|
|
6059
|
+
kind: e.kind,
|
|
6060
|
+
bytes: e.bytes,
|
|
6061
|
+
action: e.action,
|
|
6062
|
+
level: e.level,
|
|
6063
|
+
referenced_by: e.referencedBy,
|
|
6064
|
+
detail: e.detail
|
|
6065
|
+
}))
|
|
6066
|
+
}))}
|
|
6067
|
+
`);
|
|
6068
|
+
} else {
|
|
6069
|
+
io.stdout(`Signer runtimes under ${report.root}${report.dryRun ? " (dry run \u2014 nothing removed)" : ""}:
|
|
6070
|
+
`);
|
|
6071
|
+
if (report.entries.length === 0) io.stdout(" (none)\n");
|
|
6072
|
+
for (const e of report.entries) {
|
|
6073
|
+
const mark = e.level === "failed" ? "\u2717" : e.level === "advisory" ? "!" : e.action === "removed" ? "\u2713" : "\u2022";
|
|
6074
|
+
const size = e.bytes > 0 ? `${Math.round(e.bytes / 1024 / 1024)} MB` : e.action === "kept" ? "not sized" : "0 MB";
|
|
6075
|
+
io.stdout(redactSecrets(` ${mark} ${e.key} (${e.kind}, ${size}): ${e.detail}
|
|
6076
|
+
`));
|
|
6077
|
+
}
|
|
6078
|
+
io.stdout(
|
|
6079
|
+
report.dryRun ? `Would remove ${report.entries.filter((e) => e.action === "would_remove").length} director(y/ies); re-run without --dry-run to reclaim.
|
|
6080
|
+
` : `Removed ${report.removed} director(y/ies), reclaimed ${Math.round(report.reclaimedBytes / 1024 / 1024)} MB.
|
|
6081
|
+
`
|
|
6082
|
+
);
|
|
6083
|
+
}
|
|
6084
|
+
return report.level === "failed" ? 1 : 0;
|
|
6085
|
+
} catch (err) {
|
|
6086
|
+
return failSubcommand(io, parsed.json, err, { pruned: false }, {
|
|
6087
|
+
code: "prune_failed",
|
|
6088
|
+
nextAction: "review_the_error_and_rerun_prune_which_is_idempotent"
|
|
6089
|
+
});
|
|
6090
|
+
}
|
|
6091
|
+
}
|
|
5526
6092
|
if (parsed.rekey) {
|
|
5527
6093
|
const { startRekey: startRekey2, finishRekey: finishRekey2 } = await Promise.resolve().then(() => (init_rekey(), rekey_exports));
|
|
5528
6094
|
const { restartGuidance: restartGuidance2 } = await Promise.resolve().then(() => (init_rekey_restart(), rekey_restart_exports));
|
|
@@ -5599,7 +6165,7 @@ async function runCli(argv, io = {
|
|
|
5599
6165
|
`);
|
|
5600
6166
|
} else {
|
|
5601
6167
|
for (const check of report.checks) {
|
|
5602
|
-
io.stdout(redactSecrets(`${check.
|
|
6168
|
+
io.stdout(redactSecrets(`${levelMarker(check.level)} ${check.label}: ${check.detail}
|
|
5603
6169
|
`));
|
|
5604
6170
|
if (check.repair) io.stdout(redactSecrets(` \u21B3 repair: ${check.repair}
|
|
5605
6171
|
`));
|
|
@@ -5609,21 +6175,25 @@ async function runCli(argv, io = {
|
|
|
5609
6175
|
io.stdout("\nOther agents on this machine:\n");
|
|
5610
6176
|
for (const agent of otherAgents) {
|
|
5611
6177
|
const name = agent.slug ? `${agent.slug} (${agent.agentId ?? "unknown"})` : agent.agentId ?? "unknown";
|
|
5612
|
-
const failed = agent.checks.filter((check) =>
|
|
5613
|
-
const
|
|
5614
|
-
|
|
6178
|
+
const failed = agent.checks.filter((check) => check.level === "failed");
|
|
6179
|
+
const advised = agent.checks.filter((check) => check.level === "advisory");
|
|
6180
|
+
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;
|
|
6181
|
+
io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : advised.length > 0 ? "!" : "\u2022"} ${name}: ${verdict}
|
|
5615
6182
|
`));
|
|
5616
|
-
for (const check of failed) {
|
|
5617
|
-
io.stdout(redactSecrets(`
|
|
6183
|
+
for (const check of [...failed, ...advised]) {
|
|
6184
|
+
io.stdout(redactSecrets(` ${levelMarker(check.level)} ${check.label}: ${check.detail}
|
|
5618
6185
|
`));
|
|
5619
6186
|
if (check.repair) io.stdout(redactSecrets(` \u21B3 repair: ${check.repair}
|
|
5620
6187
|
`));
|
|
5621
6188
|
}
|
|
5622
6189
|
}
|
|
5623
6190
|
}
|
|
5624
|
-
io.stdout(
|
|
6191
|
+
io.stdout(
|
|
6192
|
+
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.
|
|
6193
|
+
` : "All checks passed.\n"
|
|
6194
|
+
);
|
|
5625
6195
|
}
|
|
5626
|
-
return report.
|
|
6196
|
+
return report.level === "failed" ? 1 : 0;
|
|
5627
6197
|
} catch (err) {
|
|
5628
6198
|
return failSubcommand(io, parsed.json, err, { doctor: "failed" }, {
|
|
5629
6199
|
code: "doctor_failed",
|