@haven_ai/connect 0.8.1-alpha.0 → 0.10.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -3,15 +3,15 @@
3
3
 
4
4
  var crypto = require('crypto');
5
5
  var ethers = require('ethers');
6
- var promises = require('fs/promises');
7
- var os = require('os');
8
- var path = require('path');
9
6
  var mcp = require('@haven_ai/mcp');
10
7
  var signer = require('@haven_ai/signer');
11
8
  var sdk = require('@haven_ai/sdk');
12
- var yaml = require('yaml');
13
9
  var child_process = require('child_process');
10
+ var promises = require('fs/promises');
11
+ var os = require('os');
12
+ var path = require('path');
14
13
  var util = require('util');
14
+ var yaml = require('yaml');
15
15
  var readline = require('readline');
16
16
  var fs = require('fs');
17
17
  var url = require('url');
@@ -72,6 +72,25 @@ function createConnectApiClient(baseUrl, fetchImpl = fetch, clientIdentity) {
72
72
  method: "GET",
73
73
  headers: { Authorization: `Bearer ${apiKey}` }
74
74
  }),
75
+ reportRetiredAgents: async (input) => {
76
+ try {
77
+ const body = await send(
78
+ `${root}/agent-connection-setups/${encodeURIComponent(input.setupId)}/retired-agents`,
79
+ {
80
+ method: "POST",
81
+ headers: { Authorization: `Bearer ${input.apiKey}` },
82
+ body: JSON.stringify({ retired_agent_ids: input.retiredAgentIds })
83
+ }
84
+ );
85
+ const recorded = body?.recorded_agent_ids;
86
+ return {
87
+ recordedAgentIds: Array.isArray(recorded) ? recorded.filter((id) => typeof id === "string") : []
88
+ };
89
+ } catch (err) {
90
+ if (err instanceof ConnectRequestError && err.status === 404) return null;
91
+ throw err;
92
+ }
93
+ },
75
94
  updateInstallStatus: async (setupId, apiKey, input) => {
76
95
  await send(`${root}/agent-connection-setups/${encodeURIComponent(setupId)}/install-status`, {
77
96
  method: "POST",
@@ -241,1086 +260,1538 @@ var init_server_names = __esm({
241
260
  SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
242
261
  }
243
262
  });
244
- async function preflightCredentialStorage(input = {}) {
245
- const directory = defaultCredentialRoot(input.baseDir);
246
- await promises.mkdir(directory, { recursive: true, mode: 448 });
247
- await restrictPermissions(directory, 448, input.warn);
248
- const probePath = path.join(directory, `.haven-connect-preflight-${crypto__default.default.randomBytes(8).toString("hex")}`);
249
- try {
250
- await writeOwnerOnlyJson(probePath, { ok: true }, input.warn);
251
- } finally {
252
- await promises.rm(probePath, { force: true }).catch(() => void 0);
253
- }
254
- return directory;
255
- }
256
- async function writeCredentialFiles(input) {
257
- const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
258
- await promises.mkdir(directory, { recursive: true, mode: 448 });
259
- await restrictPermissions(directory, 448, input.warn);
260
- const identityPath = path.join(directory, "identity.json");
261
- const signerPath = path.join(directory, "signer.json");
262
- const agentPath = path.join(directory, "agent.json");
263
- await assertDoesNotExist(identityPath);
264
- await assertDoesNotExist(signerPath);
265
- await assertDoesNotExist(agentPath);
266
- await writeOwnerOnlyJson(signerPath, signerPayload(input), input.warn);
267
- try {
268
- await writeOwnerOnlyJson(identityPath, identityPayload(input), input.warn);
269
- } catch (err) {
270
- await promises.rm(signerPath, { force: true }).catch(() => void 0);
271
- throw err;
272
- }
273
- try {
274
- await writeOwnerOnlyJson(agentPath, agentPayload(input), input.warn);
275
- } catch (err) {
276
- await promises.rm(signerPath, { force: true }).catch(() => void 0);
277
- await promises.rm(identityPath, { force: true }).catch(() => void 0);
278
- await promises.rm(agentPath, { force: true }).catch(() => void 0);
279
- throw err;
280
- }
281
- return { directory, identityPath, signerPath, agentPath };
282
- }
283
- function signerPayload(input) {
284
- return {
285
- delegate_key: input.delegateKey,
286
- delegate_address: input.delegateAddress,
287
- agent_id: input.agentId,
288
- account_address: input.accountAddress,
289
- chain_id: input.chainId,
290
- network: input.network,
291
- x402_binding_signer: input.x402BindingSigner,
292
- note: "Local signer credential. Haven backend never receives this private key."
293
- };
263
+ function mcpPackageSpec() {
264
+ return `${MCP_RUNTIME_MANIFEST.mcpPackage}@${MCP_RUNTIME_MANIFEST.mcpVersion}`;
294
265
  }
295
- function identityPayload(input) {
296
- return {
297
- api_key: input.apiKey,
298
- agent_id: input.agentId,
299
- account_address: input.accountAddress,
300
- chain_id: input.chainId,
301
- network: input.network,
302
- api_url: input.apiUrl,
303
- hosted_mcp_url: input.hostedMcpUrl,
304
- agent_budget: input.agentBudget,
305
- note: "Haven API key identifies the agent only. It cannot spend without the local signer key and on-chain Haven wallet rules."
306
- };
266
+ function sdkPackageSpec() {
267
+ return `${MCP_RUNTIME_MANIFEST.sdkPackage}@${MCP_RUNTIME_MANIFEST.sdkVersion}`;
307
268
  }
308
- function agentPayload(input) {
309
- return {
310
- agent_id: input.agentId,
311
- delegate_address: input.delegateAddress,
312
- account_address: input.accountAddress,
313
- chain_id: input.chainId,
314
- network: input.network,
315
- agent_budget: input.agentBudget,
316
- note: "Non-secret orientation for the agent: public delegate/Haven wallet identity + configured budget. Contains no API key or signing key. For the live remaining budget, call haven_get_allowances."
317
- };
269
+ function signerPackageSpec() {
270
+ return `${MCP_RUNTIME_MANIFEST.signerPackage}@${MCP_RUNTIME_MANIFEST.signerVersion}`;
318
271
  }
319
- async function readStoredCredentials(serverName, agentIdOrSlug, baseDir) {
320
- const key = serverName ?? agentIdOrSlug;
321
- const directory = key ? defaultAgentDirectory(key, baseDir) : await discoverSoleAgentDirectory(baseDir);
322
- const identity = await readJsonFile(path.join(directory, "identity.json"));
323
- if (!identity) {
324
- throw new Error(
325
- `No Haven credentials at ${directory}. Nothing to re-key \u2014 connect this agent first, or pass the --name you wired it under.`
326
- );
272
+ var MCP_RUNTIME_MANIFEST;
273
+ var init_runtime_manifest = __esm({
274
+ "src/runtime-manifest.ts"() {
275
+ MCP_RUNTIME_MANIFEST = {
276
+ mcpPackage: "@haven_ai/mcp",
277
+ mcpVersion: mcp.MCP_VERSION,
278
+ sdkPackage: "@haven_ai/sdk",
279
+ sdkVersion: "0.10.0-alpha.0",
280
+ signerPackage: "@haven_ai/signer",
281
+ signerVersion: "0.10.0-alpha.0",
282
+ // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
283
+ // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
284
+ // so the guard that was supposed to enforce the floor waved Node v23 through
285
+ // — including on the `--local` path where it does run. A hand-maintained
286
+ // second copy of a number is a drift waiting to happen; a guard test pins
287
+ // this against `package.json`'s `engines.node`.
288
+ minimumNodeVersion: sdk.HAVEN_MINIMUM_NODE_VERSION,
289
+ supportedClients: ["codex-cli", "codex-desktop", "claude-code"],
290
+ requiredTools: mcp.registeredToolNames(),
291
+ /**
292
+ * The signer MCP's tool surface, DERIVED from the pinned @haven_ai/signer
293
+ * package (#1587) — same anti-drift rule as `requiredTools` above: a
294
+ * literal list here would rot the first time the signer gains a tool.
295
+ * The handshake probe requires all of them.
296
+ */
297
+ requiredSignerTools: Object.keys(signer.toolSchemas)
298
+ };
327
299
  }
328
- const agent = await readJsonFile(path.join(directory, "agent.json")) ?? {};
329
- const signer = await readJsonFile(path.join(directory, "signer.json")) ?? {};
330
- const agentId = asString(identity.agent_id);
331
- const apiKey = asString(identity.api_key);
332
- const apiUrl = asString(identity.api_url);
333
- const hostedMcpUrl = asString(identity.hosted_mcp_url);
334
- if (!agentId || !apiKey || !apiUrl || !hostedMcpUrl) {
335
- throw new Error(
336
- `The credential set at ${directory} is incomplete (identity.json is missing agent_id, api_key, api_url or hosted_mcp_url). Re-key cannot rebuild it \u2014 reconnect the agent instead.`
337
- );
300
+ });
301
+ function resolveRuntimeSpecOverride(env) {
302
+ const override = {};
303
+ for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
304
+ const variable = RUNTIME_SPEC_ENV[pkg];
305
+ const raw = env[variable];
306
+ if (raw === void 0) continue;
307
+ override[pkg] = validateSpec(variable, raw);
338
308
  }
339
- return {
340
- directory,
341
- agentId,
342
- apiKey,
343
- apiUrl,
344
- hostedMcpUrl,
345
- delegateAddress: asString(agent.delegate_address) ?? asString(signer.delegate_address),
346
- ...readStoredAccountAddress(identity, agent),
347
- chainId: typeof identity.chain_id === "number" ? identity.chain_id : void 0,
348
- network: asString(identity.network),
349
- x402BindingSigner: asString(signer.x402_binding_signer),
350
- agentBudget: Array.isArray(identity.agent_budget) ? identity.agent_budget : void 0
351
- };
352
- }
353
- function readStoredAccountAddress(identity, agent) {
354
- const fresh = asString(identity.account_address) ?? asString(agent.account_address);
355
- if (fresh) return { accountAddress: fresh, accountAddressKey: "account_address" };
356
- const legacy = asString(identity.safe_address) ?? asString(agent.safe_address);
357
- if (legacy) return { accountAddress: legacy, accountAddressKey: "safe_address" };
358
- return {};
309
+ return Object.keys(override).length > 0 ? override : void 0;
359
310
  }
360
- async function discoverSoleAgentDirectory(baseDir) {
361
- const root = defaultCredentialRoot(baseDir);
362
- let entries = [];
363
- try {
364
- entries = await promises.readdir(root);
365
- } catch {
366
- throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
367
- }
368
- const candidates = [];
369
- for (const entry of entries) {
370
- const directory = path.join(root, entry);
371
- if (!await readJsonFile(path.join(directory, "identity.json"))) continue;
372
- if (await readJsonFile(path.join(directory, "TOMBSTONE.json"))) continue;
373
- candidates.push(directory);
311
+ function validateSpec(variable, raw) {
312
+ if (raw.length === 0 || raw.trim().length === 0) {
313
+ throw new RuntimeSpecOverrideError(variable, "it is empty");
374
314
  }
375
- if (candidates.length === 1) return candidates[0];
376
- if (candidates.length === 0) {
377
- throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
315
+ if (/\s/.test(raw)) {
316
+ throw new RuntimeSpecOverrideError(variable, "it contains whitespace");
378
317
  }
379
- throw new Error(
380
- `Several agents are wired on this machine, so --rekey cannot tell which one you mean:
381
- ` + candidates.map((d) => ` ${d}`).join("\n") + "\nRe-run with --name <slug> to pick one."
382
- );
383
- }
384
- async function rewriteCredentialFiles(input) {
385
- const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
386
- const identityPath = path.join(directory, "identity.json");
387
- const signerPath = path.join(directory, "signer.json");
388
- const agentPath = path.join(directory, "agent.json");
389
- const targets = [
390
- { path: signerPath, payload: signerPayload(input) },
391
- { path: identityPath, payload: identityPayload(input) },
392
- { path: agentPath, payload: agentPayload(input) }
393
- ];
394
- const originals = /* @__PURE__ */ new Map();
395
- for (const { path } of targets) {
396
- originals.set(path, await readRawFile(path));
318
+ if (/[\u0000-\u001f\u007f]/.test(raw)) {
319
+ throw new RuntimeSpecOverrideError(variable, "it contains a control character");
397
320
  }
398
- const temps = [];
399
- try {
400
- for (const { path, payload } of targets) {
401
- const temp = `${path}.rekey-${crypto__default.default.randomBytes(6).toString("hex")}.tmp`;
402
- await writeOwnerOnlyJson(temp, payload, input.warn);
403
- temps.push({ from: temp, to: path });
404
- }
405
- for (const { from, to } of temps) {
406
- await promises.rename(from, to);
407
- }
408
- } catch (err) {
409
- for (const { from } of temps) await promises.rm(from, { force: true }).catch(() => void 0);
410
- for (const [path, contents] of originals) {
411
- if (contents === null) {
412
- await promises.rm(path, { force: true }).catch(() => void 0);
413
- } else {
414
- await promises.writeFile(path, contents, { mode: 384 }).catch(() => void 0);
415
- }
416
- }
417
- throw err;
321
+ const meta = SHELL_METACHARACTERS.exec(raw);
322
+ if (meta) {
323
+ throw new RuntimeSpecOverrideError(variable, `it contains the shell metacharacter ${JSON.stringify(meta[0])}`);
418
324
  }
419
- return { directory, identityPath, signerPath, agentPath };
325
+ return raw;
420
326
  }
421
- async function writeRekeyPending(directory, pending, warn) {
422
- const path$1 = path.join(directory, REKEY_PENDING_FILENAME);
423
- await promises.rm(path$1, { force: true }).catch(() => void 0);
424
- await writeOwnerOnlyJson(path$1, { ...pending }, warn);
425
- return path$1;
327
+ function runtimeSpecOverrideDirectoryKey(resolvedSpecs) {
328
+ const digest = crypto.createHash("sha256").update(resolvedSpecs.join("\n")).digest("hex");
329
+ return `override-${digest.slice(0, 12)}`;
426
330
  }
427
- async function readRekeyPending(directory, now = Date.now()) {
428
- const path$1 = path.join(directory, REKEY_PENDING_FILENAME);
429
- const raw = await readJsonFile(path$1);
430
- if (!raw) {
431
- throw new Error(
432
- `No re-key in progress at ${directory}. Run the connector with --rekey first \u2014 it prints the new signing address to paste into the Haven agent page.`
433
- );
331
+ function overrideApplies(override, packages) {
332
+ return override !== void 0 && packages.some((pkg) => override[pkg] !== void 0);
333
+ }
334
+ function runtimeSpecOverrideNotice(runtimeLabel, override, pinned, runtimeDirectory) {
335
+ const lines = [`RUNTIME SPEC OVERRIDE ACTIVE for the local Haven ${runtimeLabel} \u2014 this is NOT the pinned manifest.`];
336
+ for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
337
+ const spec = override[pkg];
338
+ if (spec === void 0) continue;
339
+ const pin = pinned[pkg];
340
+ lines.push(` ${RUNTIME_SPEC_ENV[pkg]}=${spec}${pin ? ` (instead of ${pin})` : ""}`);
434
341
  }
435
- const pending = raw;
436
- if (!pending.new_delegate_key || !pending.new_delegate_address || !pending.agent_id) {
437
- throw new Error(`The pending re-key at ${path$1} is unreadable. Delete it and start again with --rekey.`);
438
- }
439
- if (pending.expires_at && Date.parse(pending.expires_at) < now) {
440
- throw new Error(
441
- `The re-key started at ${pending.started_at} has expired. Start again with --rekey \u2014 the address currently shown in your dashboard is no longer the one this machine holds.`
442
- );
443
- }
444
- return pending;
342
+ lines.push(` Installing into ${runtimeDirectory} \u2014 the pinned runtime directory is untouched.`);
343
+ lines.push(" Unset the variable(s) and re-run to return to the pinned manifest.");
344
+ return lines;
445
345
  }
446
- async function inspectRekeyPending(directory, now = Date.now()) {
447
- const path$1 = path.join(directory, REKEY_PENDING_FILENAME);
448
- const raw = await readJsonFile(path$1);
449
- if (!raw) {
450
- const present = await readRawFile(path$1) !== null;
451
- return present ? { state: "unreadable", path: path$1 } : null;
346
+ function describeRuntimeSpecOverride(override) {
347
+ return Object.keys(RUNTIME_SPEC_ENV).filter((pkg) => override[pkg] !== void 0).map((pkg) => `${RUNTIME_SPEC_ENV[pkg]}=${override[pkg]}`).join(" ");
348
+ }
349
+ var RUNTIME_SPEC_ENV, SHELL_METACHARACTERS, RuntimeSpecOverrideError;
350
+ var init_runtime_spec_override = __esm({
351
+ "src/runtime-spec-override.ts"() {
352
+ RUNTIME_SPEC_ENV = {
353
+ signer: "HAVEN_SIGNER_SPEC",
354
+ sdk: "HAVEN_SDK_SPEC",
355
+ mcp: "HAVEN_MCP_SPEC"
356
+ };
357
+ SHELL_METACHARACTERS = /[;&|<>$`'"()!#*?[\]{}\\]/;
358
+ RuntimeSpecOverrideError = class extends Error {
359
+ code = "runtime_spec_override_invalid";
360
+ variable;
361
+ constructor(variable, reason) {
362
+ super(`${variable} is set but not usable as an npm package spec: ${reason}. Unset it, or set it to a spec npm install accepts (file:/abs/path, a .tgz, or @haven_ai/<pkg>@<version>).`);
363
+ this.name = "RuntimeSpecOverrideError";
364
+ this.variable = variable;
365
+ }
366
+ };
452
367
  }
453
- const agentId = asString(raw.agent_id);
454
- const newDelegateAddress = asString(raw.new_delegate_address);
455
- const startedAt = asString(raw.started_at);
456
- const expiresAt = asString(raw.expires_at);
457
- if (!agentId || !newDelegateAddress) {
458
- return { state: "unreadable", path: path$1, ...startedAt ? { startedAt } : {} };
368
+ });
369
+ async function prepareSignerRuntime(input, deps = {}) {
370
+ const homeDir = input.homeDir ?? os.homedir();
371
+ const override = resolveSignerRuntimeOverride(deps.env ?? process.env);
372
+ const signerSpec = override?.signer ?? signerPackageSpec();
373
+ const sdkSpec = override?.sdk ?? sdkPackageSpec();
374
+ const resolvedSpecs = [signerSpec, sdkSpec];
375
+ const overrideRecord = override ? { specs: override, resolved_specs: resolvedSpecs, directory_key: runtimeSpecOverrideDirectoryKey(resolvedSpecs) } : void 0;
376
+ const runtimeDirectory = path.resolve(
377
+ homeDir,
378
+ ".haven",
379
+ "signer-runtime",
380
+ overrideRecord ? overrideRecord.directory_key : MCP_RUNTIME_MANIFEST.signerVersion
381
+ );
382
+ const npmCacheDirectory = path.resolve(homeDir, ".haven", "npm-cache");
383
+ const cliPath = path.join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "dist", "cli.js");
384
+ const messages = [];
385
+ if (override) {
386
+ messages.push(...runtimeSpecOverrideNotice(
387
+ "signer runtime",
388
+ override,
389
+ { signer: signerPackageSpec(), sdk: sdkPackageSpec() },
390
+ runtimeDirectory
391
+ ));
459
392
  }
460
- const expired = expiresAt !== void 0 && Date.parse(expiresAt) < now;
393
+ await promises.mkdir(runtimeDirectory, { recursive: true, mode: 448 });
394
+ await promises.chmod(runtimeDirectory, 448).catch(() => void 0);
395
+ await promises.mkdir(npmCacheDirectory, { recursive: true, mode: 448 });
396
+ await promises.chmod(npmCacheDirectory, 448).catch(() => void 0);
397
+ if (override) {
398
+ await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
399
+ messages.push(`Installed local Haven signer runtime from override (${resolvedSpecs.join(" ")}).`);
400
+ } else if (await installedRuntimeMatches(runtimeDirectory, cliPath)) {
401
+ messages.push(`Using existing local Haven signer runtime ${signerPackageSpec()}.`);
402
+ } else {
403
+ await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
404
+ messages.push(`Installed local Haven signer runtime ${signerPackageSpec()}.`);
405
+ }
406
+ await assertFileExists(cliPath, "local Haven signer CLI");
407
+ const installedVersions = override ? await readInstalledVersions(runtimeDirectory) : void 0;
408
+ const wrapperPath = path.join(input.credentialDirectory, "bin", "haven-signer.mjs");
409
+ await writeWrapper({
410
+ wrapperPath,
411
+ cliPath,
412
+ signerPath: input.signerPath,
413
+ overrideComment: override ? describeRuntimeSpecOverride(override) : void 0
414
+ });
415
+ await writeRuntimeSidecar({
416
+ path: path.join(input.credentialDirectory, "signer-runtime.json"),
417
+ wrapperPath,
418
+ runtimeDirectory,
419
+ npmCacheDirectory,
420
+ cliPath,
421
+ serverName: input.serverName,
422
+ override: overrideRecord,
423
+ installedVersions
424
+ });
425
+ messages.push(`Prepared stable local Haven signer wrapper: ${wrapperPath}`);
461
426
  return {
462
- state: expired ? "expired" : "pending",
463
- path: path$1,
464
- agentId,
465
- newDelegateAddress,
466
- ...startedAt ? { startedAt } : {},
467
- ...expiresAt ? { expiresAt } : {}
427
+ command: wrapperPath,
428
+ args: [],
429
+ wrapperPath,
430
+ runtimeDirectory,
431
+ npmCacheDirectory,
432
+ cliPath,
433
+ messages,
434
+ ...overrideRecord ? { runtimeSpecOverride: overrideRecord } : {}
468
435
  };
469
436
  }
470
- async function clearRekeyPending(directory) {
471
- await promises.rm(path.join(directory, REKEY_PENDING_FILENAME), { force: true }).catch(() => void 0);
472
- }
473
- async function readJsonFile(path) {
474
- const raw = await readRawFile(path);
475
- if (raw === null) return null;
476
- try {
477
- const parsed = JSON.parse(raw);
478
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
479
- } catch {
480
- return null;
481
- }
437
+ function resolveSignerRuntimeOverride(env) {
438
+ const override = resolveRuntimeSpecOverride(env);
439
+ if (!overrideApplies(override, ["signer", "sdk"])) return void 0;
440
+ const { signer, sdk } = override;
441
+ return { ...signer !== void 0 ? { signer } : {}, ...sdk !== void 0 ? { sdk } : {} };
482
442
  }
483
- async function readRawFile(path) {
443
+ async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, packageSpecs, deps) {
444
+ const { runCommand, onProgress } = deps;
445
+ const baseArgs = [
446
+ "install",
447
+ "--prefix",
448
+ runtimeDirectory,
449
+ "--no-audit",
450
+ "--no-fund",
451
+ "--omit=dev",
452
+ "--prefer-offline",
453
+ ...packageSpecs
454
+ ];
455
+ const run = async (args) => {
456
+ const startedAt = Date.now();
457
+ const heartbeat = setInterval(() => {
458
+ const seconds = Math.round((Date.now() - startedAt) / 1e3);
459
+ onProgress?.(`Still installing the local Haven signer runtime\u2026 (${seconds}s \u2014 a cold cache can take several minutes)`);
460
+ }, SIGNER_INSTALL_HEARTBEAT_MS);
461
+ heartbeat.unref?.();
462
+ try {
463
+ if (runCommand) await runCommand("npm", args);
464
+ else await execFileAsync("npm", args, { timeout: SIGNER_INSTALL_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
465
+ } finally {
466
+ clearInterval(heartbeat);
467
+ }
468
+ };
484
469
  try {
485
- return await promises.readFile(path, "utf8");
470
+ await run(baseArgs);
486
471
  } catch {
487
- return null;
472
+ try {
473
+ await run([...baseArgs, "--cache", npmCacheDirectory]);
474
+ } catch (err) {
475
+ throw new Error(
476
+ `Could not install local Haven signer runtime ${packageSpecs.join(" ")}: ${err instanceof Error ? err.message : String(err)}`
477
+ );
478
+ }
488
479
  }
489
480
  }
490
- function asString(value) {
491
- return typeof value === "string" && value.length > 0 ? value : void 0;
481
+ async function installedRuntimeMatches(runtimeDirectory, cliPath) {
482
+ return installedRuntimeMatchesVersions(runtimeDirectory, cliPath, {
483
+ signerVersion: MCP_RUNTIME_MANIFEST.signerVersion,
484
+ sdkVersion: MCP_RUNTIME_MANIFEST.sdkVersion
485
+ });
492
486
  }
493
- async function assertServerSlugAvailable(serverName, baseDir) {
494
- const directory = defaultAgentDirectory(serverName, baseDir);
487
+ async function installedRuntimeMatchesVersions(runtimeDirectory, cliPath, expected) {
495
488
  try {
496
- await promises.stat(path.join(directory, "identity.json"));
489
+ await assertFileExists(cliPath, "local Haven signer CLI");
490
+ const installed = await readInstalledVersions(runtimeDirectory);
491
+ return installed.signerVersion === expected.signerVersion && installed.sdkVersion === expected.sdkVersion;
497
492
  } catch {
498
- return;
493
+ return false;
499
494
  }
500
- throw new Error(
501
- `The name "${serverName}" is already wired on this machine (${directory} holds credentials). Pick a different --name, or revoke and remove that agent first \u2014 connect never overwrites credentials.`
502
- );
503
- }
504
- function defaultAgentDirectory(agentId, baseDir = path.join(os.homedir(), ".haven", "agents")) {
505
- return path.resolve(defaultCredentialRoot(baseDir), safePathPart(agentId));
506
- }
507
- function defaultCredentialRoot(baseDir = path.join(os.homedir(), ".haven", "agents")) {
508
- return path.resolve(baseDir);
509
- }
510
- async function writeOwnerOnlyJson(path, value, warn) {
511
- const json = JSON.stringify(dropUndefined(value), null, 2);
512
- await promises.writeFile(path, `${json}
513
- `, { mode: 384, flag: "wx" });
514
- await restrictPermissions(path, 384, warn);
515
495
  }
516
- function safePathPart(value) {
517
- return value.replace(/[^A-Za-z0-9_.-]/g, "_");
496
+ async function readInstalledVersions(runtimeDirectory) {
497
+ const [signerPackage, sdkPackage] = await Promise.all([
498
+ readPackageJson(path.join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "package.json")),
499
+ readPackageJson(path.join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
500
+ ]);
501
+ return { signerVersion: signerPackage.version ?? "", sdkVersion: sdkPackage.version ?? "" };
518
502
  }
519
- function dropUndefined(value) {
520
- return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
503
+ async function readPackageJson(path) {
504
+ return JSON.parse(await promises.readFile(path, "utf8"));
521
505
  }
522
- async function assertDoesNotExist(path) {
523
- try {
524
- await promises.access(path);
525
- } catch (err) {
526
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
527
- throw err;
528
- }
529
- throw new Error(`Refusing to overwrite existing Haven credential file: ${path}`);
506
+ async function writeWrapper(input) {
507
+ await promises.mkdir(path.dirname(input.wrapperPath), { recursive: true, mode: 448 });
508
+ await promises.chmod(path.dirname(input.wrapperPath), 448).catch(() => void 0);
509
+ const source = [
510
+ "#!/usr/bin/env node",
511
+ ...input.overrideComment ? [`// HAVEN RUNTIME SPEC OVERRIDE (#2424): ${input.overrideComment} \u2014 this wrapper launches a NON-pinned signer build.`] : [],
512
+ "import { spawn } from 'node:child_process'",
513
+ "",
514
+ `const cliPath = ${JSON.stringify(input.cliPath)}`,
515
+ `const signerPath = ${JSON.stringify(input.signerPath)}`,
516
+ "",
517
+ "const child = spawn(process.execPath, [cliPath, '--credentials', signerPath, ...process.argv.slice(2)], {",
518
+ " stdio: 'inherit',",
519
+ "})",
520
+ "",
521
+ "child.on('exit', (code, signal) => {",
522
+ " if (signal) process.kill(process.pid, signal)",
523
+ " else process.exit(code ?? 1)",
524
+ "})",
525
+ ""
526
+ ].join("\n");
527
+ await promises.writeFile(input.wrapperPath, source, { mode: 448 });
528
+ await promises.chmod(input.wrapperPath, 448).catch(() => void 0);
530
529
  }
531
- async function restrictPermissions(path, mode, warn) {
530
+ async function readRuntimeSidecar(credentialDirectory) {
532
531
  try {
533
- await promises.chmod(path, mode);
534
- } catch (err) {
535
- warn?.(
536
- `Warning: could not restrict permissions on ${path} to ${mode.toString(8)}. Move this credential to a private location or run chmod ${mode.toString(8)} ${path}. ${err instanceof Error ? err.message : String(err)}`
532
+ return JSON.parse(
533
+ await promises.readFile(path.join(credentialDirectory, "signer-runtime.json"), "utf8")
537
534
  );
535
+ } catch {
536
+ return null;
538
537
  }
539
538
  }
540
- async function writeConnectOutcomeRecord(directory, outcome, warn) {
541
- const path$1 = path.join(directory, CONNECT_OUTCOME_FILENAME);
542
- await promises.writeFile(path$1, `${JSON.stringify(outcome, null, 2)}
539
+ async function writeRuntimeSidecar(input) {
540
+ const value = {
541
+ ...input.serverName ? { server_name: input.serverName } : {},
542
+ signer_package: MCP_RUNTIME_MANIFEST.signerPackage,
543
+ signer_version: input.installedVersions?.signerVersion ?? MCP_RUNTIME_MANIFEST.signerVersion,
544
+ sdk_package: MCP_RUNTIME_MANIFEST.sdkPackage,
545
+ sdk_version: input.installedVersions?.sdkVersion ?? MCP_RUNTIME_MANIFEST.sdkVersion,
546
+ wrapper_path: input.wrapperPath,
547
+ runtime_directory: input.runtimeDirectory,
548
+ npm_cache_directory: input.npmCacheDirectory,
549
+ cli_path: input.cliPath,
550
+ ...input.override ? { runtime_spec_override: input.override } : {}
551
+ };
552
+ await promises.writeFile(input.path, `${JSON.stringify(value, null, 2)}
543
553
  `, { mode: 384 });
544
- await restrictPermissions(path$1, 384, warn);
545
- return path$1;
554
+ await promises.chmod(input.path, 384).catch(() => void 0);
546
555
  }
547
- async function readConnectOutcomeRuntime(directory) {
556
+ async function assertFileExists(path, label) {
548
557
  try {
549
- const parsed = JSON.parse(await promises.readFile(path.join(directory, CONNECT_OUTCOME_FILENAME), "utf8"));
550
- if (typeof parsed !== "object" || parsed === null) return null;
551
- const runtime = parsed.runtime;
552
- return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
558
+ await promises.access(path);
553
559
  } catch {
554
- return null;
560
+ throw new Error(`Missing ${label}: ${path}`);
555
561
  }
556
562
  }
557
- async function writeMcpServerBinding(directory, binding) {
558
- const path$1 = path.join(directory, MCP_SERVER_BINDING_FILENAME);
559
- await promises.writeFile(path$1, `${JSON.stringify(binding, null, 2)}
560
- `, { mode: 384 });
561
- return path$1;
562
- }
563
- async function readMcpServerBinding(directory) {
564
- try {
565
- const parsed = JSON.parse(await promises.readFile(path.join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
566
- if (typeof parsed !== "object" || parsed === null) return null;
567
- const record = parsed;
568
- 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;
569
- return record;
570
- } catch {
571
- return null;
563
+ var execFileAsync, SIGNER_INSTALL_TIMEOUT_MS, SIGNER_INSTALL_HEARTBEAT_MS;
564
+ var init_signer_runtime = __esm({
565
+ "src/signer-runtime.ts"() {
566
+ init_runtime_manifest();
567
+ init_runtime_spec_override();
568
+ execFileAsync = util.promisify(child_process.execFile);
569
+ SIGNER_INSTALL_TIMEOUT_MS = 6e5;
570
+ SIGNER_INSTALL_HEARTBEAT_MS = 15e3;
572
571
  }
572
+ });
573
+
574
+ // src/connect-error.ts
575
+ function isConnectError(err) {
576
+ return err instanceof ConnectError;
573
577
  }
574
- async function clearMcpServerBinding(directory) {
575
- try {
576
- await promises.rm(path.join(directory, MCP_SERVER_BINDING_FILENAME));
577
- return true;
578
- } catch {
579
- return false;
578
+ var ConnectError;
579
+ var init_connect_error = __esm({
580
+ "src/connect-error.ts"() {
581
+ ConnectError = class extends Error {
582
+ code;
583
+ nextAction;
584
+ details;
585
+ constructor(code, message, nextAction2, details = {}) {
586
+ super(message);
587
+ this.name = "ConnectError";
588
+ this.code = code;
589
+ this.nextAction = nextAction2;
590
+ this.details = details;
591
+ }
592
+ };
580
593
  }
594
+ });
595
+
596
+ // src/tombstone.ts
597
+ var tombstone_exports = {};
598
+ __export(tombstone_exports, {
599
+ ROOT_TOMBSTONES_DIRNAME: () => ROOT_TOMBSTONES_DIRNAME,
600
+ TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
601
+ TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
602
+ defaultTombstonesDir: () => defaultTombstonesDir,
603
+ readAgentTombstone: () => readAgentTombstone,
604
+ readTombstoneRecords: () => readTombstoneRecords,
605
+ tombstonesDirForAgentDirectory: () => tombstonesDirForAgentDirectory,
606
+ tombstonesDirForCredentialRoot: () => tombstonesDirForCredentialRoot,
607
+ writeAgentTombstone: () => writeAgentTombstone
608
+ });
609
+ function defaultTombstonesDir(baseDir) {
610
+ return path.join(baseDir ?? path.join(os.homedir(), ".haven"), "tombstones");
581
611
  }
582
- async function listMcpServerBindings(baseDir, excludeDirectory) {
583
- const root = defaultCredentialRoot(baseDir);
612
+ function tombstonesDirForCredentialRoot(credentialRoot, homeDir = os.homedir()) {
613
+ if (!credentialRoot) return defaultTombstonesDir(path.join(homeDir, ".haven"));
614
+ const root = path.resolve(credentialRoot);
615
+ if (root === path.resolve(homeDir, ".haven", "agents")) return defaultTombstonesDir(path.join(homeDir, ".haven"));
616
+ return path.join(root, ROOT_TOMBSTONES_DIRNAME);
617
+ }
618
+ function tombstonesDirForAgentDirectory(directory, homeDir = os.homedir()) {
619
+ return tombstonesDirForCredentialRoot(path.dirname(path.resolve(directory)), homeDir);
620
+ }
621
+ async function readTombstoneRecords(tombstonesDir) {
622
+ const root = tombstonesDir ?? defaultTombstonesDir();
584
623
  let entries = [];
585
624
  try {
586
625
  entries = await promises.readdir(root);
587
626
  } catch {
588
627
  return [];
589
628
  }
590
- const out = [];
629
+ const records = [];
591
630
  for (const entry of entries) {
592
- const directory = path.join(root, entry);
593
- const binding = await readMcpServerBinding(directory);
594
- if (binding) out.push({ directory, binding });
631
+ if (!entry.endsWith(".json")) continue;
632
+ const recordPath = path.join(root, entry);
633
+ try {
634
+ const parsed = JSON.parse(await promises.readFile(recordPath, "utf8"));
635
+ if (typeof parsed?.agent_id === "string") {
636
+ records.push({ ...parsed, recordPath });
637
+ }
638
+ } catch {
639
+ }
595
640
  }
596
- return out;
641
+ return records;
597
642
  }
598
- var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME, MCP_SERVER_BINDING_FILENAME;
599
- var init_storage = __esm({
600
- "src/storage.ts"() {
601
- REKEY_PENDING_FILENAME = "rekey-pending.json";
602
- REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
603
- CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
604
- MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
605
- }
606
- });
607
- function mcpPackageSpec() {
608
- return `${MCP_RUNTIME_MANIFEST.mcpPackage}@${MCP_RUNTIME_MANIFEST.mcpVersion}`;
643
+ function tombstoneScript(info) {
644
+ const lines = [
645
+ `${TOMBSTONE_MARKER}: this Haven agent was retired.`,
646
+ "",
647
+ ` agent: ${info.agent_id}`,
648
+ ` retired at: ${info.retired_at}`,
649
+ ` reason: ${info.reason}`,
650
+ ...info.replaced_by ? [` replaced by: ${info.replaced_by}`] : [],
651
+ "",
652
+ "This process is running with a wiring snapshot that predates the",
653
+ "retirement \u2014 it loaded its MCP config at startup and has kept it since.",
654
+ "Restart THIS host to pick up the current wiring. If several long-lived",
655
+ "hosts are running (a gateway, a TUI worker, an editor), restart EVERY",
656
+ "one of them: each holds the snapshot from its own start time, so after",
657
+ "a chain of recreations each can be parked on a DIFFERENT old agent.",
658
+ "",
659
+ `Then verify with: ${sdk.connectorRerunCommand("--doctor --runtime <runtime>")}`
660
+ ];
661
+ return [
662
+ "#!/usr/bin/env node",
663
+ `// ${TOMBSTONE_MARKER} \u2014 written by @haven_ai/connect (#1681). Safe to delete`,
664
+ "// once every long-lived MCP host on this machine has been restarted.",
665
+ `process.stderr.write(${JSON.stringify(lines.join("\n") + "\n")})`,
666
+ "process.exit(1)",
667
+ ""
668
+ ].join("\n");
609
669
  }
610
- function sdkPackageSpec() {
611
- return `${MCP_RUNTIME_MANIFEST.sdkPackage}@${MCP_RUNTIME_MANIFEST.sdkVersion}`;
670
+ async function writeAgentTombstone(input) {
671
+ const dirStat = await promises.stat(input.directory).catch(() => null);
672
+ if (!dirStat?.isDirectory()) {
673
+ throw new ConnectError(
674
+ "tombstone_directory_not_found",
675
+ `Not a directory: ${input.directory} \u2014 nothing to tombstone. Agent directories are named by their wiring SLUG when the agent has one, and by the agent id otherwise \u2014 so a path built from an agent id will not exist for a named agent. List ~/.haven/agents (or read the directories from --doctor --json) and pass one of those.`,
676
+ "retry_with_an_existing_agent_directory"
677
+ );
678
+ }
679
+ const info = {
680
+ // reason / replaced_by are persisted to disk and re-emitted to the host's
681
+ // MCP stderr log on EVERY stale probe, potentially for months — redact
682
+ // like every other output path, at the write layer so any future caller
683
+ // inherits it. (#1681 review, finding 1)
684
+ agent_id: input.agentId,
685
+ retired_at: input.retiredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
686
+ reason: redactSecrets(input.reason),
687
+ ...input.replacedBy ? { replaced_by: redactSecrets(input.replacedBy) } : {}
688
+ };
689
+ const binDir = path.join(input.directory, "bin");
690
+ await promises.mkdir(binDir, { recursive: true });
691
+ const wrapperPath = path.join(binDir, "haven-signer.mjs");
692
+ await promises.writeFile(wrapperPath, tombstoneScript(info), "utf8");
693
+ await promises.chmod(wrapperPath, 493);
694
+ const record = JSON.stringify(info, null, 2) + "\n";
695
+ await promises.writeFile(path.join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
696
+ const root = input.tombstonesDir ?? defaultTombstonesDir();
697
+ const recordPath = path.join(root, `${info.agent_id}.json`);
698
+ try {
699
+ await promises.mkdir(root, { recursive: true, mode: 448 });
700
+ await promises.writeFile(recordPath, record, { mode: MIRROR_MODE });
701
+ } catch (err) {
702
+ const code = err?.code;
703
+ return { ...info, recordPath: null, mirrorError: typeof code === "string" && /^E[A-Z]+$/.test(code) ? code : "mirror_write_failed" };
704
+ }
705
+ return { ...info, recordPath };
612
706
  }
613
- function signerPackageSpec() {
614
- return `${MCP_RUNTIME_MANIFEST.signerPackage}@${MCP_RUNTIME_MANIFEST.signerVersion}`;
707
+ async function readAgentTombstone(directory) {
708
+ try {
709
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, TOMBSTONE_FILENAME), "utf8"));
710
+ if (typeof parsed?.agent_id !== "string") return null;
711
+ return parsed;
712
+ } catch {
713
+ return null;
714
+ }
615
715
  }
616
- var MCP_RUNTIME_MANIFEST;
617
- var init_runtime_manifest = __esm({
618
- "src/runtime-manifest.ts"() {
619
- MCP_RUNTIME_MANIFEST = {
620
- mcpPackage: "@haven_ai/mcp",
621
- mcpVersion: mcp.MCP_VERSION,
622
- sdkPackage: "@haven_ai/sdk",
623
- sdkVersion: "0.8.1-alpha.0",
624
- signerPackage: "@haven_ai/signer",
625
- signerVersion: "0.8.1-alpha.0",
626
- // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
627
- // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
628
- // so the guard that was supposed to enforce the floor waved Node v23 through
629
- // — including on the `--local` path where it does run. A hand-maintained
630
- // second copy of a number is a drift waiting to happen; a guard test pins
631
- // this against `package.json`'s `engines.node`.
632
- minimumNodeVersion: sdk.HAVEN_MINIMUM_NODE_VERSION,
633
- supportedClients: ["codex-cli", "codex-desktop", "claude-code"],
634
- requiredTools: mcp.registeredToolNames(),
635
- /**
636
- * The signer MCP's tool surface, DERIVED from the pinned @haven_ai/signer
637
- * package (#1587) — same anti-drift rule as `requiredTools` above: a
638
- * literal list here would rot the first time the signer gains a tool.
639
- * The handshake probe requires all of them.
640
- */
641
- requiredSignerTools: Object.keys(signer.toolSchemas)
642
- };
716
+ var TOMBSTONE_FILENAME, ROOT_TOMBSTONES_DIRNAME, MIRROR_MODE, TOMBSTONE_MARKER;
717
+ var init_tombstone = __esm({
718
+ "src/tombstone.ts"() {
719
+ init_redact();
720
+ init_connect_error();
721
+ TOMBSTONE_FILENAME = "TOMBSTONE.json";
722
+ ROOT_TOMBSTONES_DIRNAME = ".tombstones";
723
+ MIRROR_MODE = 384;
724
+ TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
643
725
  }
644
726
  });
645
- async function writeRuntimeConfig(input, deps = {}) {
646
- switch (input.runtime) {
647
- case "codex-cli":
648
- case "codex-desktop":
649
- return writeCodexConfig(input);
650
- case "cursor":
651
- return writeJsonRuntimeConfig(input, cursorConfigPath(input.homeDir), "mcpServers");
652
- case "vscode":
653
- return writeJsonRuntimeConfig(input, vscodeConfigPath(input.homeDir), "servers");
654
- case "vscode-insiders":
655
- return writeJsonRuntimeConfig(input, vscodeInsidersConfigPath(input.homeDir), "servers");
656
- case "claude-desktop":
657
- return writeJsonRuntimeConfig(input, claudeDesktopConfigPath(input.homeDir), "mcpServers");
658
- case "hermes":
659
- return writeHermesConfig(input, deps);
660
- default:
661
- return {
662
- hostedConfigured: false,
663
- signerConfigured: false,
664
- localMcpConfigured: false,
665
- runtimeMcpMode: "manual",
666
- target: "manual runtime setup",
667
- changed: false,
668
- restartRequired: true,
669
- messages: ["Runtime config needs to be added manually for this agent environment."],
670
- errorCode: "manual_runtime_setup_required"
671
- };
727
+
728
+ // src/storage.ts
729
+ var storage_exports = {};
730
+ __export(storage_exports, {
731
+ CONNECT_OUTCOME_FILENAME: () => CONNECT_OUTCOME_FILENAME,
732
+ MCP_SERVER_BINDING_FILENAME: () => MCP_SERVER_BINDING_FILENAME,
733
+ REKEY_PENDING_FILENAME: () => REKEY_PENDING_FILENAME,
734
+ REKEY_PENDING_TTL_MS: () => REKEY_PENDING_TTL_MS,
735
+ assertDirectoryRecordsSlug: () => assertDirectoryRecordsSlug,
736
+ assertServerSlugAvailable: () => assertServerSlugAvailable,
737
+ clearMcpServerBinding: () => clearMcpServerBinding,
738
+ clearRekeyPending: () => clearRekeyPending,
739
+ defaultAgentDirectory: () => defaultAgentDirectory,
740
+ defaultCredentialRoot: () => defaultCredentialRoot,
741
+ inspectRekeyPending: () => inspectRekeyPending,
742
+ listMcpServerBindings: () => listMcpServerBindings,
743
+ preflightCredentialStorage: () => preflightCredentialStorage,
744
+ readConnectOutcomeRuntime: () => readConnectOutcomeRuntime,
745
+ readMcpServerBinding: () => readMcpServerBinding,
746
+ readRekeyPending: () => readRekeyPending,
747
+ readStoredAccountAddress: () => readStoredAccountAddress,
748
+ readStoredCredentials: () => readStoredCredentials,
749
+ resolveAgentDirectoryBySlug: () => resolveAgentDirectoryBySlug,
750
+ rewriteCredentialFiles: () => rewriteCredentialFiles,
751
+ writeConnectOutcomeRecord: () => writeConnectOutcomeRecord,
752
+ writeCredentialFiles: () => writeCredentialFiles,
753
+ writeMcpServerBinding: () => writeMcpServerBinding,
754
+ writeRekeyPending: () => writeRekeyPending
755
+ });
756
+ async function preflightCredentialStorage(input = {}) {
757
+ const directory = defaultCredentialRoot(input.baseDir);
758
+ await promises.mkdir(directory, { recursive: true, mode: 448 });
759
+ await restrictPermissions(directory, 448, input.warn);
760
+ const probePath = path.join(directory, `.haven-connect-preflight-${crypto__default.default.randomBytes(8).toString("hex")}`);
761
+ try {
762
+ await writeOwnerOnlyJson(probePath, { ok: true }, input.warn);
763
+ } finally {
764
+ await promises.rm(probePath, { force: true }).catch(() => void 0);
672
765
  }
766
+ return directory;
673
767
  }
674
- function buildHostedServer(hostedMcpUrl, apiKey, runtime) {
675
- if (runtime === "vscode" || runtime === "vscode-insiders") {
676
- return {
677
- type: "http",
678
- url: hostedMcpUrl,
679
- headers: { Authorization: `Bearer ${apiKey}` }
680
- };
768
+ async function writeCredentialFiles(input) {
769
+ const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
770
+ await promises.mkdir(directory, { recursive: true, mode: 448 });
771
+ await restrictPermissions(directory, 448, input.warn);
772
+ const identityPath = path.join(directory, "identity.json");
773
+ const signerPath = path.join(directory, "signer.json");
774
+ const agentPath = path.join(directory, "agent.json");
775
+ await assertDoesNotExist(identityPath);
776
+ await assertDoesNotExist(signerPath);
777
+ await assertDoesNotExist(agentPath);
778
+ await writeOwnerOnlyJson(signerPath, signerPayload(input), input.warn);
779
+ try {
780
+ await writeOwnerOnlyJson(identityPath, identityPayload(input), input.warn);
781
+ } catch (err) {
782
+ await promises.rm(signerPath, { force: true }).catch(() => void 0);
783
+ throw err;
784
+ }
785
+ try {
786
+ await writeOwnerOnlyJson(agentPath, agentPayload(input), input.warn);
787
+ } catch (err) {
788
+ await promises.rm(signerPath, { force: true }).catch(() => void 0);
789
+ await promises.rm(identityPath, { force: true }).catch(() => void 0);
790
+ await promises.rm(agentPath, { force: true }).catch(() => void 0);
791
+ throw err;
681
792
  }
793
+ return { directory, identityPath, signerPath, agentPath };
794
+ }
795
+ function signerPayload(input) {
682
796
  return {
683
- url: hostedMcpUrl,
684
- headers: { Authorization: `Bearer ${apiKey}` }
797
+ delegate_key: input.delegateKey,
798
+ delegate_address: input.delegateAddress,
799
+ agent_id: input.agentId,
800
+ account_address: input.accountAddress,
801
+ chain_id: input.chainId,
802
+ network: input.network,
803
+ x402_binding_signer: input.x402BindingSigner,
804
+ note: "Local signer credential. Haven backend never receives this private key."
685
805
  };
686
806
  }
687
- function resolveSignerLaunchSpec(input) {
688
- return input.signerCommand ?? {
689
- command: "npx",
690
- args: ["-y", signerPackageSpec(), "--credentials", input.signerPath]
807
+ function identityPayload(input) {
808
+ return {
809
+ api_key: input.apiKey,
810
+ agent_id: input.agentId,
811
+ account_address: input.accountAddress,
812
+ chain_id: input.chainId,
813
+ network: input.network,
814
+ api_url: input.apiUrl,
815
+ hosted_mcp_url: input.hostedMcpUrl,
816
+ agent_budget: input.agentBudget,
817
+ note: "Haven API key identifies the agent only. It cannot spend without the local signer key and on-chain Haven wallet rules."
691
818
  };
692
819
  }
693
- function buildSignerServer(spec, runtime) {
694
- const server = {
695
- command: spec.command,
696
- args: spec.args
820
+ function agentPayload(input) {
821
+ return {
822
+ agent_id: input.agentId,
823
+ delegate_address: input.delegateAddress,
824
+ account_address: input.accountAddress,
825
+ chain_id: input.chainId,
826
+ network: input.network,
827
+ agent_budget: input.agentBudget,
828
+ note: "Non-secret orientation for the agent: public delegate/Haven wallet identity + configured budget. Contains no API key or signing key. For the live remaining budget, call haven_get_allowances."
697
829
  };
698
- if (runtime === "vscode" || runtime === "vscode-insiders") return { type: "stdio", ...server };
699
- return server;
700
830
  }
701
- function mergeJsonMcpConfig(existingJson, serverRoot, hostedServer, signerServer, names = serverNamesFor(), configPath) {
702
- const config = existingJson?.trim() ? parseJsonObject(existingJson, configPath) : {};
703
- const existingRoot = config[serverRoot];
704
- const servers = existingRoot && typeof existingRoot === "object" && !Array.isArray(existingRoot) ? existingRoot : {};
705
- config[serverRoot] = {
706
- ...servers,
707
- [names.hosted]: hostedServer,
708
- [names.signer]: signerServer
831
+ async function readStoredCredentials(serverName, agentIdOrSlug, baseDir) {
832
+ const directory = serverName ? await resolveAgentDirectoryBySlug(serverName, { root: baseDir }) : agentIdOrSlug ? defaultAgentDirectory(agentIdOrSlug, baseDir) : await discoverSoleAgentDirectory(baseDir);
833
+ const identity = await readJsonFile(path.join(directory, "identity.json"));
834
+ if (!identity) {
835
+ throw new Error(
836
+ `No Haven credentials at ${directory}. Nothing to re-key \u2014 connect this agent first, or pass the --name you wired it under.`
837
+ );
838
+ }
839
+ let discoveredSlug;
840
+ if (!serverName && !agentIdOrSlug) {
841
+ const binding = await readJsonFile(path.join(directory, "mcp-server-binding.json"));
842
+ const hostedName = binding && typeof binding === "object" ? asString(binding.server_name) : void 0;
843
+ if (hostedName && hostedName.startsWith("haven-")) {
844
+ const slug = hostedName.slice("haven-".length);
845
+ try {
846
+ assertValidServerSlug(slug);
847
+ discoveredSlug = slug;
848
+ } catch {
849
+ }
850
+ }
851
+ }
852
+ const agent = await readJsonFile(path.join(directory, "agent.json")) ?? {};
853
+ const signer = await readJsonFile(path.join(directory, "signer.json")) ?? {};
854
+ const agentId = asString(identity.agent_id);
855
+ const apiKey = asString(identity.api_key);
856
+ const apiUrl = asString(identity.api_url);
857
+ const hostedMcpUrl = asString(identity.hosted_mcp_url);
858
+ if (!agentId || !apiKey || !apiUrl || !hostedMcpUrl) {
859
+ throw new Error(
860
+ `The credential set at ${directory} is incomplete (identity.json is missing agent_id, api_key, api_url or hosted_mcp_url). Re-key cannot rebuild it \u2014 reconnect the agent instead.`
861
+ );
862
+ }
863
+ return {
864
+ directory,
865
+ agentId,
866
+ apiKey,
867
+ ...discoveredSlug ? { discoveredSlug } : {},
868
+ apiUrl,
869
+ hostedMcpUrl,
870
+ delegateAddress: asString(agent.delegate_address) ?? asString(signer.delegate_address),
871
+ ...readStoredAccountAddress(identity, agent),
872
+ chainId: typeof identity.chain_id === "number" ? identity.chain_id : void 0,
873
+ network: asString(identity.network),
874
+ x402BindingSigner: asString(signer.x402_binding_signer),
875
+ agentBudget: Array.isArray(identity.agent_budget) ? identity.agent_budget : void 0
709
876
  };
710
- return `${JSON.stringify(config, null, 2)}
711
- `;
712
877
  }
713
- function mergeHermesYaml(existingYaml, hostedServer, signerServer, names = serverNamesFor(), configPath) {
714
- if (!existingYaml?.trim()) {
715
- return renderHermesYaml({ [names.hosted]: hostedServer, [names.signer]: signerServer });
878
+ function readStoredAccountAddress(identity, agent) {
879
+ const fresh = asString(identity.account_address) ?? asString(agent.account_address);
880
+ if (fresh) return { accountAddress: fresh, accountAddressKey: "account_address" };
881
+ const legacy = asString(identity.safe_address) ?? asString(agent.safe_address);
882
+ if (legacy) return { accountAddress: legacy, accountAddressKey: "safe_address" };
883
+ return {};
884
+ }
885
+ async function discoverSoleAgentDirectory(baseDir) {
886
+ const root = defaultCredentialRoot(baseDir);
887
+ let entries = [];
888
+ try {
889
+ entries = await promises.readdir(root);
890
+ } catch {
891
+ throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
716
892
  }
717
- const doc = yaml.parseDocument(existingYaml, { keepSourceTokens: true });
718
- if (doc.errors.length > 0 || !yaml.isMap(doc.contents)) {
719
- throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
893
+ const candidates = [];
894
+ for (const entry of entries) {
895
+ const directory = path.join(root, entry);
896
+ const identity = await readJsonFile(path.join(directory, "identity.json"));
897
+ if (!identity) continue;
898
+ if (!asString(identity.api_key)) continue;
899
+ if (await readJsonFile(path.join(directory, "TOMBSTONE.json"))) continue;
900
+ candidates.push(directory);
720
901
  }
721
- const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
722
- const existingServers = mcpPair && yaml.isMap(mcpPair.value) ? mcpPair.value.toJSON() : {};
723
- const servers = isRecord(existingServers) ? existingServers : {};
724
- const mergedServers = {
725
- ...servers,
726
- [names.hosted]: hostedServer,
727
- [names.signer]: signerServer
728
- };
729
- if (!mcpPair) {
730
- return appendHermesMcpServers(existingYaml, mergedServers);
902
+ if (candidates.length === 1) return candidates[0];
903
+ if (candidates.length === 0) {
904
+ throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
731
905
  }
732
- if (!mcpPair.value?.range) return replaceEmptyHermesMcpServers(existingYaml, mcpPair.key?.range, mergedServers);
733
- return replaceHermesMcpServers(existingYaml, mcpPair.key?.range, mcpPair.value.range, mergedServers);
906
+ throw new Error(
907
+ `Several agents are wired on this machine, so --rekey cannot tell which one you mean:
908
+ ` + candidates.map((d) => ` ${d}`).join("\n") + "\nRe-run with --name <slug> to pick one."
909
+ );
734
910
  }
735
- function mergeHermesEnv(existingEnv, apiKey, envKey = HERMES_API_KEY_ENV) {
736
- if (/[\r\n]/.test(apiKey)) throw new Error("Hermes API key must be a single line");
737
- const assignment = `${envKey}=${apiKey}`;
738
- if (!existingEnv) return `${assignment}
739
- `;
740
- const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
741
- const hasTrailingNewline = /\r?\n$/.test(existingEnv);
742
- const lines = existingEnv.split(/\r?\n/);
743
- if (hasTrailingNewline) lines.pop();
744
- let found = false;
745
- const merged = lines.flatMap((line) => {
746
- if (isHermesEnvAssignment(line, envKey)) {
747
- if (found) return [];
748
- found = true;
749
- return [assignment];
750
- }
751
- if (isAmbiguousHermesEnvLine(line, envKey)) {
752
- throw new Error("Hermes environment contains an ambiguous managed key");
911
+ async function assertDirectoryRecordsSlug(directory, slug) {
912
+ assertValidServerSlug(slug);
913
+ const sidecar = await readRuntimeSidecar(directory);
914
+ const bindingRecord = await readJsonFile(path.join(directory, "mcp-server-binding.json"));
915
+ const bindingSlug = bindingRecord ? asString(bindingRecord.server_name) : void 0;
916
+ const claimed = sidecar?.server_name ?? bindingSlug;
917
+ if (claimed === slug) return;
918
+ if (!claimed && path.basename(directory) === slug) return;
919
+ throw new Error(
920
+ `--name ${slug} and --credentials-dir ${directory} disagree: that directory records ` + (claimed ? `the pair 'haven-${claimed}'` : "no named pair") + `. Pass one of them, not both \u2014 or point --credentials-dir at the directory that records 'haven-${slug}'.`
921
+ );
922
+ }
923
+ async function resolveAgentDirectoryBySlug(slug, opts = {}) {
924
+ assertValidServerSlug(slug);
925
+ const root = defaultCredentialRoot(opts.root);
926
+ let entries = [];
927
+ try {
928
+ entries = await promises.readdir(root);
929
+ } catch {
930
+ throw new Error(
931
+ `No agent directories under ${root} \u2014 nothing records the pair 'haven-${slug}'. Connect this agent first, or pass --credentials-dir <path> for the exact directory.`
932
+ );
933
+ }
934
+ const claims = [];
935
+ const recordedSlugs = [];
936
+ let barePairCount = 0;
937
+ let legacyFolder;
938
+ for (const entry of entries.sort()) {
939
+ const directory = path.join(root, entry);
940
+ const retired = await readJsonFile(path.join(directory, TOMBSTONE_FILENAME)) !== null;
941
+ const sidecar = await readRuntimeSidecar(directory);
942
+ const bindingRecord = await readJsonFile(path.join(directory, "mcp-server-binding.json"));
943
+ const bindingSlug = bindingRecord ? asString(bindingRecord.server_name) : void 0;
944
+ const claimedSlug = sidecar?.server_name ?? bindingSlug;
945
+ if (claimedSlug && !recordedSlugs.includes(claimedSlug)) recordedSlugs.push(claimedSlug);
946
+ if (sidecar && !sidecar.server_name) barePairCount++;
947
+ if (retired && !opts.allowRetired) continue;
948
+ if (claimedSlug === slug) {
949
+ const identity = await readJsonFile(path.join(directory, "identity.json"));
950
+ claims.push({ directory, agentId: identity ? asString(identity.agent_id) ?? null : null, retired });
753
951
  }
754
- return [line];
755
- });
756
- if (!found) {
757
- return `${existingEnv}${hasTrailingNewline ? "" : lineEnding}${assignment}${lineEnding}`;
952
+ if (!claimedSlug && !retired && entry === slug) legacyFolder = directory;
758
953
  }
759
- return `${merged.join(lineEnding)}${hasTrailingNewline ? lineEnding : ""}`;
760
- }
761
- function isHermesEnvAssignment(line, envKey) {
762
- return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(line);
763
- }
764
- function isAmbiguousHermesEnvLine(line, envKey) {
765
- return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}\\b`).test(line);
954
+ if (claims.length > 1) {
955
+ throw new Error(
956
+ `${claims.length} agent directories on this machine record the pair 'haven-${slug}' \u2014 the MCP server name changed hands (mcp_server_name_rebound):
957
+ ` + claims.map((c) => ` \u2022 ${c.agentId ? `agent ${c.agentId}` : "agent (no agent_id recorded)"} \u2014 ${c.directory}${c.retired ? " (retired)" : ""}`).join("\n") + "\nRetire the earlier one with --unwire, or name the directory exactly with --credentials-dir <path>. Nothing was changed."
958
+ );
959
+ }
960
+ if (claims.length === 1) return claims[0].directory;
961
+ if (legacyFolder) return legacyFolder;
962
+ const known = [
963
+ ...recordedSlugs.map((s) => `'haven-${s}'`),
964
+ ...barePairCount > 0 ? [`${barePairCount} unnamed 'haven' / 'haven-signer' pair${barePairCount === 1 ? "" : "s"} (no --name)`] : []
965
+ ];
966
+ throw new Error(
967
+ `No agent directory on this machine records the pair 'haven-${slug}'.` + (known.length > 0 ? ` Directories under ${root} record: ${known.join(", ")}.` : ` Nothing under ${root} records a named pair.`) + " Check the agent id against the Haven agent page, or name the directory exactly with --credentials-dir <path>."
968
+ );
766
969
  }
767
- function removeHermesYaml(existingYaml, names, configPath) {
768
- if (!existingYaml?.trim()) return existingYaml ?? "";
769
- const doc = yaml.parseDocument(existingYaml, { keepSourceTokens: true });
770
- if (doc.errors.length > 0 || !yaml.isMap(doc.contents)) {
771
- throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
970
+ async function rewriteCredentialFiles(input) {
971
+ const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
972
+ const identityPath = path.join(directory, "identity.json");
973
+ const signerPath = path.join(directory, "signer.json");
974
+ const agentPath = path.join(directory, "agent.json");
975
+ const targets = [
976
+ { path: signerPath, payload: signerPayload(input) },
977
+ { path: identityPath, payload: identityPayload(input) },
978
+ { path: agentPath, payload: agentPayload(input) }
979
+ ];
980
+ const originals = /* @__PURE__ */ new Map();
981
+ for (const { path } of targets) {
982
+ originals.set(path, await readRawFile(path));
772
983
  }
773
- const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
774
- if (!mcpPair || !yaml.isMap(mcpPair.value)) return existingYaml;
775
- const toRemove = mcpPair.value.items.filter((item) => {
776
- const name = item.key?.toString();
777
- return name === names.hosted || name === names.signer;
778
- });
779
- if (toRemove.length === 0) return existingYaml;
780
- if (toRemove.length !== mcpPair.value.items.length && toRemove.some((item) => !item.key?.range || !item.value?.range)) {
781
- throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "an MCP server entry cannot be safely removed");
782
- }
783
- let out = existingYaml;
784
- const ranges = toRemove.map((item) => {
785
- const keyStart = item.key.range[0];
786
- const valueEnd = item.value.range[1];
787
- const lineStart = existingYaml.lastIndexOf("\n", keyStart - 1) + 1;
788
- let end = valueEnd;
789
- if (existingYaml.slice(end).startsWith("\r\n")) end += 2;
790
- else if (existingYaml[end] === "\n") end += 1;
791
- return [lineStart, end];
792
- });
793
- ranges.sort((a, b) => b[0] - a[0]);
794
- for (const [start, end] of ranges) out = out.slice(0, start) + out.slice(end);
795
- if (toRemove.length === mcpPair.value.items.length) {
796
- const reparsed = yaml.parseDocument(out, { keepSourceTokens: true });
797
- if (!reparsed.errors.length && yaml.isMap(reparsed.contents)) {
798
- const pair = reparsed.contents.items.find((item) => item.key?.toString() === "mcp_servers");
799
- if (pair && pair.key?.range && pair.value?.range) {
800
- const keyStart = pair.key.range[0];
801
- const valueEnd = pair.value.range[1];
802
- const lineStart = out.lastIndexOf("\n", keyStart - 1) + 1;
803
- let end = valueEnd;
804
- if (out.slice(end).startsWith("\r\n")) end += 2;
805
- else if (out[end] === "\n") end += 1;
806
- out = out.slice(0, lineStart) + out.slice(end);
984
+ const temps = [];
985
+ try {
986
+ for (const { path, payload } of targets) {
987
+ const temp = `${path}.rekey-${crypto__default.default.randomBytes(6).toString("hex")}.tmp`;
988
+ await writeOwnerOnlyJson(temp, payload, input.warn);
989
+ temps.push({ from: temp, to: path });
990
+ }
991
+ for (const { from, to } of temps) {
992
+ await promises.rename(from, to);
993
+ }
994
+ } catch (err) {
995
+ for (const { from } of temps) await promises.rm(from, { force: true }).catch(() => void 0);
996
+ for (const [path, contents] of originals) {
997
+ if (contents === null) {
998
+ await promises.rm(path, { force: true }).catch(() => void 0);
999
+ } else {
1000
+ await promises.writeFile(path, contents, { mode: 384 }).catch(() => void 0);
807
1001
  }
808
1002
  }
1003
+ throw err;
809
1004
  }
810
- return out;
1005
+ return { directory, identityPath, signerPath, agentPath };
811
1006
  }
812
- function removeHermesEnv(existingEnv, envKey) {
813
- if (!existingEnv) return "";
814
- const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
815
- const hasTrailingNewline = /\r?\n$/.test(existingEnv);
816
- const lines = existingEnv.split(/\r?\n/);
817
- if (hasTrailingNewline) lines.pop();
818
- let removed = 0;
819
- const kept = lines.filter((line) => {
820
- if (isHermesEnvAssignment(line, envKey)) {
821
- removed += 1;
822
- return false;
823
- }
824
- if (isAmbiguousHermesEnvLine(line, envKey)) {
825
- throw new Error("Hermes environment contains an ambiguous managed key");
826
- }
827
- return true;
828
- });
829
- if (removed === 0) return existingEnv;
830
- const joined = kept.join(lineEnding);
831
- return hasTrailingNewline ? `${joined}${lineEnding}` : joined;
1007
+ async function writeRekeyPending(directory, pending, warn) {
1008
+ const path$1 = path.join(directory, REKEY_PENDING_FILENAME);
1009
+ await promises.rm(path$1, { force: true }).catch(() => void 0);
1010
+ await writeOwnerOnlyJson(path$1, { ...pending }, warn);
1011
+ return path$1;
832
1012
  }
833
- function removeJsonMcpConfig(existingJson, serverRoot, names, configPath) {
834
- if (!existingJson?.trim()) return existingJson ?? "";
835
- const config = parseJsonObject(existingJson, configPath);
836
- const root = config[serverRoot];
837
- if (!root || typeof root !== "object" || Array.isArray(root)) return existingJson;
838
- const servers = root;
839
- let removed = false;
840
- for (const name of [names.hosted, names.signer]) {
841
- if (name in servers) {
842
- delete servers[name];
843
- removed = true;
844
- }
1013
+ async function readRekeyPending(directory, now = Date.now()) {
1014
+ const path$1 = path.join(directory, REKEY_PENDING_FILENAME);
1015
+ const raw = await readJsonFile(path$1);
1016
+ if (!raw) {
1017
+ throw new Error(
1018
+ `No re-key in progress at ${directory}. Run the connector with --rekey first \u2014 it prints the new signing address to paste into the Haven agent page.`
1019
+ );
845
1020
  }
846
- if (!removed) return existingJson;
847
- if (Object.keys(servers).length === 0) delete config[serverRoot];
848
- return `${JSON.stringify(config, null, 2)}
849
- `;
1021
+ const pending = raw;
1022
+ if (!pending.new_delegate_key || !pending.new_delegate_address || !pending.agent_id) {
1023
+ throw new Error(`The pending re-key at ${path$1} is unreadable. Delete it and start again with --rekey.`);
1024
+ }
1025
+ if (pending.expires_at && Date.parse(pending.expires_at) < now) {
1026
+ throw new Error(
1027
+ `The re-key started at ${pending.started_at} has expired. Start again with --rekey \u2014 the address currently shown in your dashboard is no longer the one this machine holds.`
1028
+ );
1029
+ }
1030
+ return pending;
850
1031
  }
851
- function removeCodexToml(existingToml, names) {
852
- let next = removeTomlTableTree(
853
- removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
854
- `mcp_servers.${names.codexSigner}`
855
- );
856
- return next;
1032
+ async function inspectRekeyPending(directory, now = Date.now()) {
1033
+ const path$1 = path.join(directory, REKEY_PENDING_FILENAME);
1034
+ const raw = await readJsonFile(path$1);
1035
+ if (!raw) {
1036
+ const present = await readRawFile(path$1) !== null;
1037
+ return present ? { state: "unreadable", path: path$1 } : null;
1038
+ }
1039
+ const agentId = asString(raw.agent_id);
1040
+ const newDelegateAddress = asString(raw.new_delegate_address);
1041
+ const startedAt = asString(raw.started_at);
1042
+ const expiresAt = asString(raw.expires_at);
1043
+ if (!agentId || !newDelegateAddress) {
1044
+ return { state: "unreadable", path: path$1, ...startedAt ? { startedAt } : {} };
1045
+ }
1046
+ const expired = expiresAt !== void 0 && Date.parse(expiresAt) < now;
1047
+ return {
1048
+ state: expired ? "expired" : "pending",
1049
+ path: path$1,
1050
+ agentId,
1051
+ newDelegateAddress,
1052
+ ...startedAt ? { startedAt } : {},
1053
+ ...expiresAt ? { expiresAt } : {}
1054
+ };
857
1055
  }
858
- function appendHermesMcpServers(source, servers) {
859
- const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
860
- if (documentEnd) {
861
- const markerStart = documentEnd.index + (documentEnd[0].startsWith("\n") ? 1 : 0);
862
- const beforeMarker = source.slice(0, markerStart);
863
- return `${beforeMarker}${beforeMarker.endsWith("\n") ? "" : "\n"}${renderHermesMcpServers(servers, "")}
864
- ${source.slice(markerStart)}`;
1056
+ async function clearRekeyPending(directory) {
1057
+ await promises.rm(path.join(directory, REKEY_PENDING_FILENAME), { force: true }).catch(() => void 0);
1058
+ }
1059
+ async function readJsonFile(path) {
1060
+ const raw = await readRawFile(path);
1061
+ if (raw === null) return null;
1062
+ try {
1063
+ const parsed = JSON.parse(raw);
1064
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1065
+ } catch {
1066
+ return null;
865
1067
  }
866
- const separator = source.endsWith("\n") ? "" : "\n";
867
- return `${source}${separator}${renderHermesMcpServers(servers, "")}
868
- `;
869
1068
  }
870
- function replaceHermesMcpServers(source, keyRange, valueRange, servers) {
871
- const valueStart = valueRange[0];
872
- const valueEnd = valueRange[1];
873
- const valueLineStart = source.lastIndexOf("\n", valueStart - 1) + 1;
874
- const keyStartsOnValueLine = keyRange?.[0] !== void 0 && keyRange[0] >= valueLineStart;
875
- const nestedIndent = `${source.slice(valueLineStart, keyRange?.[0] ?? valueLineStart)} `;
876
- if (keyStartsOnValueLine) {
877
- const keyStart = keyRange[0];
878
- const keyEnd = keyRange[1];
879
- const replacement2 = `${source.slice(keyStart, keyEnd)}:
880
- ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
881
- const needsTrailingNewline2 = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
882
- return `${source.slice(0, keyStart)}${replacement2}${needsTrailingNewline2 ? "\n" : ""}${source.slice(valueEnd)}`;
1069
+ async function readRawFile(path) {
1070
+ try {
1071
+ return await promises.readFile(path, "utf8");
1072
+ } catch {
1073
+ return null;
883
1074
  }
884
- const indent = source.slice(valueLineStart, valueStart);
885
- const rendered = yaml.stringify(servers, { lineWidth: 120 }).trimEnd();
886
- const replacement = rendered.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
887
- const needsTrailingNewline = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
888
- return `${source.slice(0, valueStart)}${replacement}${needsTrailingNewline ? "\n" : ""}${source.slice(valueEnd)}`;
889
1075
  }
890
- function replaceEmptyHermesMcpServers(source, keyRange, servers) {
891
- if (!keyRange) throw new Error("Hermes config mcp_servers key is invalid");
892
- const keyStart = keyRange[0];
893
- const keyEnd = keyRange[1];
894
- const lineStart = source.lastIndexOf("\n", keyStart - 1) + 1;
895
- const lineEnd = source.indexOf("\n", keyEnd);
896
- const nestedIndent = `${source.slice(lineStart, keyStart)} `;
897
- const replacement = `${source.slice(keyStart, keyEnd)}:
898
- ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
899
- return `${source.slice(0, keyStart)}${replacement}${lineEnd === -1 ? "\n" : source.slice(lineEnd)}`;
1076
+ function asString(value) {
1077
+ return typeof value === "string" && value.length > 0 ? value : void 0;
900
1078
  }
901
- function renderHermesYaml(servers) {
902
- return `${renderHermesMcpServers(servers, "")}
903
- `;
1079
+ async function assertServerSlugAvailable(serverName, baseDir) {
1080
+ const directory = defaultAgentDirectory(serverName, baseDir);
1081
+ try {
1082
+ await promises.stat(path.join(directory, "identity.json"));
1083
+ } catch {
1084
+ return;
1085
+ }
1086
+ throw new Error(
1087
+ `The name "${serverName}" is already wired on this machine (${directory} holds credentials). Pick a different --name, or revoke and remove that agent first \u2014 connect never overwrites credentials.`
1088
+ );
904
1089
  }
905
- function renderHermesMcpServers(servers, indent) {
906
- return `mcp_servers:
907
- ${renderHermesMcpServerEntries(servers, `${indent} `)}`;
1090
+ function defaultAgentDirectory(agentId, baseDir = path.join(os.homedir(), ".haven", "agents")) {
1091
+ return path.resolve(defaultCredentialRoot(baseDir), safePathPart(agentId));
908
1092
  }
909
- function renderHermesMcpServerEntries(servers, indent) {
910
- return yaml.stringify(servers, { lineWidth: 120 }).trimEnd().split("\n").map((line) => `${indent}${line}`).join("\n");
1093
+ function defaultCredentialRoot(baseDir = path.join(os.homedir(), ".haven", "agents")) {
1094
+ return path.resolve(baseDir);
911
1095
  }
912
- function isRecord(value) {
913
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1096
+ async function writeOwnerOnlyJson(path, value, warn) {
1097
+ const json = JSON.stringify(dropUndefined(value), null, 2);
1098
+ await promises.writeFile(path, `${json}
1099
+ `, { mode: 384, flag: "wx" });
1100
+ await restrictPermissions(path, 384, warn);
914
1101
  }
915
- function mergeCodexToml(existingToml, localMcpCommand, names = serverNamesFor()) {
916
- let next = removeTomlTableTree(
917
- removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
918
- `mcp_servers.${names.codexSigner}`
919
- );
920
- next = next.trimEnd();
921
- const block = [
922
- `[mcp_servers.${names.codexHosted}]`,
923
- `command = ${tomlString(localMcpCommand)}`,
924
- "args = []",
925
- "startup_timeout_sec = 120"
926
- ].join("\n");
927
- validateCodexToml(block, "Generated Codex Haven config");
928
- const merged = `${next ? `${next}
929
-
930
- ` : ""}${block}
931
- `;
932
- return merged;
1102
+ function safePathPart(value) {
1103
+ return value.replace(/[^A-Za-z0-9_.-]/g, "_");
933
1104
  }
934
- function mergeCodexTomlHosted(existingToml, hostedMcpUrl, apiKey, signerSpec, names = serverNamesFor()) {
935
- let next = removeTomlTableTree(
936
- removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
937
- `mcp_servers.${names.codexSigner}`
938
- );
939
- next = next.trimEnd();
940
- const block = [
941
- `[mcp_servers.${names.codexHosted}]`,
942
- `url = ${tomlString(hostedMcpUrl)}`,
943
- `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)} }`,
944
- "",
945
- `[mcp_servers.${names.codexSigner}]`,
946
- `command = ${tomlString(signerSpec.command)}`,
947
- `args = [${signerSpec.args.map((arg) => tomlString(arg)).join(", ")}]`,
948
- "startup_timeout_sec = 120"
949
- ].join("\n");
950
- validateCodexToml(block, "Generated Codex Haven config");
951
- const merged = `${next ? `${next}
952
-
953
- ` : ""}${block}
954
- `;
955
- return merged;
1105
+ function dropUndefined(value) {
1106
+ return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
956
1107
  }
957
- async function writeJsonRuntimeConfig(input, target, serverRoot) {
1108
+ async function assertDoesNotExist(path) {
958
1109
  try {
959
- const existing = await readOptional(target);
960
- const merged = mergeJsonMcpConfig(
961
- existing,
962
- serverRoot,
963
- buildHostedServer(input.hostedMcpUrl, input.apiKey, input.runtime),
964
- buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
965
- serverNamesFor(input.serverName),
966
- target
967
- );
968
- await writeOwnerOnlyText(target, merged);
969
- return {
970
- hostedConfigured: true,
971
- signerConfigured: true,
972
- localMcpConfigured: false,
973
- runtimeMcpMode: "hosted_plus_signer",
974
- target: configTargetLabel(input.runtime),
975
- changed: existing !== merged,
976
- restartRequired: input.runtime === "claude-desktop",
977
- messages: [`Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`]
978
- };
1110
+ await promises.access(path);
979
1111
  } catch (err) {
980
- const unreadable = err instanceof UnreadableRuntimeConfigError;
981
- return {
982
- hostedConfigured: false,
983
- signerConfigured: false,
984
- localMcpConfigured: false,
985
- runtimeMcpMode: "hosted_plus_signer",
986
- target: configTargetLabel(input.runtime),
987
- changed: false,
988
- restartRequired: true,
989
- messages: unreadable ? [
990
- `Could not update ${configTargetLabel(input.runtime)}: ${err.message}.`,
991
- `Nothing was written to ${err.configPath}. Fix the JSON there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} ${input.runtime}\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the connector command: its setup token is already used.`
992
- ] : [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
993
- errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
994
- };
1112
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
1113
+ throw err;
995
1114
  }
1115
+ throw new Error(`Refusing to overwrite existing Haven credential file: ${path}`);
996
1116
  }
997
- async function writeHermesConfig(input, deps) {
998
- const target = hermesConfigPath(input.homeDir);
999
- const envTarget = hermesEnvPath(input.homeDir);
1117
+ async function restrictPermissions(path, mode, warn) {
1000
1118
  try {
1001
- const [existing, existingEnv] = await Promise.all([readOptional(target), readOptional(envTarget)]);
1002
- const names = serverNamesFor(input.serverName);
1003
- const hostedServer = {
1004
- ...buildHostedServer(input.hostedMcpUrl, `\${${names.hermesEnvKey}}`, input.runtime),
1005
- enabled: true
1006
- };
1007
- const signerServer = {
1008
- ...buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
1009
- enabled: true
1010
- };
1011
- const merged = mergeHermesYaml(
1012
- existing,
1013
- hostedServer,
1014
- signerServer,
1015
- names,
1016
- target
1017
- );
1018
- const mergedEnv = mergeHermesEnv(existingEnv, input.apiKey, names.hermesEnvKey);
1019
- const writeText = deps.writeOwnerOnlyText ?? writeOwnerOnlyText;
1020
- await writeText(envTarget, mergedEnv);
1021
- try {
1022
- await writeText(target, merged);
1023
- } catch (err) {
1024
- const recovered = await restoreHermesFiles(target, existing, envTarget, existingEnv, writeText);
1025
- if (!recovered) throw new HermesConfigRecoveryError();
1026
- throw err;
1027
- }
1028
- return {
1029
- hostedConfigured: true,
1030
- signerConfigured: true,
1031
- localMcpConfigured: false,
1032
- runtimeMcpMode: "hosted_plus_signer",
1033
- target: "Hermes Agent config",
1034
- changed: existing !== merged || existingEnv !== mergedEnv,
1035
- restartRequired: true,
1036
- messages: [
1037
- `Updated Haven MCP entries in ${target}; stored the hosted MCP identity in ${envTarget}.`,
1038
- "Restart Hermes (start a new session; gateway users: /restart), then verify with `hermes mcp list`, `hermes mcp test haven`, and `hermes mcp test haven-signer`.",
1039
- "If several long-lived Hermes processes are running (a gateway plus TUI workers), restart EVERY one: each loads its MCP wiring at startup, so a process started before this setup keeps using its old snapshot.",
1040
- "If no mcp_* tools appear after restart, ensure the MCP SDK is installed in Hermes: pip install mcp"
1041
- ]
1042
- };
1119
+ await promises.chmod(path, mode);
1043
1120
  } catch (err) {
1044
- const recoveryIncomplete = err instanceof HermesConfigRecoveryError;
1045
- const unreadable = err instanceof UnreadableRuntimeConfigError;
1046
- return {
1047
- hostedConfigured: false,
1048
- signerConfigured: false,
1049
- localMcpConfigured: false,
1050
- runtimeMcpMode: "hosted_plus_signer",
1051
- target: "Hermes Agent config",
1052
- changed: false,
1053
- restartRequired: true,
1054
- messages: unreadable ? [
1055
- `Could not update Hermes Agent config: ${err.message}.`,
1056
- `Nothing was written to ${err.configPath}. Fix the YAML there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} hermes\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the connector command: its setup token is already used.`
1057
- ] : [recoveryIncomplete ? "Could not update Hermes Agent config. Recovery did not complete; inspect the Hermes configuration before retrying." : "Could not update Hermes Agent config. Existing configuration was left unchanged."],
1058
- errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
1059
- };
1121
+ warn?.(
1122
+ `Warning: could not restrict permissions on ${path} to ${mode.toString(8)}. Move this credential to a private location or run chmod ${mode.toString(8)} ${path}. ${err instanceof Error ? err.message : String(err)}`
1123
+ );
1060
1124
  }
1061
1125
  }
1062
- async function restoreHermesFiles(configPath, existingConfig, envPath, existingEnv, writeText) {
1063
- const [envResult, configResult] = await Promise.allSettled([
1064
- restoreOptionalText(envPath, existingEnv, writeText),
1065
- restoreOptionalText(configPath, existingConfig, writeText)
1066
- ]);
1067
- return envResult.status === "fulfilled" && configResult.status === "fulfilled";
1126
+ async function writeConnectOutcomeRecord(directory, outcome, warn) {
1127
+ const path$1 = path.join(directory, CONNECT_OUTCOME_FILENAME);
1128
+ await promises.writeFile(path$1, `${JSON.stringify(outcome, null, 2)}
1129
+ `, { mode: 384 });
1130
+ await restrictPermissions(path$1, 384, warn);
1131
+ return path$1;
1068
1132
  }
1069
- async function restoreOptionalText(path, existing, writeText) {
1070
- if (existing !== null) {
1071
- await writeText(path, existing);
1072
- return;
1073
- }
1133
+ async function readConnectOutcomeRuntime(directory) {
1074
1134
  try {
1075
- await promises.unlink(path);
1076
- } catch (err) {
1077
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
1078
- throw err;
1135
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, CONNECT_OUTCOME_FILENAME), "utf8"));
1136
+ if (typeof parsed !== "object" || parsed === null) return null;
1137
+ const runtime = parsed.runtime;
1138
+ return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
1139
+ } catch {
1140
+ return null;
1079
1141
  }
1080
1142
  }
1081
- async function writeCodexConfig(input) {
1082
- const target = codexConfigPath(input.homeDir);
1083
- const local = input.mode === "local";
1143
+ async function writeMcpServerBinding(directory, binding) {
1144
+ const path$1 = path.join(directory, MCP_SERVER_BINDING_FILENAME);
1145
+ await promises.writeFile(path$1, `${JSON.stringify(binding, null, 2)}
1146
+ `, { mode: 384 });
1147
+ return path$1;
1148
+ }
1149
+ async function readMcpServerBinding(directory) {
1084
1150
  try {
1085
- const existing = await readOptional(target);
1086
- if (local) {
1087
- if (!input.localMcpCommand) {
1088
- throw new Error("local MCP wrapper command is required");
1089
- }
1090
- const merged2 = mergeCodexToml(existing ?? "", input.localMcpCommand, serverNamesFor(input.serverName));
1091
- await writeOwnerOnlyText(target, merged2);
1092
- return {
1093
- hostedConfigured: false,
1094
- signerConfigured: true,
1095
- localMcpConfigured: true,
1096
- runtimeMcpMode: "local_stdio",
1097
- target: configTargetLabel(input.runtime),
1098
- changed: existing !== merged2,
1099
- restartRequired: true,
1100
- messages: [
1101
- `Updated local Haven MCP entry in ${configTargetLabel(input.runtime)}.`
1102
- ]
1103
- };
1104
- }
1105
- const merged = mergeCodexTomlHosted(
1106
- existing ?? "",
1107
- input.hostedMcpUrl,
1108
- input.apiKey,
1109
- resolveSignerLaunchSpec(input),
1110
- serverNamesFor(input.serverName)
1111
- );
1112
- await writeOwnerOnlyText(target, merged);
1113
- return {
1114
- hostedConfigured: true,
1115
- signerConfigured: true,
1116
- localMcpConfigured: false,
1117
- runtimeMcpMode: "hosted_plus_signer",
1118
- target: configTargetLabel(input.runtime),
1119
- changed: existing !== merged,
1120
- restartRequired: true,
1121
- messages: [
1122
- `Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`
1123
- ]
1124
- };
1125
- } catch (err) {
1126
- const invalidToml = err instanceof InvalidCodexTomlError;
1127
- return {
1128
- hostedConfigured: false,
1129
- signerConfigured: false,
1130
- localMcpConfigured: false,
1131
- runtimeMcpMode: local ? "local_stdio" : "hosted_plus_signer",
1132
- target: configTargetLabel(input.runtime),
1133
- changed: false,
1134
- restartRequired: true,
1135
- messages: [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
1136
- errorCode: invalidToml ? "codex_config_invalid" : "runtime_config_write_failed"
1137
- };
1151
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
1152
+ if (typeof parsed !== "object" || parsed === null) return null;
1153
+ const record = parsed;
1154
+ 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;
1155
+ return record;
1156
+ } catch {
1157
+ return null;
1138
1158
  }
1139
1159
  }
1140
- async function readOptional(path) {
1160
+ async function clearMcpServerBinding(directory) {
1141
1161
  try {
1142
- return await promises.readFile(path, "utf8");
1143
- } catch (err) {
1144
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
1145
- throw err;
1162
+ await promises.rm(path.join(directory, MCP_SERVER_BINDING_FILENAME));
1163
+ return true;
1164
+ } catch {
1165
+ return false;
1146
1166
  }
1147
1167
  }
1148
- async function writeOwnerOnlyText(path$1, value) {
1149
- await promises.mkdir(path.dirname(path$1), { recursive: true, mode: 448 });
1150
- await promises.writeFile(path$1, value, { mode: 384 });
1151
- await promises.chmod(path$1, 384).catch(() => void 0);
1152
- }
1153
- function parseJsonObject(value, configPath) {
1154
- let parsed;
1168
+ async function listMcpServerBindings(baseDir, excludeDirectory) {
1169
+ const root = defaultCredentialRoot(baseDir);
1170
+ let entries = [];
1155
1171
  try {
1156
- parsed = JSON.parse(value);
1172
+ entries = await promises.readdir(root);
1157
1173
  } catch {
1158
- throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "it is not valid JSON");
1174
+ return [];
1159
1175
  }
1160
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1161
- throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "the top level is not a JSON object");
1176
+ const out = [];
1177
+ for (const entry of entries) {
1178
+ const directory = path.join(root, entry);
1179
+ if (excludeDirectory && directory === excludeDirectory) continue;
1180
+ const binding = await readMcpServerBinding(directory);
1181
+ if (binding) out.push({ directory, binding });
1162
1182
  }
1163
- return parsed;
1183
+ return out;
1164
1184
  }
1165
- function removeTomlTableTree(toml, table) {
1166
- const lines = toml.split(/\r?\n/);
1167
- const kept = [];
1168
- let skipping = false;
1169
- for (const line of lines) {
1170
- const trimmed = line.trim();
1171
- const tableName = tomlTableName(trimmed);
1172
- if (tableName) {
1173
- skipping = tableName === table || tableName.startsWith(`${table}.`);
1174
- if (skipping) continue;
1185
+ var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME, MCP_SERVER_BINDING_FILENAME;
1186
+ var init_storage = __esm({
1187
+ "src/storage.ts"() {
1188
+ init_server_names();
1189
+ init_signer_runtime();
1190
+ init_tombstone();
1191
+ REKEY_PENDING_FILENAME = "rekey-pending.json";
1192
+ REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
1193
+ CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
1194
+ MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
1195
+ }
1196
+ });
1197
+ async function writeRuntimeConfig(input, deps = {}) {
1198
+ switch (input.runtime) {
1199
+ case "codex-cli":
1200
+ case "codex-desktop":
1201
+ return writeCodexConfig(input);
1202
+ case "cursor":
1203
+ return writeJsonRuntimeConfig(input, cursorConfigPath(input.homeDir), "mcpServers");
1204
+ case "vscode":
1205
+ return writeJsonRuntimeConfig(input, vscodeConfigPath(input.homeDir), "servers");
1206
+ case "vscode-insiders":
1207
+ return writeJsonRuntimeConfig(input, vscodeInsidersConfigPath(input.homeDir), "servers");
1208
+ case "claude-desktop":
1209
+ return writeJsonRuntimeConfig(input, claudeDesktopConfigPath(input.homeDir), "mcpServers");
1210
+ case "hermes":
1211
+ return writeHermesConfig(input, deps);
1212
+ default:
1213
+ return {
1214
+ hostedConfigured: false,
1215
+ signerConfigured: false,
1216
+ localMcpConfigured: false,
1217
+ runtimeMcpMode: "manual",
1218
+ target: "manual runtime setup",
1219
+ changed: false,
1220
+ restartRequired: true,
1221
+ messages: ["Runtime config needs to be added manually for this agent environment."],
1222
+ errorCode: "manual_runtime_setup_required"
1223
+ };
1224
+ }
1225
+ }
1226
+ function buildHostedServer(hostedMcpUrl, apiKey, runtime) {
1227
+ if (runtime === "vscode" || runtime === "vscode-insiders") {
1228
+ return {
1229
+ type: "http",
1230
+ url: hostedMcpUrl,
1231
+ headers: { Authorization: `Bearer ${apiKey}` }
1232
+ };
1233
+ }
1234
+ return {
1235
+ url: hostedMcpUrl,
1236
+ headers: { Authorization: `Bearer ${apiKey}` }
1237
+ };
1238
+ }
1239
+ function resolveSignerLaunchSpec(input) {
1240
+ return input.signerCommand ?? {
1241
+ command: "npx",
1242
+ args: ["-y", signerPackageSpec(), "--credentials", input.signerPath]
1243
+ };
1244
+ }
1245
+ function buildSignerServer(spec, runtime) {
1246
+ const server = {
1247
+ command: spec.command,
1248
+ args: spec.args
1249
+ };
1250
+ if (runtime === "vscode" || runtime === "vscode-insiders") return { type: "stdio", ...server };
1251
+ return server;
1252
+ }
1253
+ function mergeJsonMcpConfig(existingJson, serverRoot, hostedServer, signerServer, names = serverNamesFor(), configPath) {
1254
+ const config = existingJson?.trim() ? parseJsonObject(existingJson, configPath) : {};
1255
+ const existingRoot = config[serverRoot];
1256
+ const servers = existingRoot && typeof existingRoot === "object" && !Array.isArray(existingRoot) ? existingRoot : {};
1257
+ config[serverRoot] = {
1258
+ ...servers,
1259
+ [names.hosted]: hostedServer,
1260
+ [names.signer]: signerServer
1261
+ };
1262
+ return `${JSON.stringify(config, null, 2)}
1263
+ `;
1264
+ }
1265
+ function mergeHermesYaml(existingYaml, hostedServer, signerServer, names = serverNamesFor(), configPath) {
1266
+ if (!existingYaml?.trim()) {
1267
+ return renderHermesYaml({ [names.hosted]: hostedServer, [names.signer]: signerServer });
1268
+ }
1269
+ const doc = yaml.parseDocument(existingYaml, { keepSourceTokens: true });
1270
+ if (doc.errors.length > 0 || !yaml.isMap(doc.contents)) {
1271
+ throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
1272
+ }
1273
+ const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
1274
+ const existingServers = mcpPair && yaml.isMap(mcpPair.value) ? mcpPair.value.toJSON() : {};
1275
+ const servers = isRecord(existingServers) ? existingServers : {};
1276
+ const mergedServers = {
1277
+ ...servers,
1278
+ [names.hosted]: hostedServer,
1279
+ [names.signer]: signerServer
1280
+ };
1281
+ if (!mcpPair) {
1282
+ return appendHermesMcpServers(existingYaml, mergedServers);
1283
+ }
1284
+ if (!mcpPair.value?.range) return replaceEmptyHermesMcpServers(existingYaml, mcpPair.key?.range, mergedServers);
1285
+ return replaceHermesMcpServers(existingYaml, mcpPair.key?.range, mcpPair.value.range, mergedServers);
1286
+ }
1287
+ function mergeHermesEnv(existingEnv, apiKey, envKey = HERMES_API_KEY_ENV) {
1288
+ if (/[\r\n]/.test(apiKey)) throw new Error("Hermes API key must be a single line");
1289
+ const assignment = `${envKey}=${apiKey}`;
1290
+ if (!existingEnv) return `${assignment}
1291
+ `;
1292
+ const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
1293
+ const hasTrailingNewline = /\r?\n$/.test(existingEnv);
1294
+ const lines = existingEnv.split(/\r?\n/);
1295
+ if (hasTrailingNewline) lines.pop();
1296
+ let found = false;
1297
+ const merged = lines.flatMap((line) => {
1298
+ if (isHermesEnvAssignment(line, envKey)) {
1299
+ if (found) return [];
1300
+ found = true;
1301
+ return [assignment];
1302
+ }
1303
+ if (isAmbiguousHermesEnvLine(line, envKey)) {
1304
+ throw new Error("Hermes environment contains an ambiguous managed key");
1305
+ }
1306
+ return [line];
1307
+ });
1308
+ if (!found) {
1309
+ return `${existingEnv}${hasTrailingNewline ? "" : lineEnding}${assignment}${lineEnding}`;
1310
+ }
1311
+ return `${merged.join(lineEnding)}${hasTrailingNewline ? lineEnding : ""}`;
1312
+ }
1313
+ function isHermesEnvAssignment(line, envKey) {
1314
+ return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(line);
1315
+ }
1316
+ function isAmbiguousHermesEnvLine(line, envKey) {
1317
+ return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}\\b`).test(line);
1318
+ }
1319
+ function removeHermesYaml(existingYaml, names, configPath) {
1320
+ if (!existingYaml?.trim()) return existingYaml ?? "";
1321
+ const doc = yaml.parseDocument(existingYaml, { keepSourceTokens: true });
1322
+ if (doc.errors.length > 0 || !yaml.isMap(doc.contents)) {
1323
+ throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
1324
+ }
1325
+ const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
1326
+ if (!mcpPair || !yaml.isMap(mcpPair.value)) return existingYaml;
1327
+ const toRemove = mcpPair.value.items.filter((item) => {
1328
+ const name = item.key?.toString();
1329
+ return name === names.hosted || name === names.signer;
1330
+ });
1331
+ if (toRemove.length === 0) return existingYaml;
1332
+ if (toRemove.length !== mcpPair.value.items.length && toRemove.some((item) => !item.key?.range || !item.value?.range)) {
1333
+ throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "an MCP server entry cannot be safely removed");
1334
+ }
1335
+ let out = existingYaml;
1336
+ const ranges = toRemove.map((item) => {
1337
+ const keyStart = item.key.range[0];
1338
+ const valueEnd = item.value.range[1];
1339
+ const lineStart = existingYaml.lastIndexOf("\n", keyStart - 1) + 1;
1340
+ let end = valueEnd;
1341
+ if (existingYaml.slice(end).startsWith("\r\n")) end += 2;
1342
+ else if (existingYaml[end] === "\n") end += 1;
1343
+ return [lineStart, end];
1344
+ });
1345
+ ranges.sort((a, b) => b[0] - a[0]);
1346
+ for (const [start, end] of ranges) out = out.slice(0, start) + out.slice(end);
1347
+ if (toRemove.length === mcpPair.value.items.length) {
1348
+ const reparsed = yaml.parseDocument(out, { keepSourceTokens: true });
1349
+ if (!reparsed.errors.length && yaml.isMap(reparsed.contents)) {
1350
+ const pair = reparsed.contents.items.find((item) => item.key?.toString() === "mcp_servers");
1351
+ if (pair && pair.key?.range && pair.value?.range) {
1352
+ const keyStart = pair.key.range[0];
1353
+ const valueEnd = pair.value.range[1];
1354
+ const lineStart = out.lastIndexOf("\n", keyStart - 1) + 1;
1355
+ let end = valueEnd;
1356
+ if (out.slice(end).startsWith("\r\n")) end += 2;
1357
+ else if (out[end] === "\n") end += 1;
1358
+ out = out.slice(0, lineStart) + out.slice(end);
1359
+ }
1360
+ }
1361
+ }
1362
+ return out;
1363
+ }
1364
+ function removeHermesEnv(existingEnv, envKey) {
1365
+ if (!existingEnv) return "";
1366
+ const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
1367
+ const hasTrailingNewline = /\r?\n$/.test(existingEnv);
1368
+ const lines = existingEnv.split(/\r?\n/);
1369
+ if (hasTrailingNewline) lines.pop();
1370
+ let removed = 0;
1371
+ const kept = lines.filter((line) => {
1372
+ if (isHermesEnvAssignment(line, envKey)) {
1373
+ removed += 1;
1374
+ return false;
1375
+ }
1376
+ if (isAmbiguousHermesEnvLine(line, envKey)) {
1377
+ throw new Error("Hermes environment contains an ambiguous managed key");
1378
+ }
1379
+ return true;
1380
+ });
1381
+ if (removed === 0) return existingEnv;
1382
+ const joined = kept.join(lineEnding);
1383
+ return hasTrailingNewline ? `${joined}${lineEnding}` : joined;
1384
+ }
1385
+ function removeJsonMcpConfig(existingJson, serverRoot, names, configPath) {
1386
+ if (!existingJson?.trim()) return existingJson ?? "";
1387
+ const config = parseJsonObject(existingJson, configPath);
1388
+ const root = config[serverRoot];
1389
+ if (!root || typeof root !== "object" || Array.isArray(root)) return existingJson;
1390
+ const servers = root;
1391
+ let removed = false;
1392
+ for (const name of [names.hosted, names.signer]) {
1393
+ if (name in servers) {
1394
+ delete servers[name];
1395
+ removed = true;
1396
+ }
1397
+ }
1398
+ if (!removed) return existingJson;
1399
+ if (Object.keys(servers).length === 0) delete config[serverRoot];
1400
+ return `${JSON.stringify(config, null, 2)}
1401
+ `;
1402
+ }
1403
+ function removeCodexToml(existingToml, names) {
1404
+ let next = removeTomlTableTree(
1405
+ removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
1406
+ `mcp_servers.${names.codexSigner}`
1407
+ );
1408
+ return next;
1409
+ }
1410
+ function appendHermesMcpServers(source, servers) {
1411
+ const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
1412
+ if (documentEnd) {
1413
+ const markerStart = documentEnd.index + (documentEnd[0].startsWith("\n") ? 1 : 0);
1414
+ const beforeMarker = source.slice(0, markerStart);
1415
+ return `${beforeMarker}${beforeMarker.endsWith("\n") ? "" : "\n"}${renderHermesMcpServers(servers, "")}
1416
+ ${source.slice(markerStart)}`;
1417
+ }
1418
+ const separator = source.endsWith("\n") ? "" : "\n";
1419
+ return `${source}${separator}${renderHermesMcpServers(servers, "")}
1420
+ `;
1421
+ }
1422
+ function replaceHermesMcpServers(source, keyRange, valueRange, servers) {
1423
+ const valueStart = valueRange[0];
1424
+ const valueEnd = valueRange[1];
1425
+ const valueLineStart = source.lastIndexOf("\n", valueStart - 1) + 1;
1426
+ const keyStartsOnValueLine = keyRange?.[0] !== void 0 && keyRange[0] >= valueLineStart;
1427
+ const nestedIndent = `${source.slice(valueLineStart, keyRange?.[0] ?? valueLineStart)} `;
1428
+ if (keyStartsOnValueLine) {
1429
+ const keyStart = keyRange[0];
1430
+ const keyEnd = keyRange[1];
1431
+ const replacement2 = `${source.slice(keyStart, keyEnd)}:
1432
+ ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
1433
+ const needsTrailingNewline2 = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
1434
+ return `${source.slice(0, keyStart)}${replacement2}${needsTrailingNewline2 ? "\n" : ""}${source.slice(valueEnd)}`;
1435
+ }
1436
+ const indent = source.slice(valueLineStart, valueStart);
1437
+ const rendered = yaml.stringify(servers, { lineWidth: 120 }).trimEnd();
1438
+ const replacement = rendered.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
1439
+ const needsTrailingNewline = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
1440
+ return `${source.slice(0, valueStart)}${replacement}${needsTrailingNewline ? "\n" : ""}${source.slice(valueEnd)}`;
1441
+ }
1442
+ function replaceEmptyHermesMcpServers(source, keyRange, servers) {
1443
+ if (!keyRange) throw new Error("Hermes config mcp_servers key is invalid");
1444
+ const keyStart = keyRange[0];
1445
+ const keyEnd = keyRange[1];
1446
+ const lineStart = source.lastIndexOf("\n", keyStart - 1) + 1;
1447
+ const lineEnd = source.indexOf("\n", keyEnd);
1448
+ const nestedIndent = `${source.slice(lineStart, keyStart)} `;
1449
+ const replacement = `${source.slice(keyStart, keyEnd)}:
1450
+ ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
1451
+ return `${source.slice(0, keyStart)}${replacement}${lineEnd === -1 ? "\n" : source.slice(lineEnd)}`;
1452
+ }
1453
+ function renderHermesYaml(servers) {
1454
+ return `${renderHermesMcpServers(servers, "")}
1455
+ `;
1456
+ }
1457
+ function renderHermesMcpServers(servers, indent) {
1458
+ return `mcp_servers:
1459
+ ${renderHermesMcpServerEntries(servers, `${indent} `)}`;
1460
+ }
1461
+ function renderHermesMcpServerEntries(servers, indent) {
1462
+ return yaml.stringify(servers, { lineWidth: 120 }).trimEnd().split("\n").map((line) => `${indent}${line}`).join("\n");
1463
+ }
1464
+ function isRecord(value) {
1465
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1466
+ }
1467
+ function mergeCodexToml(existingToml, localMcpCommand, names = serverNamesFor()) {
1468
+ let next = removeTomlTableTree(
1469
+ removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
1470
+ `mcp_servers.${names.codexSigner}`
1471
+ );
1472
+ next = next.trimEnd();
1473
+ const block = [
1474
+ `[mcp_servers.${names.codexHosted}]`,
1475
+ `command = ${tomlString(localMcpCommand)}`,
1476
+ "args = []",
1477
+ "startup_timeout_sec = 120"
1478
+ ].join("\n");
1479
+ validateCodexToml(block, "Generated Codex Haven config");
1480
+ const merged = `${next ? `${next}
1481
+
1482
+ ` : ""}${block}
1483
+ `;
1484
+ return merged;
1485
+ }
1486
+ function mergeCodexTomlHosted(existingToml, hostedMcpUrl, apiKey, signerSpec, names = serverNamesFor()) {
1487
+ let next = removeTomlTableTree(
1488
+ removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
1489
+ `mcp_servers.${names.codexSigner}`
1490
+ );
1491
+ next = next.trimEnd();
1492
+ const block = [
1493
+ `[mcp_servers.${names.codexHosted}]`,
1494
+ `url = ${tomlString(hostedMcpUrl)}`,
1495
+ `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)} }`,
1496
+ "",
1497
+ `[mcp_servers.${names.codexSigner}]`,
1498
+ `command = ${tomlString(signerSpec.command)}`,
1499
+ `args = [${signerSpec.args.map((arg) => tomlString(arg)).join(", ")}]`,
1500
+ "startup_timeout_sec = 120"
1501
+ ].join("\n");
1502
+ validateCodexToml(block, "Generated Codex Haven config");
1503
+ const merged = `${next ? `${next}
1504
+
1505
+ ` : ""}${block}
1506
+ `;
1507
+ return merged;
1508
+ }
1509
+ async function writeJsonRuntimeConfig(input, target, serverRoot) {
1510
+ try {
1511
+ const existing = await readOptional(target);
1512
+ const merged = mergeJsonMcpConfig(
1513
+ existing,
1514
+ serverRoot,
1515
+ buildHostedServer(input.hostedMcpUrl, input.apiKey, input.runtime),
1516
+ buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
1517
+ serverNamesFor(input.serverName),
1518
+ target
1519
+ );
1520
+ await writeOwnerOnlyText(target, merged);
1521
+ return {
1522
+ hostedConfigured: true,
1523
+ signerConfigured: true,
1524
+ localMcpConfigured: false,
1525
+ runtimeMcpMode: "hosted_plus_signer",
1526
+ target: configTargetLabel(input.runtime),
1527
+ changed: existing !== merged,
1528
+ restartRequired: input.runtime === "claude-desktop",
1529
+ messages: [`Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`]
1530
+ };
1531
+ } catch (err) {
1532
+ const unreadable = err instanceof UnreadableRuntimeConfigError;
1533
+ return {
1534
+ hostedConfigured: false,
1535
+ signerConfigured: false,
1536
+ localMcpConfigured: false,
1537
+ runtimeMcpMode: "hosted_plus_signer",
1538
+ target: configTargetLabel(input.runtime),
1539
+ changed: false,
1540
+ restartRequired: true,
1541
+ messages: unreadable ? [
1542
+ `Could not update ${configTargetLabel(input.runtime)}: ${err.message}.`,
1543
+ `Nothing was written to ${err.configPath}. Fix the JSON there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} ${input.runtime}\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the connector command: its setup token is already used.`
1544
+ ] : [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
1545
+ errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
1546
+ };
1547
+ }
1548
+ }
1549
+ async function writeHermesConfig(input, deps) {
1550
+ const target = hermesConfigPath(input.homeDir);
1551
+ const envTarget = hermesEnvPath(input.homeDir);
1552
+ try {
1553
+ const [existing, existingEnv] = await Promise.all([readOptional(target), readOptional(envTarget)]);
1554
+ const names = serverNamesFor(input.serverName);
1555
+ const hostedServer = {
1556
+ ...buildHostedServer(input.hostedMcpUrl, `\${${names.hermesEnvKey}}`, input.runtime),
1557
+ enabled: true
1558
+ };
1559
+ const signerServer = {
1560
+ ...buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
1561
+ enabled: true
1562
+ };
1563
+ const merged = mergeHermesYaml(
1564
+ existing,
1565
+ hostedServer,
1566
+ signerServer,
1567
+ names,
1568
+ target
1569
+ );
1570
+ const mergedEnv = mergeHermesEnv(existingEnv, input.apiKey, names.hermesEnvKey);
1571
+ const writeText = deps.writeOwnerOnlyText ?? writeOwnerOnlyText;
1572
+ await writeText(envTarget, mergedEnv);
1573
+ try {
1574
+ await writeText(target, merged);
1575
+ } catch (err) {
1576
+ const recovered = await restoreHermesFiles(target, existing, envTarget, existingEnv, writeText);
1577
+ if (!recovered) throw new HermesConfigRecoveryError();
1578
+ throw err;
1175
1579
  }
1176
- if (!skipping) kept.push(line);
1580
+ return {
1581
+ hostedConfigured: true,
1582
+ signerConfigured: true,
1583
+ localMcpConfigured: false,
1584
+ runtimeMcpMode: "hosted_plus_signer",
1585
+ target: "Hermes Agent config",
1586
+ changed: existing !== merged || existingEnv !== mergedEnv,
1587
+ restartRequired: true,
1588
+ messages: [
1589
+ `Updated Haven MCP entries in ${target}; stored the hosted MCP identity in ${envTarget}.`,
1590
+ // #3737: name the pair actually written — a bare run prints exactly the
1591
+ // historical sentence; a named run names its own pair.
1592
+ `Restart Hermes (start a new session; gateway users: /restart), then verify with \`hermes mcp list\`, \`hermes mcp test ${names.hosted}\`, and \`hermes mcp test ${names.signer}\`.`,
1593
+ "If several long-lived Hermes processes are running (a gateway plus TUI workers), restart EVERY one: each loads its MCP wiring at startup, so a process started before this setup keeps using its old snapshot.",
1594
+ "If no mcp_* tools appear after restart, ensure the MCP SDK is installed in Hermes: pip install mcp"
1595
+ ]
1596
+ };
1597
+ } catch (err) {
1598
+ const recoveryIncomplete = err instanceof HermesConfigRecoveryError;
1599
+ const unreadable = err instanceof UnreadableRuntimeConfigError;
1600
+ return {
1601
+ hostedConfigured: false,
1602
+ signerConfigured: false,
1603
+ localMcpConfigured: false,
1604
+ runtimeMcpMode: "hosted_plus_signer",
1605
+ target: "Hermes Agent config",
1606
+ changed: false,
1607
+ restartRequired: true,
1608
+ messages: unreadable ? [
1609
+ `Could not update Hermes Agent config: ${err.message}.`,
1610
+ `Nothing was written to ${err.configPath}. Fix the YAML there (or move the file aside), then run \`${REPAIR_COMMAND_PREFIX} hermes\` to write the Haven entries from the credentials already stored on this machine. Do not re-run the connector command: its setup token is already used.`
1611
+ ] : [recoveryIncomplete ? "Could not update Hermes Agent config. Recovery did not complete; inspect the Hermes configuration before retrying." : "Could not update Hermes Agent config. Existing configuration was left unchanged."],
1612
+ errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
1613
+ };
1177
1614
  }
1178
- return kept.join("\n");
1179
1615
  }
1180
- function tomlTableName(line) {
1181
- if (line.startsWith("[[") && line.endsWith("]]")) return line.slice(2, -2).trim();
1182
- if (line.startsWith("[") && line.endsWith("]")) return line.slice(1, -1).trim();
1183
- return null;
1616
+ async function restoreHermesFiles(configPath, existingConfig, envPath, existingEnv, writeText) {
1617
+ const [envResult, configResult] = await Promise.allSettled([
1618
+ restoreOptionalText(envPath, existingEnv, writeText),
1619
+ restoreOptionalText(configPath, existingConfig, writeText)
1620
+ ]);
1621
+ return envResult.status === "fulfilled" && configResult.status === "fulfilled";
1184
1622
  }
1185
- function validateCodexToml(toml, label = "Codex config") {
1186
- const lines = toml.split(/\r?\n/);
1187
- let pendingValue = null;
1188
- for (let index = 0; index < lines.length; index += 1) {
1189
- const raw = lines[index];
1190
- const line = stripTomlComment(raw).trim();
1191
- if (!line) continue;
1192
- if (pendingValue) {
1193
- pendingValue.value = `${pendingValue.value}
1194
- ${line}`;
1195
- if (hasBalancedTomlContainers(pendingValue.value)) {
1196
- if (!isTomlValue(pendingValue.value)) {
1197
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1198
- }
1199
- pendingValue = null;
1200
- }
1201
- continue;
1202
- }
1203
- if (isTomlTable(line)) continue;
1204
- const equalsIndex = line.indexOf("=");
1205
- if (equalsIndex <= 0) {
1206
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1207
- }
1208
- const key = line.slice(0, equalsIndex).trim();
1209
- const value = line.slice(equalsIndex + 1).trim();
1210
- if (!isTomlKey(key)) {
1211
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1212
- }
1213
- if (startsTomlContainer(value) && !hasBalancedTomlContainers(value)) {
1214
- pendingValue = { value, line: index + 1 };
1215
- continue;
1216
- }
1217
- if (!isTomlValue(value)) {
1218
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1219
- }
1623
+ async function restoreOptionalText(path, existing, writeText) {
1624
+ if (existing !== null) {
1625
+ await writeText(path, existing);
1626
+ return;
1220
1627
  }
1221
- if (pendingValue) {
1222
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1628
+ try {
1629
+ await promises.unlink(path);
1630
+ } catch (err) {
1631
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
1632
+ throw err;
1223
1633
  }
1224
1634
  }
1225
- function isTomlTable(line) {
1226
- const table = tomlTableName(line);
1227
- return Boolean(table && splitTomlDottedKey(table).every(isTomlKeyPart));
1228
- }
1229
- function isTomlKey(value) {
1230
- return splitTomlDottedKey(value).every(isTomlKeyPart);
1231
- }
1232
- function splitTomlDottedKey(value) {
1233
- const parts = [];
1234
- let current = "";
1235
- let quote = null;
1236
- let escaped = false;
1237
- for (let i = 0; i < value.length; i += 1) {
1238
- const char = value[i];
1239
- if (quote) {
1240
- current += char;
1241
- if (quote === '"' && char === "\\" && !escaped) {
1242
- escaped = true;
1243
- continue;
1635
+ async function writeCodexConfig(input) {
1636
+ const target = codexConfigPath(input.homeDir);
1637
+ const local = input.mode === "local";
1638
+ try {
1639
+ const existing = await readOptional(target);
1640
+ if (local) {
1641
+ if (!input.localMcpCommand) {
1642
+ throw new Error("local MCP wrapper command is required");
1244
1643
  }
1245
- if (char === quote && !escaped) quote = null;
1246
- escaped = false;
1247
- continue;
1248
- }
1249
- if (char === '"' || char === "'") {
1250
- quote = char;
1251
- current += char;
1252
- continue;
1253
- }
1254
- if (char === ".") {
1255
- parts.push(current.trim());
1256
- current = "";
1257
- continue;
1644
+ const merged2 = mergeCodexToml(existing ?? "", input.localMcpCommand, serverNamesFor(input.serverName));
1645
+ await writeOwnerOnlyText(target, merged2);
1646
+ return {
1647
+ hostedConfigured: false,
1648
+ signerConfigured: true,
1649
+ localMcpConfigured: true,
1650
+ runtimeMcpMode: "local_stdio",
1651
+ target: configTargetLabel(input.runtime),
1652
+ changed: existing !== merged2,
1653
+ restartRequired: true,
1654
+ messages: [
1655
+ `Updated local Haven MCP entry in ${configTargetLabel(input.runtime)}.`
1656
+ ]
1657
+ };
1258
1658
  }
1259
- current += char;
1659
+ const merged = mergeCodexTomlHosted(
1660
+ existing ?? "",
1661
+ input.hostedMcpUrl,
1662
+ input.apiKey,
1663
+ resolveSignerLaunchSpec(input),
1664
+ serverNamesFor(input.serverName)
1665
+ );
1666
+ await writeOwnerOnlyText(target, merged);
1667
+ return {
1668
+ hostedConfigured: true,
1669
+ signerConfigured: true,
1670
+ localMcpConfigured: false,
1671
+ runtimeMcpMode: "hosted_plus_signer",
1672
+ target: configTargetLabel(input.runtime),
1673
+ changed: existing !== merged,
1674
+ restartRequired: true,
1675
+ messages: [
1676
+ `Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`
1677
+ ]
1678
+ };
1679
+ } catch (err) {
1680
+ const invalidToml = err instanceof InvalidCodexTomlError;
1681
+ return {
1682
+ hostedConfigured: false,
1683
+ signerConfigured: false,
1684
+ localMcpConfigured: false,
1685
+ runtimeMcpMode: local ? "local_stdio" : "hosted_plus_signer",
1686
+ target: configTargetLabel(input.runtime),
1687
+ changed: false,
1688
+ restartRequired: true,
1689
+ messages: [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
1690
+ errorCode: invalidToml ? "codex_config_invalid" : "runtime_config_write_failed"
1691
+ };
1260
1692
  }
1261
- parts.push(current.trim());
1262
- return quote ? [] : parts;
1263
1693
  }
1264
- function isTomlKeyPart(value) {
1265
- return isTomlBareKey(value) || isTomlQuotedString(value);
1266
- }
1267
- function isTomlBareKey(value) {
1268
- return /^[A-Za-z0-9_-]+$/.test(value);
1269
- }
1270
- function isTomlValue(value) {
1271
- if (!value) return false;
1272
- if (isTomlQuotedString(value)) return true;
1273
- if (/^(true|false)$/i.test(value)) return true;
1274
- if (/^[+-]?(?:inf|nan)$/i.test(value)) return true;
1275
- if (/^[+-]?(?:0|[1-9][0-9_]*)(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?$/.test(value)) return true;
1276
- if (/^\d{4}-\d{2}-\d{2}(?:[Tt ][0-9:.+-Zz]+)?$/.test(value)) return true;
1277
- if (value.startsWith("[") && value.endsWith("]") || value.startsWith("{") && value.endsWith("}")) {
1278
- return hasBalancedTomlContainers(value);
1694
+ async function readOptional(path) {
1695
+ try {
1696
+ return await promises.readFile(path, "utf8");
1697
+ } catch (err) {
1698
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
1699
+ throw err;
1279
1700
  }
1280
- return false;
1281
1701
  }
1282
- function startsTomlContainer(value) {
1283
- return value.startsWith("[") || value.startsWith("{");
1702
+ async function writeOwnerOnlyText(path$1, value) {
1703
+ await promises.mkdir(path.dirname(path$1), { recursive: true, mode: 448 });
1704
+ await promises.writeFile(path$1, value, { mode: 384 });
1705
+ await promises.chmod(path$1, 384).catch(() => void 0);
1284
1706
  }
1285
- function isTomlQuotedString(value) {
1286
- if (value.startsWith('"""') || value.startsWith("'''")) {
1287
- const marker = value.slice(0, 3);
1288
- return value.length >= 6 && value.endsWith(marker);
1707
+ function parseJsonObject(value, configPath) {
1708
+ let parsed;
1709
+ try {
1710
+ parsed = JSON.parse(value);
1711
+ } catch {
1712
+ throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "it is not valid JSON");
1289
1713
  }
1290
- if ((!value.startsWith('"') || !value.endsWith('"')) && (!value.startsWith("'") || !value.endsWith("'"))) {
1291
- return false;
1714
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1715
+ throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "the top level is not a JSON object");
1292
1716
  }
1293
- return hasBalancedTomlContainers(value);
1717
+ return parsed;
1718
+ }
1719
+ function removeTomlTableTree(toml, table) {
1720
+ const lines = toml.split(/\r?\n/);
1721
+ const kept = [];
1722
+ let skipping = false;
1723
+ for (const line of lines) {
1724
+ const trimmed = line.trim();
1725
+ const tableName = tomlTableName(trimmed);
1726
+ if (tableName) {
1727
+ skipping = tableName === table || tableName.startsWith(`${table}.`);
1728
+ if (skipping) continue;
1729
+ }
1730
+ if (!skipping) kept.push(line);
1731
+ }
1732
+ return kept.join("\n");
1733
+ }
1734
+ function tomlTableName(line) {
1735
+ if (line.startsWith("[[") && line.endsWith("]]")) return line.slice(2, -2).trim();
1736
+ if (line.startsWith("[") && line.endsWith("]")) return line.slice(1, -1).trim();
1737
+ return null;
1294
1738
  }
1295
- function stripTomlComment(value) {
1296
- let quote = null;
1297
- let escaped = false;
1298
- for (let i = 0; i < value.length; i += 1) {
1299
- const char = value[i];
1300
- if (quote) {
1301
- if (quote === '"' && char === "\\" && !escaped) {
1302
- escaped = true;
1303
- continue;
1739
+ function validateCodexToml(toml, label = "Codex config") {
1740
+ const lines = toml.split(/\r?\n/);
1741
+ let pendingValue = null;
1742
+ for (let index = 0; index < lines.length; index += 1) {
1743
+ const raw = lines[index];
1744
+ const line = stripTomlComment(raw).trim();
1745
+ if (!line) continue;
1746
+ if (pendingValue) {
1747
+ pendingValue.value = `${pendingValue.value}
1748
+ ${line}`;
1749
+ if (hasBalancedTomlContainers(pendingValue.value)) {
1750
+ if (!isTomlValue(pendingValue.value)) {
1751
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1752
+ }
1753
+ pendingValue = null;
1304
1754
  }
1305
- if (char === quote && !escaped) quote = null;
1306
- escaped = false;
1307
1755
  continue;
1308
1756
  }
1309
- if (char === '"' || char === "'") {
1310
- quote = char;
1757
+ if (isTomlTable(line)) continue;
1758
+ const equalsIndex = line.indexOf("=");
1759
+ if (equalsIndex <= 0) {
1760
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1761
+ }
1762
+ const key = line.slice(0, equalsIndex).trim();
1763
+ const value = line.slice(equalsIndex + 1).trim();
1764
+ if (!isTomlKey(key)) {
1765
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1766
+ }
1767
+ if (startsTomlContainer(value) && !hasBalancedTomlContainers(value)) {
1768
+ pendingValue = { value, line: index + 1 };
1311
1769
  continue;
1312
1770
  }
1313
- if (char === "#") return value.slice(0, i);
1771
+ if (!isTomlValue(value)) {
1772
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1773
+ }
1774
+ }
1775
+ if (pendingValue) {
1776
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1314
1777
  }
1315
- return value;
1316
1778
  }
1317
- function hasBalancedTomlContainers(value) {
1318
- const stack = [];
1779
+ function isTomlTable(line) {
1780
+ const table = tomlTableName(line);
1781
+ return Boolean(table && splitTomlDottedKey(table).every(isTomlKeyPart));
1782
+ }
1783
+ function isTomlKey(value) {
1784
+ return splitTomlDottedKey(value).every(isTomlKeyPart);
1785
+ }
1786
+ function splitTomlDottedKey(value) {
1787
+ const parts = [];
1788
+ let current = "";
1319
1789
  let quote = null;
1320
1790
  let escaped = false;
1321
1791
  for (let i = 0; i < value.length; i += 1) {
1322
1792
  const char = value[i];
1323
1793
  if (quote) {
1794
+ current += char;
1324
1795
  if (quote === '"' && char === "\\" && !escaped) {
1325
1796
  escaped = true;
1326
1797
  continue;
@@ -1331,638 +1802,449 @@ function hasBalancedTomlContainers(value) {
1331
1802
  }
1332
1803
  if (char === '"' || char === "'") {
1333
1804
  quote = char;
1805
+ current += char;
1334
1806
  continue;
1335
1807
  }
1336
- if (char === "[" || char === "{") {
1337
- stack.push(char);
1338
- continue;
1339
- }
1340
- if (char === "]") {
1341
- if (stack.pop() !== "[") return false;
1808
+ if (char === ".") {
1809
+ parts.push(current.trim());
1810
+ current = "";
1342
1811
  continue;
1343
1812
  }
1344
- if (char === "}") {
1345
- if (stack.pop() !== "{") return false;
1346
- }
1347
- }
1348
- return stack.length === 0 && quote === null;
1349
- }
1350
- function tomlString(value) {
1351
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1352
- }
1353
- function runtimeConfigPathFor(runtime, homeDir = os.homedir()) {
1354
- switch (runtime) {
1355
- case "cursor":
1356
- return cursorConfigPath(homeDir);
1357
- case "codex-cli":
1358
- case "codex-desktop":
1359
- return codexConfigPath(homeDir);
1360
- case "vscode":
1361
- return vscodeConfigPath(homeDir);
1362
- case "vscode-insiders":
1363
- return vscodeInsidersConfigPath(homeDir);
1364
- case "claude-desktop":
1365
- return claudeDesktopConfigPath(homeDir);
1366
- case "hermes":
1367
- return hermesConfigPath(homeDir);
1368
- default:
1369
- return null;
1370
- }
1371
- }
1372
- function cursorConfigPath(homeDir = os.homedir()) {
1373
- return path.resolve(homeDir, ".cursor", "mcp.json");
1374
- }
1375
- function codexConfigPath(homeDir = os.homedir()) {
1376
- return path.resolve(homeDir, ".codex", "config.toml");
1377
- }
1378
- function vscodeConfigPath(homeDir = os.homedir()) {
1379
- if (os.platform() === "darwin") return path.resolve(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
1380
- if (os.platform() === "win32") {
1381
- return path.resolve(process.env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
1382
- }
1383
- return path.resolve(homeDir, ".config", "Code", "User", "mcp.json");
1384
- }
1385
- function vscodeInsidersConfigPath(homeDir = os.homedir()) {
1386
- if (os.platform() === "darwin") return path.resolve(homeDir, "Library", "Application Support", "Code - Insiders", "User", "mcp.json");
1387
- if (os.platform() === "win32") {
1388
- return path.resolve(process.env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"), "Code - Insiders", "User", "mcp.json");
1389
- }
1390
- return path.resolve(homeDir, ".config", "Code - Insiders", "User", "mcp.json");
1391
- }
1392
- function claudeDesktopConfigPath(homeDir = os.homedir()) {
1393
- if (os.platform() === "darwin") {
1394
- return path.resolve(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1395
- }
1396
- if (os.platform() === "win32") {
1397
- return path.resolve(process.env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1398
- }
1399
- return path.resolve(homeDir, ".config", "Claude", "claude_desktop_config.json");
1400
- }
1401
- function hermesConfigPath(homeDir) {
1402
- return path.join(hermesHomePath(homeDir), "config.yaml");
1403
- }
1404
- function hermesEnvPath(homeDir) {
1405
- return path.join(hermesHomePath(homeDir), ".env");
1406
- }
1407
- function hermesHomePath(homeDir) {
1408
- return process.env.HERMES_HOME ?? path.join(homeDir ?? os.homedir(), ".hermes");
1409
- }
1410
- function configTargetLabel(runtime) {
1411
- switch (runtime) {
1412
- case "codex-cli":
1413
- return "Codex CLI config";
1414
- case "codex-desktop":
1415
- return "Codex Desktop config";
1416
- case "cursor":
1417
- return "Cursor MCP config";
1418
- case "vscode":
1419
- return "VS Code MCP config";
1420
- case "vscode-insiders":
1421
- return "VS Code Insiders MCP config";
1422
- case "claude-desktop":
1423
- return "Claude Desktop config";
1424
- default:
1425
- return "runtime MCP config";
1426
- }
1427
- }
1428
- var HERMES_API_KEY_ENV, HermesConfigRecoveryError, InvalidCodexTomlError, REPAIR_COMMAND_PREFIX, UnreadableRuntimeConfigError;
1429
- var init_config_writers = __esm({
1430
- "src/config-writers.ts"() {
1431
- init_runtime_manifest();
1432
- init_server_names();
1433
- HERMES_API_KEY_ENV = "MCP_HAVEN_API_KEY";
1434
- HermesConfigRecoveryError = class extends Error {
1435
- constructor() {
1436
- super("Hermes configuration recovery did not complete");
1437
- this.name = "HermesConfigRecoveryError";
1438
- }
1439
- };
1440
- InvalidCodexTomlError = class extends Error {
1441
- constructor(message) {
1442
- super(message);
1443
- this.name = "InvalidCodexTomlError";
1444
- }
1445
- };
1446
- REPAIR_COMMAND_PREFIX = sdk.connectorRerunCommand("--doctor --repair --runtime");
1447
- UnreadableRuntimeConfigError = class extends Error {
1448
- configPath;
1449
- constructor(configPath, detail) {
1450
- super(`${configPath} is not a config Haven can merge into (${detail})`);
1451
- this.name = "UnreadableRuntimeConfigError";
1452
- this.configPath = configPath;
1453
- }
1454
- };
1455
- }
1456
- });
1457
- async function acknowledgeLocalMcpConsent(identityPath, signerPath, log) {
1458
- try {
1459
- const input = await buildLocalMcpConsentInput(identityPath, signerPath);
1460
- const decision = await mcp.ensureConsent(input, {
1461
- credentialsPath: identityPath,
1462
- writeAck: true,
1463
- out: log ? { write: (chunk) => writeLogChunk(log, chunk) } : void 0
1464
- });
1465
- return {
1466
- acknowledged: decision.ok,
1467
- hash: decision.hash,
1468
- reason: decision.reason
1469
- };
1470
- } catch (err) {
1471
- return {
1472
- acknowledged: false,
1473
- error: err instanceof Error ? err.message : String(err)
1474
- };
1475
- }
1476
- }
1477
- async function getLocalMcpConsentStatus(identityPath, signerPath) {
1478
- try {
1479
- const input = await buildLocalMcpConsentInput(identityPath, signerPath);
1480
- const hash = mcp.computeConsentHash(input);
1481
- const stored = await readLocalMcpAckFile(localMcpAckPath(identityPath));
1482
- if (stored === hash) {
1483
- return { acknowledged: true, hash, reason: "ack_file_match" };
1484
- }
1485
- return {
1486
- acknowledged: false,
1487
- hash,
1488
- reason: stored ? "ack_file_mismatch" : "ack_file_missing"
1489
- };
1490
- } catch (err) {
1491
- return {
1492
- acknowledged: false,
1493
- error: err instanceof Error ? err.message : String(err)
1494
- };
1813
+ current += char;
1495
1814
  }
1815
+ parts.push(current.trim());
1816
+ return quote ? [] : parts;
1496
1817
  }
1497
- function localMcpAckPath(identityPath) {
1498
- return path.resolve(`${identityPath}.ack.json`);
1499
- }
1500
- async function buildLocalMcpConsentInput(identityPath, signerPath) {
1501
- const credentials = await mcp.loadCredentials({ identityPath, signerPath });
1502
- const unavailableDuringSetup = {
1503
- getAllowances: async () => {
1504
- throw new Error("Haven approval is not complete yet.");
1505
- }
1506
- };
1507
- return mcp.consentInputFromClient(
1508
- unavailableDuringSetup,
1509
- {
1510
- apiKey: credentials.apiKey,
1511
- apiUrl: credentials.apiUrl,
1512
- agentId: credentials.agentId,
1513
- accountAddress: credentials.accountAddress,
1514
- delegateAddress: credentials.delegateAddress,
1515
- chainId: credentials.chainId,
1516
- allowanceSummary: credentials.allowanceSummary
1517
- },
1518
- mcp.registeredToolNames()
1519
- );
1520
- }
1521
- async function readLocalMcpAckFile(path) {
1522
- try {
1523
- const parsed = JSON.parse(await promises.readFile(path, "utf8"));
1524
- return typeof parsed.ack === "string" ? parsed.ack : null;
1525
- } catch {
1526
- return null;
1527
- }
1818
+ function isTomlKeyPart(value) {
1819
+ return isTomlBareKey(value) || isTomlQuotedString(value);
1528
1820
  }
1529
- function writeLogChunk(log, chunk) {
1530
- const message = String(chunk).trimEnd();
1531
- if (message) log(message);
1821
+ function isTomlBareKey(value) {
1822
+ return /^[A-Za-z0-9_-]+$/.test(value);
1532
1823
  }
1533
- var init_local_mcp_consent = __esm({
1534
- "src/local-mcp-consent.ts"() {
1535
- }
1536
- });
1537
- async function probeHostedAgentIdentity(apiKey, apiUrl, fetchImpl = fetch) {
1538
- let response;
1539
- try {
1540
- response = await fetchWithTimeout(fetchImpl, `${apiUrl.replace(/\/+$/, "")}/machine-payments/agent`, {
1541
- method: "GET",
1542
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }
1543
- });
1544
- } catch {
1545
- return { status: "network_error" };
1546
- }
1547
- if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
1548
- if (!response.ok) return { status: "bad_response" };
1549
- try {
1550
- const payload = JSON.parse(await response.text());
1551
- if (typeof payload?.delegate_address !== "string") return { status: "bad_response" };
1552
- return { status: "ok", agentId: payload.id, delegateAddress: payload.delegate_address };
1553
- } catch {
1554
- return { status: "bad_response" };
1824
+ function isTomlValue(value) {
1825
+ if (!value) return false;
1826
+ if (isTomlQuotedString(value)) return true;
1827
+ if (/^(true|false)$/i.test(value)) return true;
1828
+ if (/^[+-]?(?:inf|nan)$/i.test(value)) return true;
1829
+ if (/^[+-]?(?:0|[1-9][0-9_]*)(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?$/.test(value)) return true;
1830
+ if (/^\d{4}-\d{2}-\d{2}(?:[Tt ][0-9:.+-Zz]+)?$/.test(value)) return true;
1831
+ if (value.startsWith("[") && value.endsWith("]") || value.startsWith("{") && value.endsWith("}")) {
1832
+ return hasBalancedTomlContainers(value);
1555
1833
  }
1834
+ return false;
1556
1835
  }
1557
- async function probeHostedMcpTools(apiKey, hostedMcpUrl, fetchImpl = fetch) {
1558
- let response;
1559
- try {
1560
- response = await fetchWithTimeout(fetchImpl, hostedMcpUrl, {
1561
- method: "POST",
1562
- headers: {
1563
- Authorization: `Bearer ${apiKey}`,
1564
- "Content-Type": "application/json",
1565
- Accept: "application/json, text/event-stream"
1566
- },
1567
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
1568
- });
1569
- } catch {
1570
- return { status: "network_error" };
1571
- }
1572
- if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
1573
- if (!response.ok) return { status: "bad_response" };
1574
- try {
1575
- const payload = parseJsonRpcPayload(await response.text());
1576
- if (!payload || payload.error) return { status: "bad_response" };
1577
- const tools = payload.result?.tools;
1578
- return { status: "ok", toolCount: Array.isArray(tools) ? tools.length : void 0 };
1579
- } catch {
1580
- return { status: "bad_response" };
1581
- }
1836
+ function startsTomlContainer(value) {
1837
+ return value.startsWith("[") || value.startsWith("{");
1582
1838
  }
1583
- async function probeLocalSignerCredential(signerPath) {
1584
- try {
1585
- const parsed = JSON.parse(await promises.readFile(signerPath, "utf8"));
1586
- return Boolean(
1587
- parsed && typeof parsed === "object" && "delegate_key" in parsed && typeof parsed.delegate_key === "string"
1588
- );
1589
- } catch {
1839
+ function isTomlQuotedString(value) {
1840
+ if (value.startsWith('"""') || value.startsWith("'''")) {
1841
+ const marker = value.slice(0, 3);
1842
+ return value.length >= 6 && value.endsWith(marker);
1843
+ }
1844
+ if ((!value.startsWith('"') || !value.endsWith('"')) && (!value.startsWith("'") || !value.endsWith("'"))) {
1590
1845
  return false;
1591
1846
  }
1847
+ return hasBalancedTomlContainers(value);
1592
1848
  }
1593
- async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
1594
- return new Promise((resolve11) => {
1595
- const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
1596
- let stdout = "";
1597
- let settled = false;
1598
- let sawInitialize = false;
1599
- let serverInfo;
1600
- let capabilities;
1601
- const finish = (result) => {
1602
- if (settled) return;
1603
- settled = true;
1604
- clearTimeout(timeout);
1605
- child.kill();
1606
- resolve11(result);
1607
- };
1608
- const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1609
- child.on("error", () => finish({ status: "process_error" }));
1610
- child.on("exit", (code) => {
1611
- if (!settled && code !== 0) finish({ status: "process_error" });
1612
- });
1613
- child.stdout.on("data", (chunk) => {
1614
- stdout += chunk.toString("utf8");
1615
- const lines = stdout.split(/\r?\n/);
1616
- stdout = lines.pop() ?? "";
1617
- for (const line of lines) {
1618
- const trimmed = line.trim();
1619
- if (!trimmed) continue;
1620
- let payload;
1621
- try {
1622
- payload = JSON.parse(trimmed);
1623
- } catch {
1624
- continue;
1625
- }
1626
- if (payload.error) {
1627
- finish({ status: "bad_response" });
1628
- return;
1629
- }
1630
- if (payload.id === 1 && !sawInitialize) {
1631
- sawInitialize = true;
1632
- const init = payload.result;
1633
- serverInfo = init?.serverInfo;
1634
- capabilities = init?.capabilities;
1635
- writeJsonRpc(child, { jsonrpc: "2.0", method: "notifications/initialized", params: {} });
1636
- writeJsonRpc(child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
1637
- continue;
1638
- }
1639
- if (payload.id === 2) {
1640
- const tools = payload.result?.tools;
1641
- const toolNames = Array.isArray(tools) ? tools.map((tool) => tool && typeof tool === "object" && "name" in tool ? tool.name : void 0).filter((name) => typeof name === "string") : [];
1642
- const missing = requiredTools.filter((name) => !toolNames.includes(name));
1643
- finish({ status: missing.length === 0 ? "ok" : "missing_tools", toolNames, serverInfo, capabilities });
1644
- return;
1645
- }
1646
- }
1647
- });
1648
- writeJsonRpc(child, {
1649
- jsonrpc: "2.0",
1650
- id: 1,
1651
- method: "initialize",
1652
- params: {
1653
- protocolVersion: "2025-06-18",
1654
- capabilities: {},
1655
- clientInfo: { name: "haven-connect-probe", version: "0.0.0" }
1849
+ function stripTomlComment(value) {
1850
+ let quote = null;
1851
+ let escaped = false;
1852
+ for (let i = 0; i < value.length; i += 1) {
1853
+ const char = value[i];
1854
+ if (quote) {
1855
+ if (quote === '"' && char === "\\" && !escaped) {
1856
+ escaped = true;
1857
+ continue;
1656
1858
  }
1657
- });
1658
- });
1659
- }
1660
- function writeJsonRpc(child, payload) {
1661
- child.stdin?.write(`${JSON.stringify(payload)}
1662
- `);
1663
- }
1664
- function parseJsonRpcPayload(raw) {
1665
- const trimmed = raw.trim();
1666
- if (!trimmed) return null;
1667
- if (trimmed.startsWith("{")) {
1668
- try {
1669
- return JSON.parse(trimmed);
1670
- } catch {
1671
- return null;
1859
+ if (char === quote && !escaped) quote = null;
1860
+ escaped = false;
1861
+ continue;
1672
1862
  }
1673
- }
1674
- const dataLines = trimmed.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim());
1675
- for (let i = dataLines.length - 1; i >= 0; i -= 1) {
1676
- try {
1677
- return JSON.parse(dataLines[i]);
1678
- } catch {
1863
+ if (char === '"' || char === "'") {
1864
+ quote = char;
1865
+ continue;
1679
1866
  }
1867
+ if (char === "#") return value.slice(0, i);
1680
1868
  }
1681
- return null;
1869
+ return value;
1682
1870
  }
1683
- async function fetchWithTimeout(fetchImpl, url, init) {
1684
- const controller = new AbortController();
1685
- const timeout = setTimeout(() => controller.abort(), 3e3);
1686
- try {
1687
- return await fetchImpl(url, { ...init, signal: controller.signal });
1688
- } finally {
1689
- clearTimeout(timeout);
1871
+ function hasBalancedTomlContainers(value) {
1872
+ const stack = [];
1873
+ let quote = null;
1874
+ let escaped = false;
1875
+ for (let i = 0; i < value.length; i += 1) {
1876
+ const char = value[i];
1877
+ if (quote) {
1878
+ if (quote === '"' && char === "\\" && !escaped) {
1879
+ escaped = true;
1880
+ continue;
1881
+ }
1882
+ if (char === quote && !escaped) quote = null;
1883
+ escaped = false;
1884
+ continue;
1885
+ }
1886
+ if (char === '"' || char === "'") {
1887
+ quote = char;
1888
+ continue;
1889
+ }
1890
+ if (char === "[" || char === "{") {
1891
+ stack.push(char);
1892
+ continue;
1893
+ }
1894
+ if (char === "]") {
1895
+ if (stack.pop() !== "[") return false;
1896
+ continue;
1897
+ }
1898
+ if (char === "}") {
1899
+ if (stack.pop() !== "{") return false;
1900
+ }
1690
1901
  }
1902
+ return stack.length === 0 && quote === null;
1691
1903
  }
1692
- var init_probes = __esm({
1693
- "src/probes.ts"() {
1694
- }
1695
- });
1696
- function resolveRuntimeSpecOverride(env) {
1697
- const override = {};
1698
- for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
1699
- const variable = RUNTIME_SPEC_ENV[pkg];
1700
- const raw = env[variable];
1701
- if (raw === void 0) continue;
1702
- override[pkg] = validateSpec(variable, raw);
1904
+ function tomlString(value) {
1905
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1906
+ }
1907
+ function runtimeConfigPathFor(runtime, homeDir = os.homedir()) {
1908
+ switch (runtime) {
1909
+ case "cursor":
1910
+ return cursorConfigPath(homeDir);
1911
+ case "codex-cli":
1912
+ case "codex-desktop":
1913
+ return codexConfigPath(homeDir);
1914
+ case "vscode":
1915
+ return vscodeConfigPath(homeDir);
1916
+ case "vscode-insiders":
1917
+ return vscodeInsidersConfigPath(homeDir);
1918
+ case "claude-desktop":
1919
+ return claudeDesktopConfigPath(homeDir);
1920
+ case "hermes":
1921
+ return hermesConfigPath(homeDir);
1922
+ default:
1923
+ return null;
1703
1924
  }
1704
- return Object.keys(override).length > 0 ? override : void 0;
1705
1925
  }
1706
- function validateSpec(variable, raw) {
1707
- if (raw.length === 0 || raw.trim().length === 0) {
1708
- throw new RuntimeSpecOverrideError(variable, "it is empty");
1926
+ function cursorConfigPath(homeDir = os.homedir()) {
1927
+ return path.resolve(homeDir, ".cursor", "mcp.json");
1928
+ }
1929
+ function codexConfigPath(homeDir = os.homedir()) {
1930
+ return path.resolve(homeDir, ".codex", "config.toml");
1931
+ }
1932
+ function vscodeConfigPath(homeDir = os.homedir()) {
1933
+ if (os.platform() === "darwin") return path.resolve(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
1934
+ if (os.platform() === "win32") {
1935
+ return path.resolve(process.env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
1709
1936
  }
1710
- if (/\s/.test(raw)) {
1711
- throw new RuntimeSpecOverrideError(variable, "it contains whitespace");
1937
+ return path.resolve(homeDir, ".config", "Code", "User", "mcp.json");
1938
+ }
1939
+ function vscodeInsidersConfigPath(homeDir = os.homedir()) {
1940
+ if (os.platform() === "darwin") return path.resolve(homeDir, "Library", "Application Support", "Code - Insiders", "User", "mcp.json");
1941
+ if (os.platform() === "win32") {
1942
+ return path.resolve(process.env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"), "Code - Insiders", "User", "mcp.json");
1712
1943
  }
1713
- if (/[\u0000-\u001f\u007f]/.test(raw)) {
1714
- throw new RuntimeSpecOverrideError(variable, "it contains a control character");
1944
+ return path.resolve(homeDir, ".config", "Code - Insiders", "User", "mcp.json");
1945
+ }
1946
+ function claudeDesktopConfigPath(homeDir = os.homedir()) {
1947
+ if (os.platform() === "darwin") {
1948
+ return path.resolve(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1715
1949
  }
1716
- const meta = SHELL_METACHARACTERS.exec(raw);
1717
- if (meta) {
1718
- throw new RuntimeSpecOverrideError(variable, `it contains the shell metacharacter ${JSON.stringify(meta[0])}`);
1950
+ if (os.platform() === "win32") {
1951
+ return path.resolve(process.env.APPDATA ?? path.join(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1719
1952
  }
1720
- return raw;
1953
+ return path.resolve(homeDir, ".config", "Claude", "claude_desktop_config.json");
1721
1954
  }
1722
- function runtimeSpecOverrideDirectoryKey(resolvedSpecs) {
1723
- const digest = crypto.createHash("sha256").update(resolvedSpecs.join("\n")).digest("hex");
1724
- return `override-${digest.slice(0, 12)}`;
1955
+ function hermesConfigPath(homeDir) {
1956
+ return path.join(hermesHomePath(homeDir), "config.yaml");
1725
1957
  }
1726
- function overrideApplies(override, packages) {
1727
- return override !== void 0 && packages.some((pkg) => override[pkg] !== void 0);
1958
+ function hermesEnvPath(homeDir) {
1959
+ return path.join(hermesHomePath(homeDir), ".env");
1728
1960
  }
1729
- function runtimeSpecOverrideNotice(runtimeLabel, override, pinned, runtimeDirectory) {
1730
- const lines = [`RUNTIME SPEC OVERRIDE ACTIVE for the local Haven ${runtimeLabel} \u2014 this is NOT the pinned manifest.`];
1731
- for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
1732
- const spec = override[pkg];
1733
- if (spec === void 0) continue;
1734
- const pin = pinned[pkg];
1735
- lines.push(` ${RUNTIME_SPEC_ENV[pkg]}=${spec}${pin ? ` (instead of ${pin})` : ""}`);
1736
- }
1737
- lines.push(` Installing into ${runtimeDirectory} \u2014 the pinned runtime directory is untouched.`);
1738
- lines.push(" Unset the variable(s) and re-run to return to the pinned manifest.");
1739
- return lines;
1961
+ function hermesHomePath(homeDir) {
1962
+ return process.env.HERMES_HOME ?? path.join(homeDir ?? os.homedir(), ".hermes");
1740
1963
  }
1741
- function describeRuntimeSpecOverride(override) {
1742
- return Object.keys(RUNTIME_SPEC_ENV).filter((pkg) => override[pkg] !== void 0).map((pkg) => `${RUNTIME_SPEC_ENV[pkg]}=${override[pkg]}`).join(" ");
1964
+ function configTargetLabel(runtime) {
1965
+ switch (runtime) {
1966
+ case "codex-cli":
1967
+ return "Codex CLI config";
1968
+ case "codex-desktop":
1969
+ return "Codex Desktop config";
1970
+ case "cursor":
1971
+ return "Cursor MCP config";
1972
+ case "vscode":
1973
+ return "VS Code MCP config";
1974
+ case "vscode-insiders":
1975
+ return "VS Code Insiders MCP config";
1976
+ case "claude-desktop":
1977
+ return "Claude Desktop config";
1978
+ default:
1979
+ return "runtime MCP config";
1980
+ }
1743
1981
  }
1744
- var RUNTIME_SPEC_ENV, SHELL_METACHARACTERS, RuntimeSpecOverrideError;
1745
- var init_runtime_spec_override = __esm({
1746
- "src/runtime-spec-override.ts"() {
1747
- RUNTIME_SPEC_ENV = {
1748
- signer: "HAVEN_SIGNER_SPEC",
1749
- sdk: "HAVEN_SDK_SPEC",
1750
- mcp: "HAVEN_MCP_SPEC"
1982
+ var HERMES_API_KEY_ENV, HermesConfigRecoveryError, InvalidCodexTomlError, REPAIR_COMMAND_PREFIX, UnreadableRuntimeConfigError;
1983
+ var init_config_writers = __esm({
1984
+ "src/config-writers.ts"() {
1985
+ init_runtime_manifest();
1986
+ init_server_names();
1987
+ HERMES_API_KEY_ENV = "MCP_HAVEN_API_KEY";
1988
+ HermesConfigRecoveryError = class extends Error {
1989
+ constructor() {
1990
+ super("Hermes configuration recovery did not complete");
1991
+ this.name = "HermesConfigRecoveryError";
1992
+ }
1751
1993
  };
1752
- SHELL_METACHARACTERS = /[;&|<>$`'"()!#*?[\]{}\\]/;
1753
- RuntimeSpecOverrideError = class extends Error {
1754
- code = "runtime_spec_override_invalid";
1755
- variable;
1756
- constructor(variable, reason) {
1757
- super(`${variable} is set but not usable as an npm package spec: ${reason}. Unset it, or set it to a spec npm install accepts (file:/abs/path, a .tgz, or @haven_ai/<pkg>@<version>).`);
1758
- this.name = "RuntimeSpecOverrideError";
1759
- this.variable = variable;
1994
+ InvalidCodexTomlError = class extends Error {
1995
+ constructor(message) {
1996
+ super(message);
1997
+ this.name = "InvalidCodexTomlError";
1998
+ }
1999
+ };
2000
+ REPAIR_COMMAND_PREFIX = sdk.connectorRerunCommand("--doctor --repair --runtime");
2001
+ UnreadableRuntimeConfigError = class extends Error {
2002
+ configPath;
2003
+ constructor(configPath, detail) {
2004
+ super(`${configPath} is not a config Haven can merge into (${detail})`);
2005
+ this.name = "UnreadableRuntimeConfigError";
2006
+ this.configPath = configPath;
1760
2007
  }
1761
2008
  };
1762
2009
  }
1763
2010
  });
1764
- async function prepareSignerRuntime(input, deps = {}) {
1765
- const homeDir = input.homeDir ?? os.homedir();
1766
- const override = resolveSignerRuntimeOverride(deps.env ?? process.env);
1767
- const signerSpec = override?.signer ?? signerPackageSpec();
1768
- const sdkSpec = override?.sdk ?? sdkPackageSpec();
1769
- const resolvedSpecs = [signerSpec, sdkSpec];
1770
- const overrideRecord = override ? { specs: override, resolved_specs: resolvedSpecs, directory_key: runtimeSpecOverrideDirectoryKey(resolvedSpecs) } : void 0;
1771
- const runtimeDirectory = path.resolve(
1772
- homeDir,
1773
- ".haven",
1774
- "signer-runtime",
1775
- overrideRecord ? overrideRecord.directory_key : MCP_RUNTIME_MANIFEST.signerVersion
1776
- );
1777
- const npmCacheDirectory = path.resolve(homeDir, ".haven", "npm-cache");
1778
- const cliPath = path.join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "dist", "cli.js");
1779
- const messages = [];
1780
- if (override) {
1781
- messages.push(...runtimeSpecOverrideNotice(
1782
- "signer runtime",
1783
- override,
1784
- { signer: signerPackageSpec(), sdk: sdkPackageSpec() },
1785
- runtimeDirectory
1786
- ));
2011
+ async function acknowledgeLocalMcpConsent(identityPath, signerPath, log) {
2012
+ try {
2013
+ const input = await buildLocalMcpConsentInput(identityPath, signerPath);
2014
+ const decision = await mcp.ensureConsent(input, {
2015
+ credentialsPath: identityPath,
2016
+ writeAck: true,
2017
+ out: log ? { write: (chunk) => writeLogChunk(log, chunk) } : void 0
2018
+ });
2019
+ return {
2020
+ acknowledged: decision.ok,
2021
+ hash: decision.hash,
2022
+ reason: decision.reason
2023
+ };
2024
+ } catch (err) {
2025
+ return {
2026
+ acknowledged: false,
2027
+ error: err instanceof Error ? err.message : String(err)
2028
+ };
1787
2029
  }
1788
- await promises.mkdir(runtimeDirectory, { recursive: true, mode: 448 });
1789
- await promises.chmod(runtimeDirectory, 448).catch(() => void 0);
1790
- await promises.mkdir(npmCacheDirectory, { recursive: true, mode: 448 });
1791
- await promises.chmod(npmCacheDirectory, 448).catch(() => void 0);
1792
- if (override) {
1793
- await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1794
- messages.push(`Installed local Haven signer runtime from override (${resolvedSpecs.join(" ")}).`);
1795
- } else if (await installedRuntimeMatches(runtimeDirectory, cliPath)) {
1796
- messages.push(`Using existing local Haven signer runtime ${signerPackageSpec()}.`);
1797
- } else {
1798
- await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1799
- messages.push(`Installed local Haven signer runtime ${signerPackageSpec()}.`);
2030
+ }
2031
+ async function getLocalMcpConsentStatus(identityPath, signerPath) {
2032
+ try {
2033
+ const input = await buildLocalMcpConsentInput(identityPath, signerPath);
2034
+ const hash = mcp.computeConsentHash(input);
2035
+ const stored = await readLocalMcpAckFile(localMcpAckPath(identityPath));
2036
+ if (stored === hash) {
2037
+ return { acknowledged: true, hash, reason: "ack_file_match" };
2038
+ }
2039
+ return {
2040
+ acknowledged: false,
2041
+ hash,
2042
+ reason: stored ? "ack_file_mismatch" : "ack_file_missing"
2043
+ };
2044
+ } catch (err) {
2045
+ return {
2046
+ acknowledged: false,
2047
+ error: err instanceof Error ? err.message : String(err)
2048
+ };
1800
2049
  }
1801
- await assertFileExists(cliPath, "local Haven signer CLI");
1802
- const installedVersions = override ? await readInstalledVersions(runtimeDirectory) : void 0;
1803
- const wrapperPath = path.join(input.credentialDirectory, "bin", "haven-signer.mjs");
1804
- await writeWrapper({
1805
- wrapperPath,
1806
- cliPath,
1807
- signerPath: input.signerPath,
1808
- overrideComment: override ? describeRuntimeSpecOverride(override) : void 0
1809
- });
1810
- await writeRuntimeSidecar({
1811
- path: path.join(input.credentialDirectory, "signer-runtime.json"),
1812
- wrapperPath,
1813
- runtimeDirectory,
1814
- npmCacheDirectory,
1815
- cliPath,
1816
- serverName: input.serverName,
1817
- override: overrideRecord,
1818
- installedVersions
1819
- });
1820
- messages.push(`Prepared stable local Haven signer wrapper: ${wrapperPath}`);
1821
- return {
1822
- command: wrapperPath,
1823
- args: [],
1824
- wrapperPath,
1825
- runtimeDirectory,
1826
- npmCacheDirectory,
1827
- cliPath,
1828
- messages,
1829
- ...overrideRecord ? { runtimeSpecOverride: overrideRecord } : {}
1830
- };
1831
2050
  }
1832
- function resolveSignerRuntimeOverride(env) {
1833
- const override = resolveRuntimeSpecOverride(env);
1834
- if (!overrideApplies(override, ["signer", "sdk"])) return void 0;
1835
- const { signer, sdk } = override;
1836
- return { ...signer !== void 0 ? { signer } : {}, ...sdk !== void 0 ? { sdk } : {} };
2051
+ function localMcpAckPath(identityPath) {
2052
+ return path.resolve(`${identityPath}.ack.json`);
1837
2053
  }
1838
- async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, packageSpecs, deps) {
1839
- const { runCommand, onProgress } = deps;
1840
- const baseArgs = [
1841
- "install",
1842
- "--prefix",
1843
- runtimeDirectory,
1844
- "--no-audit",
1845
- "--no-fund",
1846
- "--omit=dev",
1847
- "--prefer-offline",
1848
- ...packageSpecs
1849
- ];
1850
- const run = async (args) => {
1851
- const startedAt = Date.now();
1852
- const heartbeat = setInterval(() => {
1853
- const seconds = Math.round((Date.now() - startedAt) / 1e3);
1854
- onProgress?.(`Still installing the local Haven signer runtime\u2026 (${seconds}s \u2014 a cold cache can take several minutes)`);
1855
- }, SIGNER_INSTALL_HEARTBEAT_MS);
1856
- heartbeat.unref?.();
1857
- try {
1858
- if (runCommand) await runCommand("npm", args);
1859
- else await execFileAsync("npm", args, { timeout: SIGNER_INSTALL_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
1860
- } finally {
1861
- clearInterval(heartbeat);
2054
+ async function buildLocalMcpConsentInput(identityPath, signerPath) {
2055
+ const credentials = await mcp.loadCredentials({ identityPath, signerPath });
2056
+ const unavailableDuringSetup = {
2057
+ getAllowances: async () => {
2058
+ throw new Error("Haven approval is not complete yet.");
1862
2059
  }
1863
2060
  };
2061
+ return mcp.consentInputFromClient(
2062
+ unavailableDuringSetup,
2063
+ {
2064
+ apiKey: credentials.apiKey,
2065
+ apiUrl: credentials.apiUrl,
2066
+ agentId: credentials.agentId,
2067
+ accountAddress: credentials.accountAddress,
2068
+ delegateAddress: credentials.delegateAddress,
2069
+ chainId: credentials.chainId,
2070
+ allowanceSummary: credentials.allowanceSummary
2071
+ },
2072
+ mcp.registeredToolNames()
2073
+ );
2074
+ }
2075
+ async function readLocalMcpAckFile(path) {
1864
2076
  try {
1865
- await run(baseArgs);
2077
+ const parsed = JSON.parse(await promises.readFile(path, "utf8"));
2078
+ return typeof parsed.ack === "string" ? parsed.ack : null;
1866
2079
  } catch {
1867
- try {
1868
- await run([...baseArgs, "--cache", npmCacheDirectory]);
1869
- } catch (err) {
1870
- throw new Error(
1871
- `Could not install local Haven signer runtime ${packageSpecs.join(" ")}: ${err instanceof Error ? err.message : String(err)}`
1872
- );
1873
- }
2080
+ return null;
1874
2081
  }
1875
2082
  }
1876
- async function installedRuntimeMatches(runtimeDirectory, cliPath) {
1877
- return installedRuntimeMatchesVersions(runtimeDirectory, cliPath, {
1878
- signerVersion: MCP_RUNTIME_MANIFEST.signerVersion,
1879
- sdkVersion: MCP_RUNTIME_MANIFEST.sdkVersion
1880
- });
2083
+ function writeLogChunk(log, chunk) {
2084
+ const message = String(chunk).trimEnd();
2085
+ if (message) log(message);
1881
2086
  }
1882
- async function installedRuntimeMatchesVersions(runtimeDirectory, cliPath, expected) {
2087
+ var init_local_mcp_consent = __esm({
2088
+ "src/local-mcp-consent.ts"() {
2089
+ }
2090
+ });
2091
+ async function probeHostedAgentIdentity(apiKey, apiUrl, fetchImpl = fetch) {
2092
+ let response;
1883
2093
  try {
1884
- await assertFileExists(cliPath, "local Haven signer CLI");
1885
- const installed = await readInstalledVersions(runtimeDirectory);
1886
- return installed.signerVersion === expected.signerVersion && installed.sdkVersion === expected.sdkVersion;
2094
+ response = await fetchWithTimeout(fetchImpl, `${apiUrl.replace(/\/+$/, "")}/machine-payments/agent`, {
2095
+ method: "GET",
2096
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }
2097
+ });
1887
2098
  } catch {
1888
- return false;
2099
+ return { status: "network_error" };
2100
+ }
2101
+ if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
2102
+ if (!response.ok) return { status: "bad_response" };
2103
+ try {
2104
+ const payload = JSON.parse(await response.text());
2105
+ if (typeof payload?.delegate_address !== "string") return { status: "bad_response" };
2106
+ return { status: "ok", agentId: payload.id, delegateAddress: payload.delegate_address };
2107
+ } catch {
2108
+ return { status: "bad_response" };
1889
2109
  }
1890
2110
  }
1891
- async function readInstalledVersions(runtimeDirectory) {
1892
- const [signerPackage, sdkPackage] = await Promise.all([
1893
- readPackageJson(path.join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "package.json")),
1894
- readPackageJson(path.join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
1895
- ]);
1896
- return { signerVersion: signerPackage.version ?? "", sdkVersion: sdkPackage.version ?? "" };
1897
- }
1898
- async function readPackageJson(path) {
1899
- return JSON.parse(await promises.readFile(path, "utf8"));
1900
- }
1901
- async function writeWrapper(input) {
1902
- await promises.mkdir(path.dirname(input.wrapperPath), { recursive: true, mode: 448 });
1903
- await promises.chmod(path.dirname(input.wrapperPath), 448).catch(() => void 0);
1904
- const source = [
1905
- "#!/usr/bin/env node",
1906
- ...input.overrideComment ? [`// HAVEN RUNTIME SPEC OVERRIDE (#2424): ${input.overrideComment} \u2014 this wrapper launches a NON-pinned signer build.`] : [],
1907
- "import { spawn } from 'node:child_process'",
1908
- "",
1909
- `const cliPath = ${JSON.stringify(input.cliPath)}`,
1910
- `const signerPath = ${JSON.stringify(input.signerPath)}`,
1911
- "",
1912
- "const child = spawn(process.execPath, [cliPath, '--credentials', signerPath, ...process.argv.slice(2)], {",
1913
- " stdio: 'inherit',",
1914
- "})",
1915
- "",
1916
- "child.on('exit', (code, signal) => {",
1917
- " if (signal) process.kill(process.pid, signal)",
1918
- " else process.exit(code ?? 1)",
1919
- "})",
1920
- ""
1921
- ].join("\n");
1922
- await promises.writeFile(input.wrapperPath, source, { mode: 448 });
1923
- await promises.chmod(input.wrapperPath, 448).catch(() => void 0);
2111
+ async function probeHostedMcpTools(apiKey, hostedMcpUrl, fetchImpl = fetch) {
2112
+ let response;
2113
+ try {
2114
+ response = await fetchWithTimeout(fetchImpl, hostedMcpUrl, {
2115
+ method: "POST",
2116
+ headers: {
2117
+ Authorization: `Bearer ${apiKey}`,
2118
+ "Content-Type": "application/json",
2119
+ Accept: "application/json, text/event-stream"
2120
+ },
2121
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
2122
+ });
2123
+ } catch {
2124
+ return { status: "network_error" };
2125
+ }
2126
+ if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
2127
+ if (!response.ok) return { status: "bad_response" };
2128
+ try {
2129
+ const payload = parseJsonRpcPayload(await response.text());
2130
+ if (!payload || payload.error) return { status: "bad_response" };
2131
+ const tools = payload.result?.tools;
2132
+ return { status: "ok", toolCount: Array.isArray(tools) ? tools.length : void 0 };
2133
+ } catch {
2134
+ return { status: "bad_response" };
2135
+ }
1924
2136
  }
1925
- async function readRuntimeSidecar(credentialDirectory) {
2137
+ async function probeLocalSignerCredential(signerPath) {
1926
2138
  try {
1927
- return JSON.parse(
1928
- await promises.readFile(path.join(credentialDirectory, "signer-runtime.json"), "utf8")
2139
+ const parsed = JSON.parse(await promises.readFile(signerPath, "utf8"));
2140
+ return Boolean(
2141
+ parsed && typeof parsed === "object" && "delegate_key" in parsed && typeof parsed.delegate_key === "string"
1929
2142
  );
1930
2143
  } catch {
1931
- return null;
2144
+ return false;
1932
2145
  }
1933
2146
  }
1934
- async function writeRuntimeSidecar(input) {
1935
- const value = {
1936
- ...input.serverName ? { server_name: input.serverName } : {},
1937
- signer_package: MCP_RUNTIME_MANIFEST.signerPackage,
1938
- signer_version: input.installedVersions?.signerVersion ?? MCP_RUNTIME_MANIFEST.signerVersion,
1939
- sdk_package: MCP_RUNTIME_MANIFEST.sdkPackage,
1940
- sdk_version: input.installedVersions?.sdkVersion ?? MCP_RUNTIME_MANIFEST.sdkVersion,
1941
- wrapper_path: input.wrapperPath,
1942
- runtime_directory: input.runtimeDirectory,
1943
- npm_cache_directory: input.npmCacheDirectory,
1944
- cli_path: input.cliPath,
1945
- ...input.override ? { runtime_spec_override: input.override } : {}
1946
- };
1947
- await promises.writeFile(input.path, `${JSON.stringify(value, null, 2)}
1948
- `, { mode: 384 });
1949
- await promises.chmod(input.path, 384).catch(() => void 0);
2147
+ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
2148
+ return new Promise((resolve11) => {
2149
+ const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
2150
+ let stdout = "";
2151
+ let settled = false;
2152
+ let sawInitialize = false;
2153
+ let serverInfo;
2154
+ let capabilities;
2155
+ const finish = (result) => {
2156
+ if (settled) return;
2157
+ settled = true;
2158
+ clearTimeout(timeout);
2159
+ child.kill();
2160
+ resolve11(result);
2161
+ };
2162
+ const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
2163
+ child.on("error", () => finish({ status: "process_error" }));
2164
+ child.on("exit", (code) => {
2165
+ if (!settled && code !== 0) finish({ status: "process_error" });
2166
+ });
2167
+ child.stdout.on("data", (chunk) => {
2168
+ stdout += chunk.toString("utf8");
2169
+ const lines = stdout.split(/\r?\n/);
2170
+ stdout = lines.pop() ?? "";
2171
+ for (const line of lines) {
2172
+ const trimmed = line.trim();
2173
+ if (!trimmed) continue;
2174
+ let payload;
2175
+ try {
2176
+ payload = JSON.parse(trimmed);
2177
+ } catch {
2178
+ continue;
2179
+ }
2180
+ if (payload.error) {
2181
+ finish({ status: "bad_response" });
2182
+ return;
2183
+ }
2184
+ if (payload.id === 1 && !sawInitialize) {
2185
+ sawInitialize = true;
2186
+ const init = payload.result;
2187
+ serverInfo = init?.serverInfo;
2188
+ capabilities = init?.capabilities;
2189
+ writeJsonRpc(child, { jsonrpc: "2.0", method: "notifications/initialized", params: {} });
2190
+ writeJsonRpc(child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
2191
+ continue;
2192
+ }
2193
+ if (payload.id === 2) {
2194
+ const tools = payload.result?.tools;
2195
+ const toolNames = Array.isArray(tools) ? tools.map((tool) => tool && typeof tool === "object" && "name" in tool ? tool.name : void 0).filter((name) => typeof name === "string") : [];
2196
+ const missing = requiredTools.filter((name) => !toolNames.includes(name));
2197
+ finish({ status: missing.length === 0 ? "ok" : "missing_tools", toolNames, serverInfo, capabilities });
2198
+ return;
2199
+ }
2200
+ }
2201
+ });
2202
+ writeJsonRpc(child, {
2203
+ jsonrpc: "2.0",
2204
+ id: 1,
2205
+ method: "initialize",
2206
+ params: {
2207
+ protocolVersion: "2025-06-18",
2208
+ capabilities: {},
2209
+ clientInfo: { name: "haven-connect-probe", version: "0.0.0" }
2210
+ }
2211
+ });
2212
+ });
1950
2213
  }
1951
- async function assertFileExists(path, label) {
2214
+ function writeJsonRpc(child, payload) {
2215
+ child.stdin?.write(`${JSON.stringify(payload)}
2216
+ `);
2217
+ }
2218
+ function parseJsonRpcPayload(raw) {
2219
+ const trimmed = raw.trim();
2220
+ if (!trimmed) return null;
2221
+ if (trimmed.startsWith("{")) {
2222
+ try {
2223
+ return JSON.parse(trimmed);
2224
+ } catch {
2225
+ return null;
2226
+ }
2227
+ }
2228
+ const dataLines = trimmed.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim());
2229
+ for (let i = dataLines.length - 1; i >= 0; i -= 1) {
2230
+ try {
2231
+ return JSON.parse(dataLines[i]);
2232
+ } catch {
2233
+ }
2234
+ }
2235
+ return null;
2236
+ }
2237
+ async function fetchWithTimeout(fetchImpl, url, init) {
2238
+ const controller = new AbortController();
2239
+ const timeout = setTimeout(() => controller.abort(), 3e3);
1952
2240
  try {
1953
- await promises.access(path);
1954
- } catch {
1955
- throw new Error(`Missing ${label}: ${path}`);
2241
+ return await fetchImpl(url, { ...init, signal: controller.signal });
2242
+ } finally {
2243
+ clearTimeout(timeout);
1956
2244
  }
1957
2245
  }
1958
- var execFileAsync, SIGNER_INSTALL_TIMEOUT_MS, SIGNER_INSTALL_HEARTBEAT_MS;
1959
- var init_signer_runtime = __esm({
1960
- "src/signer-runtime.ts"() {
1961
- init_runtime_manifest();
1962
- init_runtime_spec_override();
1963
- execFileAsync = util.promisify(child_process.execFile);
1964
- SIGNER_INSTALL_TIMEOUT_MS = 6e5;
1965
- SIGNER_INSTALL_HEARTBEAT_MS = 15e3;
2246
+ var init_probes = __esm({
2247
+ "src/probes.ts"() {
1966
2248
  }
1967
2249
  });
1968
2250
  async function prepareLocalMcpRuntime(input, deps = {}) {
@@ -2272,33 +2554,11 @@ function hermesHome(deps) {
2272
2554
  const env = deps.env ?? process.env;
2273
2555
  return env.HERMES_HOME ?? path.join(deps.homeDir ?? os.homedir(), ".hermes");
2274
2556
  }
2275
- var CODEX_AGENTS_BEGIN_MARKER, CODEX_AGENTS_END_MARKER;
2276
- var init_skill_install = __esm({
2277
- "src/skill-install.ts"() {
2278
- CODEX_AGENTS_BEGIN_MARKER = "<!-- BEGIN haven-pay (managed by @haven_ai/connect; edits inside this section are overwritten on re-setup) -->";
2279
- CODEX_AGENTS_END_MARKER = "<!-- END haven-pay -->";
2280
- }
2281
- });
2282
-
2283
- // src/connect-error.ts
2284
- function isConnectError(err) {
2285
- return err instanceof ConnectError;
2286
- }
2287
- var ConnectError;
2288
- var init_connect_error = __esm({
2289
- "src/connect-error.ts"() {
2290
- ConnectError = class extends Error {
2291
- code;
2292
- nextAction;
2293
- details;
2294
- constructor(code, message, nextAction2, details = {}) {
2295
- super(message);
2296
- this.name = "ConnectError";
2297
- this.code = code;
2298
- this.nextAction = nextAction2;
2299
- this.details = details;
2300
- }
2301
- };
2557
+ var CODEX_AGENTS_BEGIN_MARKER, CODEX_AGENTS_END_MARKER;
2558
+ var init_skill_install = __esm({
2559
+ "src/skill-install.ts"() {
2560
+ CODEX_AGENTS_BEGIN_MARKER = "<!-- BEGIN haven-pay (managed by @haven_ai/connect; edits inside this section are overwritten on re-setup) -->";
2561
+ CODEX_AGENTS_END_MARKER = "<!-- END haven-pay -->";
2302
2562
  }
2303
2563
  });
2304
2564
 
@@ -3003,9 +3263,23 @@ function installedClientTargets(homeDir = os.homedir(), cwd = process.cwd(), env
3003
3263
  runtime: "claude-code",
3004
3264
  label: "Claude Code",
3005
3265
  // Claude Code is configured through its own CLI (`claude mcp add-json`),
3006
- // not by writing a file this module owns — so its evidence is the
3007
- // client directory, never a config path.
3266
+ // not by writing a file this module owns — so `configPath` stays null
3267
+ // and the write still goes through the CLI. But a user-scope
3268
+ // `~/.claude.json` carrying an `mcpServers` key IS config-file
3269
+ // evidence: the user has already pointed Claude Code at some MCP
3270
+ // server. It is not the file the connector would write, hence the
3271
+ // separate evidence path (#3732): without it, a Codex config won by
3272
+ // construction on any machine where the user runs Claude Code.
3008
3273
  configPath: null,
3274
+ evidencePath: path.join(homeDir, ".claude.json"),
3275
+ evidenceMatches: (contents) => {
3276
+ try {
3277
+ const parsed = JSON.parse(contents);
3278
+ return typeof parsed === "object" && parsed !== null && "mcpServers" in parsed;
3279
+ } catch {
3280
+ return false;
3281
+ }
3282
+ },
3009
3283
  markers: [path.join(homeDir, ".claude"), path.join(homeDir, ".claude.json")]
3010
3284
  },
3011
3285
  {
@@ -3037,7 +3311,10 @@ function installedClientTargets(homeDir = os.homedir(), cwd = process.cwd(), env
3037
3311
  },
3038
3312
  {
3039
3313
  runtime: "claude-desktop",
3040
- label: "Claude Desktop",
3314
+ // The chat app, a separate runtime from Claude Code. Named so a user in
3315
+ // the desktop app's Code tab cannot read this row as "where I am" —
3316
+ // it sorted above Claude Code whenever both had config (#3732).
3317
+ label: "Claude Desktop (chat app)",
3041
3318
  configPath: runtimeConfigPathFor("claude-desktop", homeDir),
3042
3319
  markers: []
3043
3320
  },
@@ -3052,6 +3329,7 @@ function installedClientTargets(homeDir = os.homedir(), cwd = process.cwd(), env
3052
3329
  }
3053
3330
  async function scanInstalledClients(options = {}) {
3054
3331
  const exists = options.exists ?? pathExists;
3332
+ const readFile15 = options.readFile ?? ((path) => promises.readFile(path, "utf8"));
3055
3333
  const targets = installedClientTargets(options.homeDir, options.cwd, options.env ?? process.env);
3056
3334
  const found = [];
3057
3335
  for (const target of targets) {
@@ -3061,6 +3339,18 @@ async function scanInstalledClients(options = {}) {
3061
3339
  label: target.label,
3062
3340
  detail: `MCP config found at ${target.configPath}`,
3063
3341
  configPath: target.configPath,
3342
+ evidencePath: target.configPath,
3343
+ evidence: "config-file"
3344
+ });
3345
+ continue;
3346
+ }
3347
+ if (target.evidencePath && await hasConfigEvidence(target, exists, readFile15)) {
3348
+ found.push({
3349
+ runtime: target.runtime,
3350
+ label: target.label,
3351
+ detail: `MCP config found at ${target.evidencePath}`,
3352
+ configPath: target.configPath,
3353
+ evidencePath: target.evidencePath,
3064
3354
  evidence: "config-file"
3065
3355
  });
3066
3356
  continue;
@@ -3072,6 +3362,7 @@ async function scanInstalledClients(options = {}) {
3072
3362
  label: target.label,
3073
3363
  detail: `installed (${marker})`,
3074
3364
  configPath: target.configPath,
3365
+ evidencePath: null,
3075
3366
  evidence: "client-directory"
3076
3367
  });
3077
3368
  break;
@@ -3082,6 +3373,16 @@ async function scanInstalledClients(options = {}) {
3082
3373
  return SCAN_ORDER.indexOf(a.runtime) - SCAN_ORDER.indexOf(b.runtime);
3083
3374
  });
3084
3375
  }
3376
+ async function hasConfigEvidence(target, exists, readFile15) {
3377
+ const path = target.evidencePath;
3378
+ if (!await exists(path)) return false;
3379
+ if (!target.evidenceMatches) return true;
3380
+ try {
3381
+ return target.evidenceMatches(await readFile15(path));
3382
+ } catch {
3383
+ return false;
3384
+ }
3385
+ }
3085
3386
  function installedClientHint(candidates) {
3086
3387
  const installedClients = candidates.map((candidate) => candidate.runtime);
3087
3388
  if (candidates.length === 1) {
@@ -3092,18 +3393,24 @@ function installedClientHint(candidates) {
3092
3393
  }
3093
3394
  async function promptForInstalledClient(candidates, io = defaultPromptIo()) {
3094
3395
  if (candidates.length === 0) throw noInstalledClientsError();
3396
+ const hint = installedClientHint(candidates);
3095
3397
  io.write("Haven could not detect which agent runtime this is.\n");
3096
3398
  io.write("These agent clients are installed on this machine:\n");
3097
3399
  candidates.forEach((candidate, index) => {
3098
- io.write(` ${index + 1}) ${candidate.label} \u2014 ${candidate.detail}
3400
+ const suggested = hint.suggestedRuntime === candidate.runtime ? " (suggested)" : "";
3401
+ io.write(` ${index + 1}) ${candidate.label}${suggested} \u2014 ${candidate.detail}
3099
3402
  `);
3100
3403
  });
3101
3404
  io.write("Haven writes an API key and a signing key into the client you pick, so pick the one your agent actually runs in.\n");
3102
3405
  for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt += 1) {
3103
- const answer = await io.question(`Which one? [1-${candidates.length}] (default 1 \u2014 ${candidates[0].label}): `);
3406
+ const answer = await io.question(`Which one? [1-${candidates.length}]: `);
3104
3407
  if (answer === null) throw promptAbortedError("the prompt was cancelled");
3105
3408
  const trimmed = answer.trim();
3106
- if (trimmed === "") return candidates[0].runtime;
3409
+ if (trimmed === "") {
3410
+ io.write(`Type the number of the client to configure (1-${candidates.length}).
3411
+ `);
3412
+ continue;
3413
+ }
3107
3414
  const picked = Number.parseInt(trimmed, 10);
3108
3415
  if (Number.isInteger(picked) && picked >= 1 && picked <= candidates.length) {
3109
3416
  return candidates[picked - 1].runtime;
@@ -3176,138 +3483,6 @@ var init_installed_clients = __esm({
3176
3483
  MAX_PROMPT_ATTEMPTS = 3;
3177
3484
  }
3178
3485
  });
3179
-
3180
- // src/tombstone.ts
3181
- var tombstone_exports = {};
3182
- __export(tombstone_exports, {
3183
- ROOT_TOMBSTONES_DIRNAME: () => ROOT_TOMBSTONES_DIRNAME,
3184
- TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
3185
- TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
3186
- defaultTombstonesDir: () => defaultTombstonesDir,
3187
- readAgentTombstone: () => readAgentTombstone,
3188
- readTombstoneRecords: () => readTombstoneRecords,
3189
- tombstonesDirForAgentDirectory: () => tombstonesDirForAgentDirectory,
3190
- tombstonesDirForCredentialRoot: () => tombstonesDirForCredentialRoot,
3191
- writeAgentTombstone: () => writeAgentTombstone
3192
- });
3193
- function defaultTombstonesDir(baseDir) {
3194
- return path.join(baseDir ?? path.join(os.homedir(), ".haven"), "tombstones");
3195
- }
3196
- function tombstonesDirForCredentialRoot(credentialRoot, homeDir = os.homedir()) {
3197
- if (!credentialRoot) return defaultTombstonesDir(path.join(homeDir, ".haven"));
3198
- const root = path.resolve(credentialRoot);
3199
- if (root === path.resolve(homeDir, ".haven", "agents")) return defaultTombstonesDir(path.join(homeDir, ".haven"));
3200
- return path.join(root, ROOT_TOMBSTONES_DIRNAME);
3201
- }
3202
- function tombstonesDirForAgentDirectory(directory, homeDir = os.homedir()) {
3203
- return tombstonesDirForCredentialRoot(path.dirname(path.resolve(directory)), homeDir);
3204
- }
3205
- async function readTombstoneRecords(tombstonesDir) {
3206
- const root = tombstonesDir ?? defaultTombstonesDir();
3207
- let entries = [];
3208
- try {
3209
- entries = await promises.readdir(root);
3210
- } catch {
3211
- return [];
3212
- }
3213
- const records = [];
3214
- for (const entry of entries) {
3215
- if (!entry.endsWith(".json")) continue;
3216
- const recordPath = path.join(root, entry);
3217
- try {
3218
- const parsed = JSON.parse(await promises.readFile(recordPath, "utf8"));
3219
- if (typeof parsed?.agent_id === "string") {
3220
- records.push({ ...parsed, recordPath });
3221
- }
3222
- } catch {
3223
- }
3224
- }
3225
- return records;
3226
- }
3227
- function tombstoneScript(info) {
3228
- const lines = [
3229
- `${TOMBSTONE_MARKER}: this Haven agent was retired.`,
3230
- "",
3231
- ` agent: ${info.agent_id}`,
3232
- ` retired at: ${info.retired_at}`,
3233
- ` reason: ${info.reason}`,
3234
- ...info.replaced_by ? [` replaced by: ${info.replaced_by}`] : [],
3235
- "",
3236
- "This process is running with a wiring snapshot that predates the",
3237
- "retirement \u2014 it loaded its MCP config at startup and has kept it since.",
3238
- "Restart THIS host to pick up the current wiring. If several long-lived",
3239
- "hosts are running (a gateway, a TUI worker, an editor), restart EVERY",
3240
- "one of them: each holds the snapshot from its own start time, so after",
3241
- "a chain of recreations each can be parked on a DIFFERENT old agent.",
3242
- "",
3243
- `Then verify with: ${sdk.connectorRerunCommand("--doctor --runtime <runtime>")}`
3244
- ];
3245
- return [
3246
- "#!/usr/bin/env node",
3247
- `// ${TOMBSTONE_MARKER} \u2014 written by @haven_ai/connect (#1681). Safe to delete`,
3248
- "// once every long-lived MCP host on this machine has been restarted.",
3249
- `process.stderr.write(${JSON.stringify(lines.join("\n") + "\n")})`,
3250
- "process.exit(1)",
3251
- ""
3252
- ].join("\n");
3253
- }
3254
- async function writeAgentTombstone(input) {
3255
- const dirStat = await promises.stat(input.directory).catch(() => null);
3256
- if (!dirStat?.isDirectory()) {
3257
- throw new ConnectError(
3258
- "tombstone_directory_not_found",
3259
- `Not a directory: ${input.directory} \u2014 nothing to tombstone. Agent directories are named by their wiring SLUG when the agent has one, and by the agent id otherwise \u2014 so a path built from an agent id will not exist for a named agent. List ~/.haven/agents (or read the directories from --doctor --json) and pass one of those.`,
3260
- "retry_with_an_existing_agent_directory"
3261
- );
3262
- }
3263
- const info = {
3264
- // reason / replaced_by are persisted to disk and re-emitted to the host's
3265
- // MCP stderr log on EVERY stale probe, potentially for months — redact
3266
- // like every other output path, at the write layer so any future caller
3267
- // inherits it. (#1681 review, finding 1)
3268
- agent_id: input.agentId,
3269
- retired_at: input.retiredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3270
- reason: redactSecrets(input.reason),
3271
- ...input.replacedBy ? { replaced_by: redactSecrets(input.replacedBy) } : {}
3272
- };
3273
- const binDir = path.join(input.directory, "bin");
3274
- await promises.mkdir(binDir, { recursive: true });
3275
- const wrapperPath = path.join(binDir, "haven-signer.mjs");
3276
- await promises.writeFile(wrapperPath, tombstoneScript(info), "utf8");
3277
- await promises.chmod(wrapperPath, 493);
3278
- const record = JSON.stringify(info, null, 2) + "\n";
3279
- await promises.writeFile(path.join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
3280
- const root = input.tombstonesDir ?? defaultTombstonesDir();
3281
- const recordPath = path.join(root, `${info.agent_id}.json`);
3282
- try {
3283
- await promises.mkdir(root, { recursive: true, mode: 448 });
3284
- await promises.writeFile(recordPath, record, { mode: MIRROR_MODE });
3285
- } catch (err) {
3286
- const code = err?.code;
3287
- return { ...info, recordPath: null, mirrorError: typeof code === "string" && /^E[A-Z]+$/.test(code) ? code : "mirror_write_failed" };
3288
- }
3289
- return { ...info, recordPath };
3290
- }
3291
- async function readAgentTombstone(directory) {
3292
- try {
3293
- const parsed = JSON.parse(await promises.readFile(path.join(directory, TOMBSTONE_FILENAME), "utf8"));
3294
- if (typeof parsed?.agent_id !== "string") return null;
3295
- return parsed;
3296
- } catch {
3297
- return null;
3298
- }
3299
- }
3300
- var TOMBSTONE_FILENAME, ROOT_TOMBSTONES_DIRNAME, MIRROR_MODE, TOMBSTONE_MARKER;
3301
- var init_tombstone = __esm({
3302
- "src/tombstone.ts"() {
3303
- init_redact();
3304
- init_connect_error();
3305
- TOMBSTONE_FILENAME = "TOMBSTONE.json";
3306
- ROOT_TOMBSTONES_DIRNAME = ".tombstones";
3307
- MIRROR_MODE = 384;
3308
- TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
3309
- }
3310
- });
3311
3486
  async function detectWiringCollision(input) {
3312
3487
  const root = defaultCredentialRoot(input.credentialsDir);
3313
3488
  let entries = [];
@@ -3345,17 +3520,45 @@ async function detectWiringCollision(input) {
3345
3520
  suggestedServerName: proposeServerSlug(input.agentName, taken)
3346
3521
  };
3347
3522
  }
3348
- function proposeServerSlug(agentName, taken) {
3349
- let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
3523
+ function proposeServerSlug(agentName, taken, maxBaseLength = 32) {
3524
+ let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, maxBaseLength).replace(/-+$/g, "");
3350
3525
  if (!slugIsValid(base)) base = "agent";
3351
3526
  if (!taken.has(base)) return base;
3352
3527
  for (let n = 2; n < 1e3; n += 1) {
3353
3528
  const suffix = `-${n}`;
3354
- const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
3529
+ const candidate = `${base.slice(0, maxBaseLength - suffix.length).replace(/-+$/g, "")}${suffix}`;
3355
3530
  if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
3356
3531
  }
3357
3532
  throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
3358
3533
  }
3534
+ function deriveServerSlug(agentName, taken, markNonProduction) {
3535
+ const mark = "-dev";
3536
+ if (!markNonProduction) return proposeServerSlug(agentName, taken);
3537
+ const base = proposeServerSlug(agentName, taken, 32 - mark.length);
3538
+ if (base.endsWith(mark)) return base;
3539
+ const marked = `${base}${mark}`;
3540
+ if (!taken.has(marked)) return marked;
3541
+ return proposeServerSlug(marked, taken);
3542
+ }
3543
+ async function listTakenDirectoryNames(credentialsDir) {
3544
+ const root = defaultCredentialRoot(credentialsDir);
3545
+ let entries = [];
3546
+ try {
3547
+ entries = await promises.readdir(root);
3548
+ } catch {
3549
+ return /* @__PURE__ */ new Set();
3550
+ }
3551
+ const taken = /* @__PURE__ */ new Set();
3552
+ for (const entry of entries) {
3553
+ try {
3554
+ if (!(await promises.stat(path.join(root, entry))).isDirectory()) continue;
3555
+ } catch {
3556
+ continue;
3557
+ }
3558
+ taken.add(entry);
3559
+ }
3560
+ return taken;
3561
+ }
3359
3562
  function slugIsValid(slug) {
3360
3563
  try {
3361
3564
  assertValidServerSlug(slug);
@@ -3371,7 +3574,7 @@ async function promptWiringCollisionResolution(collision, agentName, io) {
3371
3574
  io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
3372
3575
  `);
3373
3576
  io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
3374
- io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
3577
+ io.write(" (tombstoned, local key files removed; you still use Remove agent\u2026 on the Haven agent page)\n");
3375
3578
  io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
3376
3579
  `);
3377
3580
  io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
@@ -3594,7 +3797,7 @@ async function decideTeardown(identity, input) {
3594
3797
  status: "retained",
3595
3798
  probe: "ok",
3596
3799
  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.",
3597
- 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}.`
3800
+ remedy: `Use Remove agent\u2026 on the Haven agent page (connect never revokes), then re-run --unwire; or, to delete the key anyway, re-run with ${DESTROY_FLAG}.`
3598
3801
  };
3599
3802
  case "unauthorized":
3600
3803
  return {
@@ -3755,7 +3958,16 @@ async function executeConnect(options, deps, trace) {
3755
3958
  log("Checked local credential storage \u2014 all clear.");
3756
3959
  let serverName = options.serverName;
3757
3960
  let replacing;
3758
- if (!serverName) {
3961
+ if (!serverName && options.deriveServerName) {
3962
+ const taken = await listTakenDirectoryNames(options.credentialsDir);
3963
+ const nonProduction = setup.haven_wallet.chain_id !== PRODUCTION_CHAIN_ID;
3964
+ serverName = deriveServerSlug(setup.agent.name, taken, nonProduction);
3965
+ await assertServerSlugAvailable(serverName, options.credentialsDir);
3966
+ const derivedNames = serverNamesFor(serverName);
3967
+ log(
3968
+ `Naming this agent's MCP pair from its display name: ${derivedNames.hosted} / ${derivedNames.signer}. Nothing was displaced; to wire the bare haven / haven-signer pair instead, re-run with --bare, or choose the name yourself with --name <slug>.`
3969
+ );
3970
+ } else if (!serverName) {
3759
3971
  const collision = await detectWiringCollision({
3760
3972
  credentialsDir: options.credentialsDir,
3761
3973
  agentName: setup.agent.name
@@ -3981,15 +4193,15 @@ async function executeConnect(options, deps, trace) {
3981
4193
  if (supersededAgentsRetiredLocally === true) {
3982
4194
  log("");
3983
4195
  log(
3984
- `Replaced: previous agent(s) ${supersededIds(replacing)} are retired on this machine but NOT revoked \u2014 revoke them on the Haven agent page, then restart EVERY long-lived host (gateways, TUI workers, editors): each holds the MCP wiring snapshot from its own start time, and the tombstone speaks only when a stale host next probes the old path.`
4196
+ `Replaced: previous agent(s) ${supersededIds(replacing)} are retired on this machine but NOT revoked \u2014 use Remove agent\u2026 on the Haven agent page for each (it ends their live budgets), then restart EVERY long-lived host (gateways, TUI workers, editors): each holds the MCP wiring snapshot from its own start time, and the tombstone speaks only when a stale host next probes the old path.`
3985
4197
  );
3986
4198
  } else if (supersededAgentIds.length > 0) {
3987
4199
  log("");
3988
4200
  log(
3989
- `Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${supersededAgentIds.join(", ")} \u2014 still exist with their own keys, and any host that was already running keeps acting as them.`
4201
+ `Heads-up: other agent directories on this machine \u2014 ${supersededAgentIds.join(", ")}. This setup created a NEW agent and revoked none of them; any not revoked in Haven keep their own keys and any budgets.`
3990
4202
  );
3991
4203
  log(
3992
- `If you meant to replace them: revoke them on the Haven agent page, then restart EVERY long-lived host (gateways, TUI workers, editors) \u2014 each holds the MCP wiring snapshot from its own start time, so after repeated setups each can be stuck on a DIFFERENT old agent. Then remove their directories under ~/.haven/agents (or ${RERUN_HINT} --tombstone <dir> to leave a diagnostic in their place). Run ${RERUN_HINT} --doctor to check whether their keys are still live.`
4204
+ `To retire one you no longer use: use Remove agent\u2026 on its Haven agent page (it ends its live budget), restart any long-lived host (gateways, TUI workers, editors) still wired to it \u2014 each holds the MCP wiring snapshot from its own start time \u2014 and remove its directory under ~/.haven/agents (or ${RERUN_HINT} --tombstone <dir> to leave a diagnostic in its place). To rewire the bare haven / haven-signer pair instead of adding an agent, re-run with --replace. Run ${RERUN_HINT} --doctor to check which keys are still live.`
3993
4205
  );
3994
4206
  }
3995
4207
  } catch {
@@ -4020,6 +4232,18 @@ async function executeConnect(options, deps, trace) {
4020
4232
  } catch (err) {
4021
4233
  log(`Could not report install status to Haven: ${err instanceof Error ? err.message : String(err)}`);
4022
4234
  }
4235
+ const reportableRetiredIds = [...new Set(retiredAgentIds.filter((id) => UUID_SHAPE.test(id)))];
4236
+ if (reportableRetiredIds.length > 0 && api.reportRetiredAgents) {
4237
+ try {
4238
+ await api.reportRetiredAgents({
4239
+ setupId: registration.setup_id,
4240
+ apiKey: localApiKey,
4241
+ retiredAgentIds: reportableRetiredIds
4242
+ });
4243
+ } catch (err) {
4244
+ log(`Could not tell Haven which agents were retired: ${err instanceof Error ? err.message : String(err)}`);
4245
+ }
4246
+ }
4023
4247
  let approval;
4024
4248
  const narrateApprovalWait = options.waitForApproval === true || options.waitForApproval === void 0 && (deps.isStdoutTty ?? Boolean(process.stdout.isTTY));
4025
4249
  if (narrateApprovalWait && !runtimeInstall.errorCode) {
@@ -4030,12 +4254,29 @@ async function executeConnect(options, deps, trace) {
4030
4254
  runtimeInstall,
4031
4255
  delegateAddress: registration.delegate_address,
4032
4256
  hostedMcpUrl: registration.hosted_mcp_url,
4257
+ // #3737: the resolved name — derived, explicit, or the bare pair — rides
4258
+ // the machine-readable outcome alongside the prose completion text.
4259
+ serverName,
4033
4260
  supersededAgentIds,
4034
4261
  supersededAgentsRetiredLocally,
4035
4262
  ...replacing ? { retiredAgentIds } : {},
4036
4263
  ...Object.keys(retirementMirrorErrors).length > 0 ? { retirementMirrorErrors } : {},
4037
4264
  existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
4038
4265
  ...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
4266
+ // #3772: only when the install completed. An install that ended with an
4267
+ // error code may not have written the wiring, and on --replace it leaves the
4268
+ // old agent's wiring as possibly the only working one (retirement is skipped
4269
+ // on the same condition) — so it gets no such sentence. That includes
4270
+ // manual_runtime_setup_required, deliberately: the connector wrote nothing
4271
+ // and the manual instruction already says to start a fresh session. A
4272
+ // --replace names every displaced agent even when no binding record exists
4273
+ // (pre-0.4.0 wiring, or a failed best-effort write).
4274
+ ...!runtimeInstall.errorCode && (replacing || reboundFrom) ? {
4275
+ staleSession: {
4276
+ agentIds: [.../* @__PURE__ */ new Set([...replacing?.superseded.map((entry) => entry.agentId) ?? [], ...reboundFrom ? [reboundFrom.agent_id] : []])],
4277
+ ...reboundFrom?.backend_changed ? { otherBackendAgentId: reboundFrom.agent_id } : {}
4278
+ }
4279
+ } : {},
4039
4280
  setupChallengeExpiresAt: setup.challenge.expires_at,
4040
4281
  approvalRequired: registration.agent_status === "pending_approval",
4041
4282
  approvalUrl: registration.approval_url
@@ -4051,6 +4292,10 @@ async function executeConnect(options, deps, trace) {
4051
4292
  outcome
4052
4293
  };
4053
4294
  }
4295
+ function staleSessionNotice(previousAgentIds, otherBackendAgentId) {
4296
+ const one = previousAgentIds.length === 1;
4297
+ return `Any session that was already running keeps acting as the previous agent${one ? "" : "s"} (${previousAgentIds.join(", ")}) until it is restarted, and ${one ? "that agent" : "they"} may still be active in Haven` + (otherBackendAgentId ? ` (${otherBackendAgentId} is on the backend it was created on, not this one)` : "") + `: if ${one ? "it" : "they"} should no longer spend, use Remove agent\u2026 on the Haven agent page (it ends ${one ? "its" : "their"} live budgets).`;
4298
+ }
4054
4299
  function completionOutcome(input) {
4055
4300
  const { runtimeInstall } = input;
4056
4301
  const manualSetup = runtimeInstall.errorCode === "manual_runtime_setup_required";
@@ -4068,7 +4313,10 @@ function completionOutcome(input) {
4068
4313
  probe: { result: runtimeInstall.probeResult },
4069
4314
  activation: {
4070
4315
  restart_required: runtimeInstall.restartRequired,
4071
- instruction: manualSetup ? "Finish the manual MCP setup using the secret-free references shown in normal Connect output, then start a fresh session." : runtimeProfile(runtimeInstall.runtime).activationInstruction
4316
+ instruction: (manualSetup ? "Finish the manual MCP setup using the secret-free references shown in normal Connect output, then start a fresh session." : runtimeProfile(runtimeInstall.runtime).activationInstruction) + // #3772: a rebind changes which agent the server name means, but a
4317
+ // session that is already running keeps the config it loaded — it
4318
+ // goes on acting as the previous agent, which Connect never revokes.
4319
+ (input.staleSession && input.staleSession.agentIds.length > 0 ? ` ${staleSessionNotice(input.staleSession.agentIds, input.staleSession.otherBackendAgentId)}` : "")
4072
4320
  },
4073
4321
  next_action: nextAction2,
4074
4322
  approval: {
@@ -4104,6 +4352,9 @@ function completionOutcome(input) {
4104
4352
  // #3122: always emitted on a completed run (empty list included), for the
4105
4353
  // same reason as superseded_agent_ids above.
4106
4354
  existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
4355
+ // #3737: the resolved hosted server name — always present on a completed
4356
+ // run, bare pair included, so a caller never has to parse the prose.
4357
+ server_name: serverNamesFor(input.serverName).hosted,
4107
4358
  ...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
4108
4359
  ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4109
4360
  ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
@@ -4127,7 +4378,8 @@ function installedClientProse(hint) {
4127
4378
  }
4128
4379
  function runtimeSelectionPrompt(options, deps) {
4129
4380
  if (!interactivePromptAllowed(options, deps)) return void 0;
4130
- return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
4381
+ if (deps.promptRuntime) return deps.promptRuntime;
4382
+ return () => (deps.promptRuntimeByInstalledClient ?? resolveRuntimeByInstalledClientPrompt)({ env: deps.env });
4131
4383
  }
4132
4384
  function interactivePromptAllowed(options, deps) {
4133
4385
  if (options.interactive !== true) return false;
@@ -4153,7 +4405,7 @@ async function resolveWiringCollision(collision, agentName, options, deps) {
4153
4405
  }
4154
4406
  throw new ConnectError(
4155
4407
  "wiring_collision",
4156
- `This machine is already wired to a Haven agent with a live key (${supersededIds(collision)}), and setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring. Nothing was written and the Haven setup token is still unused. If you are an AI agent running this command: do NOT add a flag yourself \u2014 relay this to your user and stop. Your user decides: REPLACE the existing wiring (re-run the same command with --replace added, which re-points the pair at the new agent and retires the previous directory locally \u2014 they still revoke the old agent on the Haven agent page), or install ALONGSIDE it (re-run with --name <slug> added, e.g. --name ${collision.suggestedServerName}, which gives the new agent its own haven-<slug> / haven-signer-<slug> pair). Re-run only with the flag your user chooses.`,
4408
+ `This machine is already wired to a Haven agent with a live key (${supersededIds(collision)}), and setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring. Nothing was written and the Haven setup token is still unused. If you are an AI agent running this command: do NOT add a flag yourself \u2014 relay this to your user and stop. Your user decides: REPLACE the existing wiring (re-run the same command with --replace added, which re-points the pair at the new agent and retires the previous directory locally \u2014 they still use Remove agent\u2026 on the Haven agent page to end the old agent\u2019s budgets), or install ALONGSIDE it (re-run with --name <slug> added, e.g. --name ${collision.suggestedServerName}, which gives the new agent its own haven-<slug> / haven-signer-<slug> pair). Re-run only with the flag your user chooses.`,
4157
4409
  "relay_wiring_collision_to_user",
4158
4410
  { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4159
4411
  );
@@ -4434,7 +4686,7 @@ async function listOtherAgentIds(baseDir, currentDirectory) {
4434
4686
  function printNextSteps(result, log, approval, approvalUrl) {
4435
4687
  for (const line of completionHandoffLines(result, approval, approvalUrl)) log(line);
4436
4688
  }
4437
- var CONNECTOR_VERSION, CONNECTOR_CLIENT_IDENTITY, CONNECT_OUTCOME_SCHEMA_VERSION, failureOutcomesByError, RERUN_HINT;
4689
+ var CONNECTOR_VERSION, PRODUCTION_CHAIN_ID, UUID_SHAPE, CONNECTOR_CLIENT_IDENTITY, CONNECT_OUTCOME_SCHEMA_VERSION, failureOutcomesByError, RERUN_HINT;
4438
4690
  var init_runtime = __esm({
4439
4691
  "src/runtime.ts"() {
4440
4692
  init_api();
@@ -4451,7 +4703,9 @@ var init_runtime = __esm({
4451
4703
  init_tombstone();
4452
4704
  init_local_mcp_runtime();
4453
4705
  init_runtime_manifest();
4454
- CONNECTOR_VERSION = "0.8.1-alpha.0";
4706
+ CONNECTOR_VERSION = "0.10.0-alpha.0";
4707
+ PRODUCTION_CHAIN_ID = 8453;
4708
+ UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4455
4709
  CONNECTOR_CLIENT_IDENTITY = `@haven_ai/connect/${CONNECTOR_VERSION}`;
4456
4710
  CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4457
4711
  failureOutcomesByError = /* @__PURE__ */ new WeakMap();
@@ -4661,9 +4915,10 @@ async function startRekey(options, deps = {}) {
4661
4915
  started_at: startedAt,
4662
4916
  expires_at: expiresAt
4663
4917
  });
4918
+ const finishName = options.serverName ?? stored.discoveredSlug;
4664
4919
  const finishCommand = [
4665
4920
  sdk.connectorRerunCommand("--rekey-finish"),
4666
- options.serverName ? `--name ${options.serverName}` : void 0,
4921
+ finishName ? `--name ${finishName}` : void 0,
4667
4922
  "--api-key <the key the dashboard showed you>",
4668
4923
  options.runtime ? `--runtime ${options.runtime}` : "--runtime <your runtime>"
4669
4924
  ].filter(Boolean).join(" ");
@@ -4931,6 +5186,40 @@ function rollUpLevel(checks) {
4931
5186
  if (checks.some((check) => check.level === "advisory")) return "advisory";
4932
5187
  return "ok";
4933
5188
  }
5189
+ function runWrapperAck(wrapperPath, timeoutMs = 3e4) {
5190
+ return new Promise((resolve11, reject) => {
5191
+ const child = child_process.spawn(wrapperPath, ["--ack"], { stdio: ["ignore", "pipe", "pipe"] });
5192
+ let stdout = "";
5193
+ let settled = false;
5194
+ const timer = setTimeout(() => {
5195
+ if (settled) return;
5196
+ settled = true;
5197
+ child.kill();
5198
+ reject(new Error(`signer --ack timed out after ${Math.round(timeoutMs / 1e3)}s`));
5199
+ }, timeoutMs);
5200
+ child.stdout?.on("data", (chunk) => {
5201
+ stdout += chunk.toString("utf8");
5202
+ });
5203
+ child.on("error", (err) => {
5204
+ if (settled) return;
5205
+ settled = true;
5206
+ clearTimeout(timer);
5207
+ reject(err);
5208
+ });
5209
+ child.on("close", (code) => {
5210
+ if (settled) return;
5211
+ settled = true;
5212
+ clearTimeout(timer);
5213
+ if (code === 0) resolve11({ stdout });
5214
+ else reject(new Error(`signer --ack exited ${code ?? "abnormally"}`));
5215
+ });
5216
+ });
5217
+ }
5218
+ function repairCommandNamedFor(runtime, slug) {
5219
+ const nameFlag = ` --name ${slug}`;
5220
+ if (normalizeRuntimeName(runtime)) return `Run: ${RERUN} --doctor --repair --runtime ${runtime}${nameFlag}`;
5221
+ return `Run: ${RERUN} --doctor --repair --runtime <runtime>${nameFlag} \u2014 --repair needs the runtime named; one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`;
5222
+ }
4934
5223
  function runtimeFlagFor(runtime) {
4935
5224
  return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
4936
5225
  }
@@ -5012,6 +5301,35 @@ async function discoverCredentialDirectory(homeDir, explicit) {
5012
5301
  identityDirectories
5013
5302
  };
5014
5303
  }
5304
+ async function readClaudeCodeWiringEvidence(homeDir) {
5305
+ let parsed;
5306
+ try {
5307
+ parsed = JSON.parse(await promises.readFile(path.join(homeDir, ".claude.json"), "utf8"));
5308
+ } catch {
5309
+ return null;
5310
+ }
5311
+ if (typeof parsed !== "object" || parsed === null) return null;
5312
+ const blocks = [parsed.mcpServers];
5313
+ const projects = parsed.projects;
5314
+ if (typeof projects === "object" && projects !== null) {
5315
+ for (const project of Object.values(projects)) {
5316
+ if (typeof project === "object" && project !== null) blocks.push(project.mcpServers);
5317
+ }
5318
+ }
5319
+ const lines = [];
5320
+ for (const block of blocks) {
5321
+ if (typeof block !== "object" || block === null) continue;
5322
+ for (const [name, server] of Object.entries(block)) {
5323
+ const entry = typeof server === "object" && server !== null ? server : {};
5324
+ const parts = [JSON.stringify(name) + ":"];
5325
+ if (typeof entry.command === "string") parts.push(entry.command);
5326
+ if (Array.isArray(entry.args)) parts.push(...entry.args.filter((arg) => typeof arg === "string"));
5327
+ if (typeof entry.url === "string") parts.push(entry.url);
5328
+ lines.push(parts.join(" "));
5329
+ }
5330
+ }
5331
+ return lines.length > 0 ? lines.join("\n") : null;
5332
+ }
5015
5333
  function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bareOwnerExists) {
5016
5334
  if (configText === null) return isPrimary;
5017
5335
  if (slug) {
@@ -5257,8 +5575,11 @@ async function verdictsForAgent(entry, input, deps) {
5257
5575
  id: "signer_process",
5258
5576
  label: "Signer stdio handshake",
5259
5577
  level: "failed",
5260
- detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
5261
- repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
5578
+ // #3799: honest about what the mismatch does and does not prove. The
5579
+ // stored ack covers a tool SET; a doctor bundled with a newer signer
5580
+ // reports exactly this against an older, still-working install.
5581
+ detail: "The stored local-tools consent does not match the tool set this connector bundles \u2014 by design a signer with a different tool set refuses to start. This does not prove the currently running signer is broken: a doctor newer than the installed signer reports exactly this mismatch.",
5582
+ repair: `Run: ${sidecar.wrapper_path} --ack < /dev/null \u2014 re-acknowledge consent with the installed signer, no setup token needed. Or re-run repair with --ack-local-tools: ${repairCommandFor(input.runtime, " --ack-local-tools", input.repairDir)}.`
5262
5583
  });
5263
5584
  } else {
5264
5585
  const probe = await (deps.probeSignerTools ?? probeLocalMcpTools)(
@@ -5289,12 +5610,21 @@ async function verdictsForAgent(entry, input, deps) {
5289
5610
  }
5290
5611
  return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
5291
5612
  }
5613
+ async function resolveNamedDirectory(input, homeDir) {
5614
+ if (!input.serverName) return input.credentialsDir;
5615
+ if (input.credentialsDir) {
5616
+ await assertDirectoryRecordsSlug(input.credentialsDir, input.serverName);
5617
+ return input.credentialsDir;
5618
+ }
5619
+ return resolveAgentDirectoryBySlug(input.serverName, { root: path.join(homeDir, ".haven", "agents") });
5620
+ }
5292
5621
  async function runDoctor(input, deps = {}) {
5293
5622
  const homeDir = deps.homeDir ?? os.homedir();
5294
5623
  const checks = [];
5295
5624
  let signerCapabilities;
5296
- const { directory, others, parkedOnly, identityDirectories } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
5297
- const nameRepairDir = identityDirectories.length > 1 || Boolean(input.credentialsDir);
5625
+ const namedDir = await resolveNamedDirectory(input, homeDir);
5626
+ const { directory, others, parkedOnly, identityDirectories } = await discoverCredentialDirectory(homeDir, namedDir);
5627
+ const nameRepairDir = identityDirectories.length > 1 || Boolean(namedDir);
5298
5628
  const resolution = await resolveDoctorRuntime(input, directory);
5299
5629
  const runtime = resolution.runtime;
5300
5630
  const input2 = { ...input, runtime };
@@ -5308,6 +5638,7 @@ async function runDoctor(input, deps = {}) {
5308
5638
  configText = null;
5309
5639
  }
5310
5640
  }
5641
+ const claudeCodeWiring = configText === null && normalizedRuntime === "claude-code" ? await readClaudeCodeWiringEvidence(homeDir) : null;
5311
5642
  const allDirectories = directory ? [directory, ...others] : others;
5312
5643
  let bareOwnerExists = false;
5313
5644
  for (const dir of allDirectories) {
@@ -5341,7 +5672,10 @@ async function runDoctor(input, deps = {}) {
5341
5672
  });
5342
5673
  continue;
5343
5674
  }
5344
- const wired = agentIsWired(configText, names, slug, identity, sidecar, dir === directory, bareOwnerExists);
5675
+ const wired = agentIsWired(configText, names, slug, identity, sidecar, dir === directory, bareOwnerExists) || // Additive only: a name or wrapper path in ~/.claude.json proves this
5676
+ // directory is wired; its absence proves nothing (#3830), so the
5677
+ // fallback verdict above is never downgraded.
5678
+ claudeCodeWiring !== null && agentIsWired(claudeCodeWiring, names, slug, identity, sidecar, false, false);
5345
5679
  const entry = {
5346
5680
  ...slug ? { slug } : {},
5347
5681
  ...identity.agent_id ? { agentId: identity.agent_id } : {},
@@ -5409,15 +5743,20 @@ async function runDoctor(input, deps = {}) {
5409
5743
  id: "runtime_config",
5410
5744
  label: "Runtime MCP config",
5411
5745
  level: "failed",
5412
- 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(", ")}.`,
5746
+ detail: `Runtime is unknown \u2014 no runtime flag was given and the connector's record in ${primaryDirectory ?? namedDir ?? "~/.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(", ")}.`,
5413
5747
  repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
5414
5748
  });
5415
5749
  } else if (configPath === null) {
5750
+ const claudePairSuffix = input2.runtime === "claude-code" && primaryDirectory ? await (async () => {
5751
+ const sidecar = await readRuntimeSidecar(primaryDirectory);
5752
+ const names = serverNamesFor(sidecar?.server_name);
5753
+ return ` The MCP pair recorded for this folder is ${names.hosted} / ${names.signer} \u2014 confirm with \`claude mcp get ${names.signer}\`.`;
5754
+ })() : "";
5416
5755
  checks.push({
5417
5756
  id: "runtime_config",
5418
5757
  label: "Runtime MCP config",
5419
5758
  level: "ok",
5420
- 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.`
5759
+ 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.${claudePairSuffix}`
5421
5760
  });
5422
5761
  } else if (configText === null) {
5423
5762
  checks.push({
@@ -5491,13 +5830,17 @@ async function runDoctor(input, deps = {}) {
5491
5830
  const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
5492
5831
  checks.push({
5493
5832
  id: "superseded_agents",
5494
- label: "Superseded agent credentials",
5833
+ // #3830: an advisory means the connector could not tell wired from
5834
+ // superseded, so its label must not assert "superseded" either. The
5835
+ // inventory `classification` stays as is (#3121 decision 5); the label
5836
+ // is what a person reads.
5837
+ label: supersededLevel === "advisory" ? "Other agent credentials (wiring not verifiable)" : "Superseded agent credentials",
5495
5838
  level: supersededLevel,
5496
- 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("; ")}.`,
5839
+ 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 writes, and none it can read names these agents, so which of them 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("; ")}.`,
5497
5840
  ...supersededLevel === "failed" ? {
5498
- 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.`
5841
+ repair: `Use Remove agent\u2026 on the Haven agent page for ${supersededLive.join(", ")} (it ends each one\u2019s live budgets), then remove the old director(y/ies) under ~/.haven/agents. Connect never revokes or deletes for you.`
5499
5842
  } : supersededLevel === "advisory" ? {
5500
- 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.`
5843
+ repair: `Check ${supersededLive.join(", ")} on the Haven agent page: use Remove agent\u2026 on the ones you no longer use, then remove their director(y/ies) under ~/.haven/agents. Connect never revokes or deletes for you.`
5501
5844
  } : {}
5502
5845
  });
5503
5846
  }
@@ -5522,7 +5865,7 @@ async function runDoctor(input, deps = {}) {
5522
5865
  label: "MCP server names bound more than once",
5523
5866
  level: "advisory",
5524
5867
  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.`,
5525
- repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
5868
+ repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name \u2014 --doctor --repair --name <slug> now selects the pair directly.`
5526
5869
  });
5527
5870
  }
5528
5871
  const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
@@ -5582,20 +5925,24 @@ async function runDoctor(input, deps = {}) {
5582
5925
  async function runRepair(input, deps = {}) {
5583
5926
  const homeDir = deps.homeDir ?? os.homedir();
5584
5927
  const messages = [];
5585
- const { directory, others, identityDirectories } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
5586
- if (!input.credentialsDir && identityDirectories.length > 1) {
5928
+ const namedDir = await resolveNamedDirectory(input, homeDir);
5929
+ const { directory, others, identityDirectories } = await discoverCredentialDirectory(homeDir, namedDir);
5930
+ if (!namedDir && identityDirectories.length > 1) {
5587
5931
  const lines = await Promise.all(
5588
5932
  identityDirectories.map(async (dir) => {
5589
5933
  const identity2 = await readIdentity(dir);
5590
5934
  const who = identity2?.agent_id ? `agent ${identity2.agent_id}` : "agent (no agent_id recorded)";
5591
5935
  const retired = !identity2?.api_key || await pathExists3(path.join(dir, TOMBSTONE_FILENAME));
5592
- return retired ? ` \u2022 ${who} (${dir}): retired \u2014 not a repair target` : ` \u2022 ${who}: ${repairCommandFor(input.runtime, "", dir).replace(/^Run: /, "")}`;
5936
+ const sidecar = await readRuntimeSidecar(dir);
5937
+ const slug = sidecar?.server_name;
5938
+ const pair = slug ? `${serverNamesFor(slug).hosted} / ${serverNamesFor(slug).signer}` : "the bare haven / haven-signer pair";
5939
+ return retired ? ` \u2022 ${who} \u2014 ${pair} (${dir}): retired \u2014 not a repair target` : slug ? ` \u2022 ${who} \u2014 owns ${pair}: ${repairCommandNamedFor(input.runtime, slug).replace(/^Run: /, "")}` : ` \u2022 ${who} \u2014 owns ${pair}: ${repairCommandFor(input.runtime, "", dir).replace(/^Run: /, "")}`;
5593
5940
  })
5594
5941
  );
5595
5942
  return {
5596
5943
  ok: false,
5597
5944
  messages: [
5598
- `${identityDirectories.length} agent credential directories exist on this machine, and a repair rewrites the runtime config from ONE of them. Name the agent to repair with --credentials-dir \u2014 nothing was changed:`,
5945
+ `${identityDirectories.length} agent credential directories exist on this machine, and a repair rewrites the runtime config from ONE of them. Name the agent to repair with --name <slug> (its recorded pair) or --credentials-dir \u2014 nothing was changed:`,
5599
5946
  ...lines,
5600
5947
  "Not sure which agent is which? Compare the agent ids against the Haven agent page, or run --doctor first."
5601
5948
  ]
@@ -5653,13 +6000,62 @@ async function runRepair(input, deps = {}) {
5653
6000
  const existingSidecar = await readRuntimeSidecar(directory);
5654
6001
  const serverName = existingSidecar?.server_name;
5655
6002
  const signerPath = path.join(directory, "signer.json");
6003
+ const consent = await getLocalSignerConsentStatus(signerPath);
6004
+ const consentMismatch = !consent.acknowledged && (consent.reason === "ack_file_missing" || consent.reason === "ack_file_mismatch");
6005
+ if (consentMismatch && !input.ackLocalTools) {
6006
+ const wrapperPath = existingSidecar?.wrapper_path ?? path.join(directory, "bin", "haven-signer.mjs");
6007
+ return {
6008
+ ok: false,
6009
+ messages: [
6010
+ "Repair stopped before writing anything: the signer it installs would refuse to start until local-tools consent is re-acknowledged \u2014 the stored acknowledgement does not match the tool set of the signer this connector pins.",
6011
+ "The mismatch does not prove the currently running signer is broken; a newer tool set is enough to explain it.",
6012
+ `Re-consent without a setup token, produced by the installed signer: ${wrapperPath} --ack < /dev/null`,
6013
+ `Or re-run repair with --ack-local-tools to re-consent during the repair: ${repairCommandFor(input2.runtime, " --ack-local-tools", directory)}`
6014
+ ]
6015
+ };
6016
+ }
5656
6017
  const prepared = await prepareSignerRuntime(
5657
6018
  { credentialDirectory: directory, signerPath, homeDir, serverName },
5658
6019
  { runCommand: deps.runCommand, env: deps.env }
5659
6020
  );
5660
6021
  messages.push(...prepared.messages);
5661
6022
  const names = serverNamesFor(serverName);
5662
- messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
6023
+ const rewritesConfig = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir) !== null;
6024
+ if (rewritesConfig) {
6025
+ messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
6026
+ } else {
6027
+ messages.push(
6028
+ `Re-pointed ${prepared.wrapperPath} at signer ${signerPackageSpec()}. ` + (input2.runtime === "claude-code" ? `Claude Code's MCP entries were not touched \u2014 the entry that launches this wrapper uses the new signer after a restart. Confirm with \`claude mcp get ${names.signer}\`.` : `No MCP config was written \u2014 '${input2.runtime}' is configured through its own CLI or by hand; the entry that launches this wrapper uses the new signer after a restart.`)
6029
+ );
6030
+ }
6031
+ if (consentMismatch && input.ackLocalTools) {
6032
+ messages.push(`Re-acknowledging local-tools consent with the installed signer (${signerPackageSpec()}):`);
6033
+ try {
6034
+ const ack = await (deps.runSignerAck ?? runWrapperAck)(prepared.wrapperPath);
6035
+ for (const line of ack.stdout.split("\n")) {
6036
+ if (line.trim()) messages.push(` ${line}`);
6037
+ }
6038
+ } catch (err) {
6039
+ return {
6040
+ ok: false,
6041
+ messages: [
6042
+ ...messages,
6043
+ `The re-acknowledgement failed: ${err instanceof Error ? err.message : String(err)} \u2014 consent was NOT written; the runtime config was not rewritten.`
6044
+ ]
6045
+ };
6046
+ }
6047
+ const after = await getLocalSignerConsentStatus(signerPath);
6048
+ if (!after.acknowledged) {
6049
+ return {
6050
+ ok: false,
6051
+ messages: [
6052
+ ...messages,
6053
+ `Consent still does not match after the re-acknowledgement (${after.reason ?? after.error}). The runtime config was not rewritten.`
6054
+ ]
6055
+ };
6056
+ }
6057
+ messages.push("Local-tools consent acknowledged for the installed signer.");
6058
+ }
5663
6059
  const configResult = await writeRuntimeConfig({
5664
6060
  // Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
5665
6061
  // switches on the id, and the raw alias fell to its "manual runtime" arm
@@ -5722,6 +6118,7 @@ function parseArgs(argv, env = process.env) {
5722
6118
  let dryRun = false;
5723
6119
  let unwireDir;
5724
6120
  let replace = false;
6121
+ let bare = false;
5725
6122
  for (let i = 0; i < argv.length; i += 1) {
5726
6123
  const arg = argv[i];
5727
6124
  if (arg === "--help" || arg === "-h") {
@@ -5769,6 +6166,8 @@ function parseArgs(argv, env = process.env) {
5769
6166
  options.credentialsDir = requireValue(argv, ++i, arg);
5770
6167
  } else if (arg === "--replace") {
5771
6168
  replace = true;
6169
+ } else if (arg === "--bare") {
6170
+ bare = true;
5772
6171
  } else if (arg === "--name") {
5773
6172
  options.serverName = requireValue(argv, ++i, arg);
5774
6173
  assertValidServerSlug(options.serverName);
@@ -5794,16 +6193,23 @@ function parseArgs(argv, env = process.env) {
5794
6193
  if (help) {
5795
6194
  return { options, help, json, doctor, repair, tombstone, rekey };
5796
6195
  }
5797
- if (replace) {
6196
+ if (replace || bare) {
5798
6197
  if (rekeyPhase || tombstoneDir || unwire || doctor || repair || pruneSignerRuntimes2) {
5799
- 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.");
6198
+ throw new Error(`${replace ? "--replace" : "--bare"} belongs to a --setup run: it says what to do when the bare haven / haven-signer pair is already wired to another agent.`);
5800
6199
  }
5801
- if (options.serverName) {
6200
+ if (bare && options.serverName) {
5802
6201
  throw new Error(
5803
- "--replace and --name contradict each other: --replace overwrites the bare haven / haven-signer wiring, --name installs alongside it under its own pair. Pass one of them."
6202
+ "--bare and --name contradict each other: --bare wires the bare haven / haven-signer pair, --name installs a named haven-<slug> pair. Pass one of them."
5804
6203
  );
5805
6204
  }
5806
- options.replaceExistingWiring = true;
6205
+ if (replace) {
6206
+ if (options.serverName) {
6207
+ throw new Error(
6208
+ "--replace and --name contradict each other: --replace overwrites the bare haven / haven-signer wiring, --name installs alongside it under its own pair. Pass one of them."
6209
+ );
6210
+ }
6211
+ options.replaceExistingWiring = true;
6212
+ }
5807
6213
  }
5808
6214
  if (destroyKeyMaterial && !unwire) {
5809
6215
  throw new Error("--destroy-key-material only applies to --unwire.");
@@ -5869,6 +6275,11 @@ function parseArgs(argv, env = process.env) {
5869
6275
  "--repair needs --runtime <runtime>: it rewrites that runtime's config, so the runtime is named explicitly rather than inherited from the setup record. --doctor alone resolves it from the record."
5870
6276
  );
5871
6277
  }
6278
+ if (options.ackLocalTools && !repair) {
6279
+ throw new Error(
6280
+ "--ack-local-tools does nothing with --doctor alone: the doctor is read-only. Re-consent during a repair (--repair --ack-local-tools), or token-free with the recorded wrapper: <agent dir>/bin/haven-signer.mjs --ack"
6281
+ );
6282
+ }
5872
6283
  return { options, help, json, doctor, repair, tombstone, rekey };
5873
6284
  }
5874
6285
  if (!options.setupToken) {
@@ -5877,6 +6288,9 @@ function parseArgs(argv, env = process.env) {
5877
6288
  if (!options.apiBaseUrl) {
5878
6289
  throw new Error("Missing --api <Haven API URL>.");
5879
6290
  }
6291
+ if (!options.serverName && !bare && !replace) {
6292
+ options.deriveServerName = true;
6293
+ }
5880
6294
  options.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, "");
5881
6295
  return { options, help, json, doctor, repair, tombstone, rekey };
5882
6296
  }
@@ -5904,14 +6318,22 @@ function helpText() {
5904
6318
  " --replace When this machine is already wired to a different Haven agent on the bare",
5905
6319
  " haven / haven-signer pair, re-point that pair at the new agent and retire the",
5906
6320
  " previous agent directory locally (tombstoned, local key files removed).",
5907
- " Nothing is revoked \u2014 revoke the old agent on the Haven agent page. Without",
5908
- " this flag a non-interactive run REFUSES such a collision (wiring_collision)",
5909
- " and an interactive terminal is asked; --name installs alongside instead.",
6321
+ " Nothing is revoked \u2014 use Remove agent\u2026 on the Haven agent page. Implies",
6322
+ " the bare pair. Without it (or --bare) a non-interactive run REFUSES such a",
6323
+ " collision (wiring_collision) and an interactive terminal is asked.",
6324
+ " --bare Wire the bare haven / haven-signer pair. Without --name a setup now names",
6325
+ " its pair from the agent's display name (haven-<slug> /",
6326
+ " haven-signer-<slug>, de-collided against this machine, ending -dev on a",
6327
+ " non-production backend), so adding an agent never has to displace anyone;",
6328
+ " --bare is the explicit opt-in to the unnamed pair. --name overrides it.",
5910
6329
  " --name <slug> Wiring slug for a NAMED agent: writes haven-<slug> / haven-signer-<slug>",
5911
6330
  " MCP entries and stores credentials at ~/.haven/agents/<slug>/, so several",
5912
6331
  " agents can run side by side in one runtime. 1-32 lowercase letters, digits,",
5913
- " single hyphens; immutable once wired. Omit for the bare haven / haven-signer pair.",
5914
- " --ack-local-tools Write the one-time local Haven tools acknowledgement during setup.",
6332
+ " single hyphens; immutable once wired. Without it the pair is named from the",
6333
+ " agent's display name; --bare wires the bare haven / haven-signer pair.",
6334
+ " --ack-local-tools Write the local Haven tools acknowledgement: during setup, or (with --repair)",
6335
+ " re-acknowledge consent for the signer the repair installs \u2014 no setup token.",
6336
+ " With a bare --doctor it is refused: --doctor is read-only.",
5915
6337
  " --ack-signer Backward-compatible alias for --ack-local-tools.",
5916
6338
  " --local Advanced: install the fully-local Haven MCP (no hosted dependency).",
5917
6339
  " Only available for Claude Code and Codex. Default is hosted MCP + local signer.",
@@ -5920,10 +6342,13 @@ function helpText() {
5920
6342
  " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero only on a failed check; an advisory (!) exits 0.",
5921
6343
  " --runtime is optional: without it the doctor checks the runtime the setup recorded,",
5922
6344
  " and reports a failure (not a pass) when no record names one.",
6345
+ " --name <slug> selects the agent directory by its recorded MCP pair.",
5923
6346
  " --repair Repair, then re-diagnose (implies --doctor): reinstall the pinned signer",
5924
6347
  " runtime, rewrite the wrapper and runtime config from stored credentials.",
5925
6348
  " Hosted topology only (refuses to touch a --local config). No keys, no token.",
5926
6349
  " Requires --runtime <runtime>: it rewrites that config, so the runtime is never inherited.",
6350
+ " --name <slug> selects the agent to repair by its recorded pair; --ack-local-tools",
6351
+ " re-consents the installed signer (the old token-free path: <agent dir>/bin/haven-signer.mjs --ack).",
5927
6352
  " --rekey Replace this agent's signing key (no token). Generates a fresh keypair HERE and",
5928
6353
  " prints its public address to paste into the Haven agent page. Nothing changes",
5929
6354
  " until you finish; the agent keeps working on its old key throughout.",
@@ -6056,7 +6481,7 @@ async function runCli(argv, io = {
6056
6481
  io.stdout(redactSecrets(`Tombstoned agent ${info.agent_id} at ${parsed.tombstone.directory}.
6057
6482
  `));
6058
6483
  io.stdout(
6059
- "Key files were NOT touched and nothing was revoked \u2014 revoke the agent on the Haven agent page if you have not already.\n"
6484
+ "Key files were NOT touched and nothing was revoked \u2014 on the Haven agent page, use Remove agent\u2026 (or Stop budget) if you have not already.\n"
6060
6485
  );
6061
6486
  io.stdout(
6062
6487
  info.recordPath !== null ? `A surviving tombstone record was mirrored to ${redactSecrets(info.recordPath)}
@@ -6084,11 +6509,12 @@ async function runCli(argv, io = {
6084
6509
  if (parsed.unwire) {
6085
6510
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
6086
6511
  const { tombstonesDirForAgentDirectory: tombstonesDirForAgentDirectory2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
6512
+ const { resolveAgentDirectoryBySlug: resolveAgentDirectoryBySlug2 } = await Promise.resolve().then(() => (init_storage(), storage_exports));
6087
6513
  const { homedir: homedir11 } = await import('os');
6088
6514
  const { join: join13 } = await import('path');
6089
6515
  const homeDir = homedir11();
6090
6516
  const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
6091
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
6517
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? await resolveAgentDirectoryBySlug2(parsed.options.serverName, { root, allowRetired: true }) : root);
6092
6518
  const ledgerDir = tombstonesDirForAgentDirectory2(directory, homeDir);
6093
6519
  try {
6094
6520
  const result = await unwireAgent2({
@@ -6165,7 +6591,7 @@ async function runCli(argv, io = {
6165
6591
  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"
6166
6592
  );
6167
6593
  io.stdout(
6168
- "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"
6594
+ "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, use Remove agent\u2026 on the Haven agent page to end its budgets on-chain and stop it spending.\n"
6169
6595
  );
6170
6596
  }
6171
6597
  return failures.length > 0 || retained ? 1 : 0;
@@ -6290,14 +6716,16 @@ async function runCli(argv, io = {
6290
6716
  const { runDoctor: runDoctor2, runRepair: runRepair2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
6291
6717
  const runtime = parsed.options.runtime ?? "";
6292
6718
  const credentialsDir = parsed.options.credentialsDir;
6719
+ const serverName = parsed.options.serverName;
6720
+ const ackLocalTools = parsed.options.ackLocalTools === true;
6293
6721
  try {
6294
6722
  if (parsed.repair) {
6295
- const repair = await runRepair2({ runtime, credentialsDir });
6723
+ const repair = await runRepair2({ runtime, credentialsDir, serverName, ackLocalTools });
6296
6724
  for (const message of repair.messages) io.stderr(`${redactSecrets(message)}
6297
6725
  `);
6298
6726
  if (!repair.ok) return 1;
6299
6727
  }
6300
- const report = await runDoctor2({ runtime, credentialsDir });
6728
+ const report = await runDoctor2({ runtime, credentialsDir, serverName });
6301
6729
  if (parsed.json) {
6302
6730
  io.stdout(`${redactSecrets(JSON.stringify(report))}
6303
6731
  `);
@@ -6309,13 +6737,16 @@ async function runCli(argv, io = {
6309
6737
  `));
6310
6738
  }
6311
6739
  const otherAgents = report.agents.filter((agent) => agent.directory !== report.credentialDirectory);
6740
+ const wiringUnverifiable = report.checks.some(
6741
+ (check) => check.id === "superseded_agents" && check.level === "advisory"
6742
+ );
6312
6743
  if (otherAgents.length > 0) {
6313
6744
  io.stdout("\nOther agents on this machine:\n");
6314
6745
  for (const agent of otherAgents) {
6315
6746
  const name = agent.slug ? `${agent.slug} (${agent.agentId ?? "unknown"})` : agent.agentId ?? "unknown";
6316
6747
  const failed = agent.checks.filter((check) => check.level === "failed");
6317
6748
  const advised = agent.checks.filter((check) => check.level === "advisory");
6318
- 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;
6749
+ 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 === "superseded" && wiringUnverifiable ? "not verified as wired \u2014 no config the connector can read names it" : agent.classification;
6319
6750
  io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : advised.length > 0 ? "!" : "\u2022"} ${name}: ${verdict}
6320
6751
  `));
6321
6752
  for (const check of [...failed, ...advised]) {