@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.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import crypto, { createHash } from 'crypto';
3
3
  import { Wallet } from 'ethers';
4
- import { mkdir, rm, stat, readdir, readFile, writeFile, chmod, access, unlink, realpath, rename } from 'fs/promises';
5
- import { homedir, platform } from 'os';
6
- import { join, resolve, dirname, basename } from 'path';
7
4
  import { MCP_VERSION, registeredToolNames, ensureConsent, computeConsentHash, loadCredentials, consentInputFromClient } from '@haven_ai/mcp';
8
5
  import { toolSchemas, ensureSignerConsent, computeSignerConsentHash, loadSignerCredentials, createEdgeSigner } from '@haven_ai/signer';
9
6
  import { connectorRerunCommand, HAVEN_MINIMUM_NODE_VERSION, isSupportedNodeVersion, SKILL_FOLDER_NAME, resolveTokenFromAddress, HAVEN_SKILL_MD, HAVEN_SKILL_BODY_MD, unsupportedNodeVersionMessage } from '@haven_ai/sdk';
10
- import { parseDocument, isMap, stringify } from 'yaml';
11
7
  import { execFile, spawn } from 'child_process';
8
+ import { mkdir, rm, stat, readdir, readFile, writeFile, chmod, access, unlink, rename, realpath } from 'fs/promises';
9
+ import { homedir, platform } from 'os';
10
+ import { join, resolve, dirname, basename } from 'path';
12
11
  import { promisify } from 'util';
12
+ import { parseDocument, isMap, stringify } from 'yaml';
13
13
  import { createInterface } from 'readline';
14
14
  import { realpathSync } from 'fs';
15
15
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -65,6 +65,25 @@ function createConnectApiClient(baseUrl, fetchImpl = fetch, clientIdentity) {
65
65
  method: "GET",
66
66
  headers: { Authorization: `Bearer ${apiKey}` }
67
67
  }),
68
+ reportRetiredAgents: async (input) => {
69
+ try {
70
+ const body = await send(
71
+ `${root}/agent-connection-setups/${encodeURIComponent(input.setupId)}/retired-agents`,
72
+ {
73
+ method: "POST",
74
+ headers: { Authorization: `Bearer ${input.apiKey}` },
75
+ body: JSON.stringify({ retired_agent_ids: input.retiredAgentIds })
76
+ }
77
+ );
78
+ const recorded = body?.recorded_agent_ids;
79
+ return {
80
+ recordedAgentIds: Array.isArray(recorded) ? recorded.filter((id) => typeof id === "string") : []
81
+ };
82
+ } catch (err) {
83
+ if (err instanceof ConnectRequestError && err.status === 404) return null;
84
+ throw err;
85
+ }
86
+ },
68
87
  updateInstallStatus: async (setupId, apiKey, input) => {
69
88
  await send(`${root}/agent-connection-setups/${encodeURIComponent(setupId)}/install-status`, {
70
89
  method: "POST",
@@ -234,1086 +253,1538 @@ var init_server_names = __esm({
234
253
  SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
235
254
  }
236
255
  });
237
- async function preflightCredentialStorage(input = {}) {
238
- const directory = defaultCredentialRoot(input.baseDir);
239
- await mkdir(directory, { recursive: true, mode: 448 });
240
- await restrictPermissions(directory, 448, input.warn);
241
- const probePath = join(directory, `.haven-connect-preflight-${crypto.randomBytes(8).toString("hex")}`);
242
- try {
243
- await writeOwnerOnlyJson(probePath, { ok: true }, input.warn);
244
- } finally {
245
- await rm(probePath, { force: true }).catch(() => void 0);
246
- }
247
- return directory;
248
- }
249
- async function writeCredentialFiles(input) {
250
- const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
251
- await mkdir(directory, { recursive: true, mode: 448 });
252
- await restrictPermissions(directory, 448, input.warn);
253
- const identityPath = join(directory, "identity.json");
254
- const signerPath = join(directory, "signer.json");
255
- const agentPath = join(directory, "agent.json");
256
- await assertDoesNotExist(identityPath);
257
- await assertDoesNotExist(signerPath);
258
- await assertDoesNotExist(agentPath);
259
- await writeOwnerOnlyJson(signerPath, signerPayload(input), input.warn);
260
- try {
261
- await writeOwnerOnlyJson(identityPath, identityPayload(input), input.warn);
262
- } catch (err) {
263
- await rm(signerPath, { force: true }).catch(() => void 0);
264
- throw err;
265
- }
266
- try {
267
- await writeOwnerOnlyJson(agentPath, agentPayload(input), input.warn);
268
- } catch (err) {
269
- await rm(signerPath, { force: true }).catch(() => void 0);
270
- await rm(identityPath, { force: true }).catch(() => void 0);
271
- await rm(agentPath, { force: true }).catch(() => void 0);
272
- throw err;
273
- }
274
- return { directory, identityPath, signerPath, agentPath };
275
- }
276
- function signerPayload(input) {
277
- return {
278
- delegate_key: input.delegateKey,
279
- delegate_address: input.delegateAddress,
280
- agent_id: input.agentId,
281
- account_address: input.accountAddress,
282
- chain_id: input.chainId,
283
- network: input.network,
284
- x402_binding_signer: input.x402BindingSigner,
285
- note: "Local signer credential. Haven backend never receives this private key."
286
- };
256
+ function mcpPackageSpec() {
257
+ return `${MCP_RUNTIME_MANIFEST.mcpPackage}@${MCP_RUNTIME_MANIFEST.mcpVersion}`;
287
258
  }
288
- function identityPayload(input) {
289
- return {
290
- api_key: input.apiKey,
291
- agent_id: input.agentId,
292
- account_address: input.accountAddress,
293
- chain_id: input.chainId,
294
- network: input.network,
295
- api_url: input.apiUrl,
296
- hosted_mcp_url: input.hostedMcpUrl,
297
- agent_budget: input.agentBudget,
298
- note: "Haven API key identifies the agent only. It cannot spend without the local signer key and on-chain Haven wallet rules."
299
- };
259
+ function sdkPackageSpec() {
260
+ return `${MCP_RUNTIME_MANIFEST.sdkPackage}@${MCP_RUNTIME_MANIFEST.sdkVersion}`;
300
261
  }
301
- function agentPayload(input) {
302
- return {
303
- agent_id: input.agentId,
304
- delegate_address: input.delegateAddress,
305
- account_address: input.accountAddress,
306
- chain_id: input.chainId,
307
- network: input.network,
308
- agent_budget: input.agentBudget,
309
- 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."
310
- };
262
+ function signerPackageSpec() {
263
+ return `${MCP_RUNTIME_MANIFEST.signerPackage}@${MCP_RUNTIME_MANIFEST.signerVersion}`;
311
264
  }
312
- async function readStoredCredentials(serverName, agentIdOrSlug, baseDir) {
313
- const key = serverName ?? agentIdOrSlug;
314
- const directory = key ? defaultAgentDirectory(key, baseDir) : await discoverSoleAgentDirectory(baseDir);
315
- const identity = await readJsonFile(join(directory, "identity.json"));
316
- if (!identity) {
317
- throw new Error(
318
- `No Haven credentials at ${directory}. Nothing to re-key \u2014 connect this agent first, or pass the --name you wired it under.`
319
- );
265
+ var MCP_RUNTIME_MANIFEST;
266
+ var init_runtime_manifest = __esm({
267
+ "src/runtime-manifest.ts"() {
268
+ MCP_RUNTIME_MANIFEST = {
269
+ mcpPackage: "@haven_ai/mcp",
270
+ mcpVersion: MCP_VERSION,
271
+ sdkPackage: "@haven_ai/sdk",
272
+ sdkVersion: "0.10.0-alpha.0",
273
+ signerPackage: "@haven_ai/signer",
274
+ signerVersion: "0.10.0-alpha.0",
275
+ // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
276
+ // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
277
+ // so the guard that was supposed to enforce the floor waved Node v23 through
278
+ // — including on the `--local` path where it does run. A hand-maintained
279
+ // second copy of a number is a drift waiting to happen; a guard test pins
280
+ // this against `package.json`'s `engines.node`.
281
+ minimumNodeVersion: HAVEN_MINIMUM_NODE_VERSION,
282
+ supportedClients: ["codex-cli", "codex-desktop", "claude-code"],
283
+ requiredTools: registeredToolNames(),
284
+ /**
285
+ * The signer MCP's tool surface, DERIVED from the pinned @haven_ai/signer
286
+ * package (#1587) — same anti-drift rule as `requiredTools` above: a
287
+ * literal list here would rot the first time the signer gains a tool.
288
+ * The handshake probe requires all of them.
289
+ */
290
+ requiredSignerTools: Object.keys(toolSchemas)
291
+ };
320
292
  }
321
- const agent = await readJsonFile(join(directory, "agent.json")) ?? {};
322
- const signer = await readJsonFile(join(directory, "signer.json")) ?? {};
323
- const agentId = asString(identity.agent_id);
324
- const apiKey = asString(identity.api_key);
325
- const apiUrl = asString(identity.api_url);
326
- const hostedMcpUrl = asString(identity.hosted_mcp_url);
327
- if (!agentId || !apiKey || !apiUrl || !hostedMcpUrl) {
328
- throw new Error(
329
- `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.`
330
- );
293
+ });
294
+ function resolveRuntimeSpecOverride(env) {
295
+ const override = {};
296
+ for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
297
+ const variable = RUNTIME_SPEC_ENV[pkg];
298
+ const raw = env[variable];
299
+ if (raw === void 0) continue;
300
+ override[pkg] = validateSpec(variable, raw);
331
301
  }
332
- return {
333
- directory,
334
- agentId,
335
- apiKey,
336
- apiUrl,
337
- hostedMcpUrl,
338
- delegateAddress: asString(agent.delegate_address) ?? asString(signer.delegate_address),
339
- ...readStoredAccountAddress(identity, agent),
340
- chainId: typeof identity.chain_id === "number" ? identity.chain_id : void 0,
341
- network: asString(identity.network),
342
- x402BindingSigner: asString(signer.x402_binding_signer),
343
- agentBudget: Array.isArray(identity.agent_budget) ? identity.agent_budget : void 0
344
- };
345
- }
346
- function readStoredAccountAddress(identity, agent) {
347
- const fresh = asString(identity.account_address) ?? asString(agent.account_address);
348
- if (fresh) return { accountAddress: fresh, accountAddressKey: "account_address" };
349
- const legacy = asString(identity.safe_address) ?? asString(agent.safe_address);
350
- if (legacy) return { accountAddress: legacy, accountAddressKey: "safe_address" };
351
- return {};
302
+ return Object.keys(override).length > 0 ? override : void 0;
352
303
  }
353
- async function discoverSoleAgentDirectory(baseDir) {
354
- const root = defaultCredentialRoot(baseDir);
355
- let entries = [];
356
- try {
357
- entries = await readdir(root);
358
- } catch {
359
- throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
360
- }
361
- const candidates = [];
362
- for (const entry of entries) {
363
- const directory = join(root, entry);
364
- if (!await readJsonFile(join(directory, "identity.json"))) continue;
365
- if (await readJsonFile(join(directory, "TOMBSTONE.json"))) continue;
366
- candidates.push(directory);
304
+ function validateSpec(variable, raw) {
305
+ if (raw.length === 0 || raw.trim().length === 0) {
306
+ throw new RuntimeSpecOverrideError(variable, "it is empty");
367
307
  }
368
- if (candidates.length === 1) return candidates[0];
369
- if (candidates.length === 0) {
370
- throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
308
+ if (/\s/.test(raw)) {
309
+ throw new RuntimeSpecOverrideError(variable, "it contains whitespace");
371
310
  }
372
- throw new Error(
373
- `Several agents are wired on this machine, so --rekey cannot tell which one you mean:
374
- ` + candidates.map((d) => ` ${d}`).join("\n") + "\nRe-run with --name <slug> to pick one."
375
- );
376
- }
377
- async function rewriteCredentialFiles(input) {
378
- const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
379
- const identityPath = join(directory, "identity.json");
380
- const signerPath = join(directory, "signer.json");
381
- const agentPath = join(directory, "agent.json");
382
- const targets = [
383
- { path: signerPath, payload: signerPayload(input) },
384
- { path: identityPath, payload: identityPayload(input) },
385
- { path: agentPath, payload: agentPayload(input) }
386
- ];
387
- const originals = /* @__PURE__ */ new Map();
388
- for (const { path } of targets) {
389
- originals.set(path, await readRawFile(path));
311
+ if (/[\u0000-\u001f\u007f]/.test(raw)) {
312
+ throw new RuntimeSpecOverrideError(variable, "it contains a control character");
390
313
  }
391
- const temps = [];
392
- try {
393
- for (const { path, payload } of targets) {
394
- const temp = `${path}.rekey-${crypto.randomBytes(6).toString("hex")}.tmp`;
395
- await writeOwnerOnlyJson(temp, payload, input.warn);
396
- temps.push({ from: temp, to: path });
397
- }
398
- for (const { from, to } of temps) {
399
- await rename(from, to);
400
- }
401
- } catch (err) {
402
- for (const { from } of temps) await rm(from, { force: true }).catch(() => void 0);
403
- for (const [path, contents] of originals) {
404
- if (contents === null) {
405
- await rm(path, { force: true }).catch(() => void 0);
406
- } else {
407
- await writeFile(path, contents, { mode: 384 }).catch(() => void 0);
408
- }
409
- }
410
- throw err;
314
+ const meta = SHELL_METACHARACTERS.exec(raw);
315
+ if (meta) {
316
+ throw new RuntimeSpecOverrideError(variable, `it contains the shell metacharacter ${JSON.stringify(meta[0])}`);
411
317
  }
412
- return { directory, identityPath, signerPath, agentPath };
318
+ return raw;
413
319
  }
414
- async function writeRekeyPending(directory, pending, warn) {
415
- const path = join(directory, REKEY_PENDING_FILENAME);
416
- await rm(path, { force: true }).catch(() => void 0);
417
- await writeOwnerOnlyJson(path, { ...pending }, warn);
418
- return path;
320
+ function runtimeSpecOverrideDirectoryKey(resolvedSpecs) {
321
+ const digest = createHash("sha256").update(resolvedSpecs.join("\n")).digest("hex");
322
+ return `override-${digest.slice(0, 12)}`;
419
323
  }
420
- async function readRekeyPending(directory, now = Date.now()) {
421
- const path = join(directory, REKEY_PENDING_FILENAME);
422
- const raw = await readJsonFile(path);
423
- if (!raw) {
424
- throw new Error(
425
- `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.`
426
- );
324
+ function overrideApplies(override, packages) {
325
+ return override !== void 0 && packages.some((pkg) => override[pkg] !== void 0);
326
+ }
327
+ function runtimeSpecOverrideNotice(runtimeLabel, override, pinned, runtimeDirectory) {
328
+ const lines = [`RUNTIME SPEC OVERRIDE ACTIVE for the local Haven ${runtimeLabel} \u2014 this is NOT the pinned manifest.`];
329
+ for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
330
+ const spec = override[pkg];
331
+ if (spec === void 0) continue;
332
+ const pin = pinned[pkg];
333
+ lines.push(` ${RUNTIME_SPEC_ENV[pkg]}=${spec}${pin ? ` (instead of ${pin})` : ""}`);
427
334
  }
428
- const pending = raw;
429
- if (!pending.new_delegate_key || !pending.new_delegate_address || !pending.agent_id) {
430
- throw new Error(`The pending re-key at ${path} is unreadable. Delete it and start again with --rekey.`);
431
- }
432
- if (pending.expires_at && Date.parse(pending.expires_at) < now) {
433
- throw new Error(
434
- `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.`
435
- );
436
- }
437
- return pending;
335
+ lines.push(` Installing into ${runtimeDirectory} \u2014 the pinned runtime directory is untouched.`);
336
+ lines.push(" Unset the variable(s) and re-run to return to the pinned manifest.");
337
+ return lines;
438
338
  }
439
- async function inspectRekeyPending(directory, now = Date.now()) {
440
- const path = join(directory, REKEY_PENDING_FILENAME);
441
- const raw = await readJsonFile(path);
442
- if (!raw) {
443
- const present = await readRawFile(path) !== null;
444
- return present ? { state: "unreadable", path } : null;
339
+ function describeRuntimeSpecOverride(override) {
340
+ return Object.keys(RUNTIME_SPEC_ENV).filter((pkg) => override[pkg] !== void 0).map((pkg) => `${RUNTIME_SPEC_ENV[pkg]}=${override[pkg]}`).join(" ");
341
+ }
342
+ var RUNTIME_SPEC_ENV, SHELL_METACHARACTERS, RuntimeSpecOverrideError;
343
+ var init_runtime_spec_override = __esm({
344
+ "src/runtime-spec-override.ts"() {
345
+ RUNTIME_SPEC_ENV = {
346
+ signer: "HAVEN_SIGNER_SPEC",
347
+ sdk: "HAVEN_SDK_SPEC",
348
+ mcp: "HAVEN_MCP_SPEC"
349
+ };
350
+ SHELL_METACHARACTERS = /[;&|<>$`'"()!#*?[\]{}\\]/;
351
+ RuntimeSpecOverrideError = class extends Error {
352
+ code = "runtime_spec_override_invalid";
353
+ variable;
354
+ constructor(variable, reason) {
355
+ 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>).`);
356
+ this.name = "RuntimeSpecOverrideError";
357
+ this.variable = variable;
358
+ }
359
+ };
445
360
  }
446
- const agentId = asString(raw.agent_id);
447
- const newDelegateAddress = asString(raw.new_delegate_address);
448
- const startedAt = asString(raw.started_at);
449
- const expiresAt = asString(raw.expires_at);
450
- if (!agentId || !newDelegateAddress) {
451
- return { state: "unreadable", path, ...startedAt ? { startedAt } : {} };
361
+ });
362
+ async function prepareSignerRuntime(input, deps = {}) {
363
+ const homeDir = input.homeDir ?? homedir();
364
+ const override = resolveSignerRuntimeOverride(deps.env ?? process.env);
365
+ const signerSpec = override?.signer ?? signerPackageSpec();
366
+ const sdkSpec = override?.sdk ?? sdkPackageSpec();
367
+ const resolvedSpecs = [signerSpec, sdkSpec];
368
+ const overrideRecord = override ? { specs: override, resolved_specs: resolvedSpecs, directory_key: runtimeSpecOverrideDirectoryKey(resolvedSpecs) } : void 0;
369
+ const runtimeDirectory = resolve(
370
+ homeDir,
371
+ ".haven",
372
+ "signer-runtime",
373
+ overrideRecord ? overrideRecord.directory_key : MCP_RUNTIME_MANIFEST.signerVersion
374
+ );
375
+ const npmCacheDirectory = resolve(homeDir, ".haven", "npm-cache");
376
+ const cliPath = join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "dist", "cli.js");
377
+ const messages = [];
378
+ if (override) {
379
+ messages.push(...runtimeSpecOverrideNotice(
380
+ "signer runtime",
381
+ override,
382
+ { signer: signerPackageSpec(), sdk: sdkPackageSpec() },
383
+ runtimeDirectory
384
+ ));
452
385
  }
453
- const expired = expiresAt !== void 0 && Date.parse(expiresAt) < now;
386
+ await mkdir(runtimeDirectory, { recursive: true, mode: 448 });
387
+ await chmod(runtimeDirectory, 448).catch(() => void 0);
388
+ await mkdir(npmCacheDirectory, { recursive: true, mode: 448 });
389
+ await chmod(npmCacheDirectory, 448).catch(() => void 0);
390
+ if (override) {
391
+ await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
392
+ messages.push(`Installed local Haven signer runtime from override (${resolvedSpecs.join(" ")}).`);
393
+ } else if (await installedRuntimeMatches(runtimeDirectory, cliPath)) {
394
+ messages.push(`Using existing local Haven signer runtime ${signerPackageSpec()}.`);
395
+ } else {
396
+ await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
397
+ messages.push(`Installed local Haven signer runtime ${signerPackageSpec()}.`);
398
+ }
399
+ await assertFileExists(cliPath, "local Haven signer CLI");
400
+ const installedVersions = override ? await readInstalledVersions(runtimeDirectory) : void 0;
401
+ const wrapperPath = join(input.credentialDirectory, "bin", "haven-signer.mjs");
402
+ await writeWrapper({
403
+ wrapperPath,
404
+ cliPath,
405
+ signerPath: input.signerPath,
406
+ overrideComment: override ? describeRuntimeSpecOverride(override) : void 0
407
+ });
408
+ await writeRuntimeSidecar({
409
+ path: join(input.credentialDirectory, "signer-runtime.json"),
410
+ wrapperPath,
411
+ runtimeDirectory,
412
+ npmCacheDirectory,
413
+ cliPath,
414
+ serverName: input.serverName,
415
+ override: overrideRecord,
416
+ installedVersions
417
+ });
418
+ messages.push(`Prepared stable local Haven signer wrapper: ${wrapperPath}`);
454
419
  return {
455
- state: expired ? "expired" : "pending",
456
- path,
457
- agentId,
458
- newDelegateAddress,
459
- ...startedAt ? { startedAt } : {},
460
- ...expiresAt ? { expiresAt } : {}
420
+ command: wrapperPath,
421
+ args: [],
422
+ wrapperPath,
423
+ runtimeDirectory,
424
+ npmCacheDirectory,
425
+ cliPath,
426
+ messages,
427
+ ...overrideRecord ? { runtimeSpecOverride: overrideRecord } : {}
461
428
  };
462
429
  }
463
- async function clearRekeyPending(directory) {
464
- await rm(join(directory, REKEY_PENDING_FILENAME), { force: true }).catch(() => void 0);
465
- }
466
- async function readJsonFile(path) {
467
- const raw = await readRawFile(path);
468
- if (raw === null) return null;
469
- try {
470
- const parsed = JSON.parse(raw);
471
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
472
- } catch {
473
- return null;
474
- }
430
+ function resolveSignerRuntimeOverride(env) {
431
+ const override = resolveRuntimeSpecOverride(env);
432
+ if (!overrideApplies(override, ["signer", "sdk"])) return void 0;
433
+ const { signer, sdk } = override;
434
+ return { ...signer !== void 0 ? { signer } : {}, ...sdk !== void 0 ? { sdk } : {} };
475
435
  }
476
- async function readRawFile(path) {
436
+ async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, packageSpecs, deps) {
437
+ const { runCommand, onProgress } = deps;
438
+ const baseArgs = [
439
+ "install",
440
+ "--prefix",
441
+ runtimeDirectory,
442
+ "--no-audit",
443
+ "--no-fund",
444
+ "--omit=dev",
445
+ "--prefer-offline",
446
+ ...packageSpecs
447
+ ];
448
+ const run = async (args) => {
449
+ const startedAt = Date.now();
450
+ const heartbeat = setInterval(() => {
451
+ const seconds = Math.round((Date.now() - startedAt) / 1e3);
452
+ onProgress?.(`Still installing the local Haven signer runtime\u2026 (${seconds}s \u2014 a cold cache can take several minutes)`);
453
+ }, SIGNER_INSTALL_HEARTBEAT_MS);
454
+ heartbeat.unref?.();
455
+ try {
456
+ if (runCommand) await runCommand("npm", args);
457
+ else await execFileAsync("npm", args, { timeout: SIGNER_INSTALL_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
458
+ } finally {
459
+ clearInterval(heartbeat);
460
+ }
461
+ };
477
462
  try {
478
- return await readFile(path, "utf8");
463
+ await run(baseArgs);
479
464
  } catch {
480
- return null;
465
+ try {
466
+ await run([...baseArgs, "--cache", npmCacheDirectory]);
467
+ } catch (err) {
468
+ throw new Error(
469
+ `Could not install local Haven signer runtime ${packageSpecs.join(" ")}: ${err instanceof Error ? err.message : String(err)}`
470
+ );
471
+ }
481
472
  }
482
473
  }
483
- function asString(value) {
484
- return typeof value === "string" && value.length > 0 ? value : void 0;
474
+ async function installedRuntimeMatches(runtimeDirectory, cliPath) {
475
+ return installedRuntimeMatchesVersions(runtimeDirectory, cliPath, {
476
+ signerVersion: MCP_RUNTIME_MANIFEST.signerVersion,
477
+ sdkVersion: MCP_RUNTIME_MANIFEST.sdkVersion
478
+ });
485
479
  }
486
- async function assertServerSlugAvailable(serverName, baseDir) {
487
- const directory = defaultAgentDirectory(serverName, baseDir);
480
+ async function installedRuntimeMatchesVersions(runtimeDirectory, cliPath, expected) {
488
481
  try {
489
- await stat(join(directory, "identity.json"));
482
+ await assertFileExists(cliPath, "local Haven signer CLI");
483
+ const installed = await readInstalledVersions(runtimeDirectory);
484
+ return installed.signerVersion === expected.signerVersion && installed.sdkVersion === expected.sdkVersion;
490
485
  } catch {
491
- return;
486
+ return false;
492
487
  }
493
- throw new Error(
494
- `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.`
495
- );
496
- }
497
- function defaultAgentDirectory(agentId, baseDir = join(homedir(), ".haven", "agents")) {
498
- return resolve(defaultCredentialRoot(baseDir), safePathPart(agentId));
499
488
  }
500
- function defaultCredentialRoot(baseDir = join(homedir(), ".haven", "agents")) {
501
- return resolve(baseDir);
502
- }
503
- async function writeOwnerOnlyJson(path, value, warn) {
504
- const json = JSON.stringify(dropUndefined(value), null, 2);
505
- await writeFile(path, `${json}
506
- `, { mode: 384, flag: "wx" });
507
- await restrictPermissions(path, 384, warn);
508
- }
509
- function safePathPart(value) {
510
- return value.replace(/[^A-Za-z0-9_.-]/g, "_");
489
+ async function readInstalledVersions(runtimeDirectory) {
490
+ const [signerPackage, sdkPackage] = await Promise.all([
491
+ readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "package.json")),
492
+ readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
493
+ ]);
494
+ return { signerVersion: signerPackage.version ?? "", sdkVersion: sdkPackage.version ?? "" };
511
495
  }
512
- function dropUndefined(value) {
513
- return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
496
+ async function readPackageJson(path) {
497
+ return JSON.parse(await readFile(path, "utf8"));
514
498
  }
515
- async function assertDoesNotExist(path) {
516
- try {
517
- await access(path);
518
- } catch (err) {
519
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
520
- throw err;
521
- }
522
- throw new Error(`Refusing to overwrite existing Haven credential file: ${path}`);
499
+ async function writeWrapper(input) {
500
+ await mkdir(dirname(input.wrapperPath), { recursive: true, mode: 448 });
501
+ await chmod(dirname(input.wrapperPath), 448).catch(() => void 0);
502
+ const source = [
503
+ "#!/usr/bin/env node",
504
+ ...input.overrideComment ? [`// HAVEN RUNTIME SPEC OVERRIDE (#2424): ${input.overrideComment} \u2014 this wrapper launches a NON-pinned signer build.`] : [],
505
+ "import { spawn } from 'node:child_process'",
506
+ "",
507
+ `const cliPath = ${JSON.stringify(input.cliPath)}`,
508
+ `const signerPath = ${JSON.stringify(input.signerPath)}`,
509
+ "",
510
+ "const child = spawn(process.execPath, [cliPath, '--credentials', signerPath, ...process.argv.slice(2)], {",
511
+ " stdio: 'inherit',",
512
+ "})",
513
+ "",
514
+ "child.on('exit', (code, signal) => {",
515
+ " if (signal) process.kill(process.pid, signal)",
516
+ " else process.exit(code ?? 1)",
517
+ "})",
518
+ ""
519
+ ].join("\n");
520
+ await writeFile(input.wrapperPath, source, { mode: 448 });
521
+ await chmod(input.wrapperPath, 448).catch(() => void 0);
523
522
  }
524
- async function restrictPermissions(path, mode, warn) {
523
+ async function readRuntimeSidecar(credentialDirectory) {
525
524
  try {
526
- await chmod(path, mode);
527
- } catch (err) {
528
- warn?.(
529
- `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)}`
525
+ return JSON.parse(
526
+ await readFile(join(credentialDirectory, "signer-runtime.json"), "utf8")
530
527
  );
528
+ } catch {
529
+ return null;
531
530
  }
532
531
  }
533
- async function writeConnectOutcomeRecord(directory, outcome, warn) {
534
- const path = join(directory, CONNECT_OUTCOME_FILENAME);
535
- await writeFile(path, `${JSON.stringify(outcome, null, 2)}
532
+ async function writeRuntimeSidecar(input) {
533
+ const value = {
534
+ ...input.serverName ? { server_name: input.serverName } : {},
535
+ signer_package: MCP_RUNTIME_MANIFEST.signerPackage,
536
+ signer_version: input.installedVersions?.signerVersion ?? MCP_RUNTIME_MANIFEST.signerVersion,
537
+ sdk_package: MCP_RUNTIME_MANIFEST.sdkPackage,
538
+ sdk_version: input.installedVersions?.sdkVersion ?? MCP_RUNTIME_MANIFEST.sdkVersion,
539
+ wrapper_path: input.wrapperPath,
540
+ runtime_directory: input.runtimeDirectory,
541
+ npm_cache_directory: input.npmCacheDirectory,
542
+ cli_path: input.cliPath,
543
+ ...input.override ? { runtime_spec_override: input.override } : {}
544
+ };
545
+ await writeFile(input.path, `${JSON.stringify(value, null, 2)}
536
546
  `, { mode: 384 });
537
- await restrictPermissions(path, 384, warn);
538
- return path;
547
+ await chmod(input.path, 384).catch(() => void 0);
539
548
  }
540
- async function readConnectOutcomeRuntime(directory) {
549
+ async function assertFileExists(path, label) {
541
550
  try {
542
- const parsed = JSON.parse(await readFile(join(directory, CONNECT_OUTCOME_FILENAME), "utf8"));
543
- if (typeof parsed !== "object" || parsed === null) return null;
544
- const runtime = parsed.runtime;
545
- return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
551
+ await access(path);
546
552
  } catch {
547
- return null;
553
+ throw new Error(`Missing ${label}: ${path}`);
548
554
  }
549
555
  }
550
- async function writeMcpServerBinding(directory, binding) {
551
- const path = join(directory, MCP_SERVER_BINDING_FILENAME);
552
- await writeFile(path, `${JSON.stringify(binding, null, 2)}
553
- `, { mode: 384 });
554
- return path;
555
- }
556
- async function readMcpServerBinding(directory) {
557
- try {
558
- const parsed = JSON.parse(await readFile(join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
559
- if (typeof parsed !== "object" || parsed === null) return null;
560
- const record = parsed;
561
- 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;
562
- return record;
563
- } catch {
564
- return null;
556
+ var execFileAsync, SIGNER_INSTALL_TIMEOUT_MS, SIGNER_INSTALL_HEARTBEAT_MS;
557
+ var init_signer_runtime = __esm({
558
+ "src/signer-runtime.ts"() {
559
+ init_runtime_manifest();
560
+ init_runtime_spec_override();
561
+ execFileAsync = promisify(execFile);
562
+ SIGNER_INSTALL_TIMEOUT_MS = 6e5;
563
+ SIGNER_INSTALL_HEARTBEAT_MS = 15e3;
565
564
  }
565
+ });
566
+
567
+ // src/connect-error.ts
568
+ function isConnectError(err) {
569
+ return err instanceof ConnectError;
566
570
  }
567
- async function clearMcpServerBinding(directory) {
568
- try {
569
- await rm(join(directory, MCP_SERVER_BINDING_FILENAME));
570
- return true;
571
- } catch {
572
- return false;
571
+ var ConnectError;
572
+ var init_connect_error = __esm({
573
+ "src/connect-error.ts"() {
574
+ ConnectError = class extends Error {
575
+ code;
576
+ nextAction;
577
+ details;
578
+ constructor(code, message, nextAction2, details = {}) {
579
+ super(message);
580
+ this.name = "ConnectError";
581
+ this.code = code;
582
+ this.nextAction = nextAction2;
583
+ this.details = details;
584
+ }
585
+ };
573
586
  }
587
+ });
588
+
589
+ // src/tombstone.ts
590
+ var tombstone_exports = {};
591
+ __export(tombstone_exports, {
592
+ ROOT_TOMBSTONES_DIRNAME: () => ROOT_TOMBSTONES_DIRNAME,
593
+ TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
594
+ TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
595
+ defaultTombstonesDir: () => defaultTombstonesDir,
596
+ readAgentTombstone: () => readAgentTombstone,
597
+ readTombstoneRecords: () => readTombstoneRecords,
598
+ tombstonesDirForAgentDirectory: () => tombstonesDirForAgentDirectory,
599
+ tombstonesDirForCredentialRoot: () => tombstonesDirForCredentialRoot,
600
+ writeAgentTombstone: () => writeAgentTombstone
601
+ });
602
+ function defaultTombstonesDir(baseDir) {
603
+ return join(baseDir ?? join(homedir(), ".haven"), "tombstones");
574
604
  }
575
- async function listMcpServerBindings(baseDir, excludeDirectory) {
576
- const root = defaultCredentialRoot(baseDir);
605
+ function tombstonesDirForCredentialRoot(credentialRoot, homeDir = homedir()) {
606
+ if (!credentialRoot) return defaultTombstonesDir(join(homeDir, ".haven"));
607
+ const root = resolve(credentialRoot);
608
+ if (root === resolve(homeDir, ".haven", "agents")) return defaultTombstonesDir(join(homeDir, ".haven"));
609
+ return join(root, ROOT_TOMBSTONES_DIRNAME);
610
+ }
611
+ function tombstonesDirForAgentDirectory(directory, homeDir = homedir()) {
612
+ return tombstonesDirForCredentialRoot(dirname(resolve(directory)), homeDir);
613
+ }
614
+ async function readTombstoneRecords(tombstonesDir) {
615
+ const root = tombstonesDir ?? defaultTombstonesDir();
577
616
  let entries = [];
578
617
  try {
579
618
  entries = await readdir(root);
580
619
  } catch {
581
620
  return [];
582
621
  }
583
- const out = [];
622
+ const records = [];
584
623
  for (const entry of entries) {
585
- const directory = join(root, entry);
586
- const binding = await readMcpServerBinding(directory);
587
- if (binding) out.push({ directory, binding });
624
+ if (!entry.endsWith(".json")) continue;
625
+ const recordPath = join(root, entry);
626
+ try {
627
+ const parsed = JSON.parse(await readFile(recordPath, "utf8"));
628
+ if (typeof parsed?.agent_id === "string") {
629
+ records.push({ ...parsed, recordPath });
630
+ }
631
+ } catch {
632
+ }
588
633
  }
589
- return out;
634
+ return records;
590
635
  }
591
- var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME, MCP_SERVER_BINDING_FILENAME;
592
- var init_storage = __esm({
593
- "src/storage.ts"() {
594
- REKEY_PENDING_FILENAME = "rekey-pending.json";
595
- REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
596
- CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
597
- MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
598
- }
599
- });
600
- function mcpPackageSpec() {
601
- return `${MCP_RUNTIME_MANIFEST.mcpPackage}@${MCP_RUNTIME_MANIFEST.mcpVersion}`;
636
+ function tombstoneScript(info) {
637
+ const lines = [
638
+ `${TOMBSTONE_MARKER}: this Haven agent was retired.`,
639
+ "",
640
+ ` agent: ${info.agent_id}`,
641
+ ` retired at: ${info.retired_at}`,
642
+ ` reason: ${info.reason}`,
643
+ ...info.replaced_by ? [` replaced by: ${info.replaced_by}`] : [],
644
+ "",
645
+ "This process is running with a wiring snapshot that predates the",
646
+ "retirement \u2014 it loaded its MCP config at startup and has kept it since.",
647
+ "Restart THIS host to pick up the current wiring. If several long-lived",
648
+ "hosts are running (a gateway, a TUI worker, an editor), restart EVERY",
649
+ "one of them: each holds the snapshot from its own start time, so after",
650
+ "a chain of recreations each can be parked on a DIFFERENT old agent.",
651
+ "",
652
+ `Then verify with: ${connectorRerunCommand("--doctor --runtime <runtime>")}`
653
+ ];
654
+ return [
655
+ "#!/usr/bin/env node",
656
+ `// ${TOMBSTONE_MARKER} \u2014 written by @haven_ai/connect (#1681). Safe to delete`,
657
+ "// once every long-lived MCP host on this machine has been restarted.",
658
+ `process.stderr.write(${JSON.stringify(lines.join("\n") + "\n")})`,
659
+ "process.exit(1)",
660
+ ""
661
+ ].join("\n");
602
662
  }
603
- function sdkPackageSpec() {
604
- return `${MCP_RUNTIME_MANIFEST.sdkPackage}@${MCP_RUNTIME_MANIFEST.sdkVersion}`;
663
+ async function writeAgentTombstone(input) {
664
+ const dirStat = await stat(input.directory).catch(() => null);
665
+ if (!dirStat?.isDirectory()) {
666
+ throw new ConnectError(
667
+ "tombstone_directory_not_found",
668
+ `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.`,
669
+ "retry_with_an_existing_agent_directory"
670
+ );
671
+ }
672
+ const info = {
673
+ // reason / replaced_by are persisted to disk and re-emitted to the host's
674
+ // MCP stderr log on EVERY stale probe, potentially for months — redact
675
+ // like every other output path, at the write layer so any future caller
676
+ // inherits it. (#1681 review, finding 1)
677
+ agent_id: input.agentId,
678
+ retired_at: input.retiredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
679
+ reason: redactSecrets(input.reason),
680
+ ...input.replacedBy ? { replaced_by: redactSecrets(input.replacedBy) } : {}
681
+ };
682
+ const binDir = join(input.directory, "bin");
683
+ await mkdir(binDir, { recursive: true });
684
+ const wrapperPath = join(binDir, "haven-signer.mjs");
685
+ await writeFile(wrapperPath, tombstoneScript(info), "utf8");
686
+ await chmod(wrapperPath, 493);
687
+ const record = JSON.stringify(info, null, 2) + "\n";
688
+ await writeFile(join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
689
+ const root = input.tombstonesDir ?? defaultTombstonesDir();
690
+ const recordPath = join(root, `${info.agent_id}.json`);
691
+ try {
692
+ await mkdir(root, { recursive: true, mode: 448 });
693
+ await writeFile(recordPath, record, { mode: MIRROR_MODE });
694
+ } catch (err) {
695
+ const code = err?.code;
696
+ return { ...info, recordPath: null, mirrorError: typeof code === "string" && /^E[A-Z]+$/.test(code) ? code : "mirror_write_failed" };
697
+ }
698
+ return { ...info, recordPath };
605
699
  }
606
- function signerPackageSpec() {
607
- return `${MCP_RUNTIME_MANIFEST.signerPackage}@${MCP_RUNTIME_MANIFEST.signerVersion}`;
700
+ async function readAgentTombstone(directory) {
701
+ try {
702
+ const parsed = JSON.parse(await readFile(join(directory, TOMBSTONE_FILENAME), "utf8"));
703
+ if (typeof parsed?.agent_id !== "string") return null;
704
+ return parsed;
705
+ } catch {
706
+ return null;
707
+ }
608
708
  }
609
- var MCP_RUNTIME_MANIFEST;
610
- var init_runtime_manifest = __esm({
611
- "src/runtime-manifest.ts"() {
612
- MCP_RUNTIME_MANIFEST = {
613
- mcpPackage: "@haven_ai/mcp",
614
- mcpVersion: MCP_VERSION,
615
- sdkPackage: "@haven_ai/sdk",
616
- sdkVersion: "0.8.1-alpha.0",
617
- signerPackage: "@haven_ai/signer",
618
- signerVersion: "0.8.1-alpha.0",
619
- // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
620
- // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
621
- // so the guard that was supposed to enforce the floor waved Node v23 through
622
- // — including on the `--local` path where it does run. A hand-maintained
623
- // second copy of a number is a drift waiting to happen; a guard test pins
624
- // this against `package.json`'s `engines.node`.
625
- minimumNodeVersion: HAVEN_MINIMUM_NODE_VERSION,
626
- supportedClients: ["codex-cli", "codex-desktop", "claude-code"],
627
- requiredTools: registeredToolNames(),
628
- /**
629
- * The signer MCP's tool surface, DERIVED from the pinned @haven_ai/signer
630
- * package (#1587) — same anti-drift rule as `requiredTools` above: a
631
- * literal list here would rot the first time the signer gains a tool.
632
- * The handshake probe requires all of them.
633
- */
634
- requiredSignerTools: Object.keys(toolSchemas)
635
- };
709
+ var TOMBSTONE_FILENAME, ROOT_TOMBSTONES_DIRNAME, MIRROR_MODE, TOMBSTONE_MARKER;
710
+ var init_tombstone = __esm({
711
+ "src/tombstone.ts"() {
712
+ init_redact();
713
+ init_connect_error();
714
+ TOMBSTONE_FILENAME = "TOMBSTONE.json";
715
+ ROOT_TOMBSTONES_DIRNAME = ".tombstones";
716
+ MIRROR_MODE = 384;
717
+ TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
636
718
  }
637
719
  });
638
- async function writeRuntimeConfig(input, deps = {}) {
639
- switch (input.runtime) {
640
- case "codex-cli":
641
- case "codex-desktop":
642
- return writeCodexConfig(input);
643
- case "cursor":
644
- return writeJsonRuntimeConfig(input, cursorConfigPath(input.homeDir), "mcpServers");
645
- case "vscode":
646
- return writeJsonRuntimeConfig(input, vscodeConfigPath(input.homeDir), "servers");
647
- case "vscode-insiders":
648
- return writeJsonRuntimeConfig(input, vscodeInsidersConfigPath(input.homeDir), "servers");
649
- case "claude-desktop":
650
- return writeJsonRuntimeConfig(input, claudeDesktopConfigPath(input.homeDir), "mcpServers");
651
- case "hermes":
652
- return writeHermesConfig(input, deps);
653
- default:
654
- return {
655
- hostedConfigured: false,
656
- signerConfigured: false,
657
- localMcpConfigured: false,
658
- runtimeMcpMode: "manual",
659
- target: "manual runtime setup",
660
- changed: false,
661
- restartRequired: true,
662
- messages: ["Runtime config needs to be added manually for this agent environment."],
663
- errorCode: "manual_runtime_setup_required"
664
- };
720
+
721
+ // src/storage.ts
722
+ var storage_exports = {};
723
+ __export(storage_exports, {
724
+ CONNECT_OUTCOME_FILENAME: () => CONNECT_OUTCOME_FILENAME,
725
+ MCP_SERVER_BINDING_FILENAME: () => MCP_SERVER_BINDING_FILENAME,
726
+ REKEY_PENDING_FILENAME: () => REKEY_PENDING_FILENAME,
727
+ REKEY_PENDING_TTL_MS: () => REKEY_PENDING_TTL_MS,
728
+ assertDirectoryRecordsSlug: () => assertDirectoryRecordsSlug,
729
+ assertServerSlugAvailable: () => assertServerSlugAvailable,
730
+ clearMcpServerBinding: () => clearMcpServerBinding,
731
+ clearRekeyPending: () => clearRekeyPending,
732
+ defaultAgentDirectory: () => defaultAgentDirectory,
733
+ defaultCredentialRoot: () => defaultCredentialRoot,
734
+ inspectRekeyPending: () => inspectRekeyPending,
735
+ listMcpServerBindings: () => listMcpServerBindings,
736
+ preflightCredentialStorage: () => preflightCredentialStorage,
737
+ readConnectOutcomeRuntime: () => readConnectOutcomeRuntime,
738
+ readMcpServerBinding: () => readMcpServerBinding,
739
+ readRekeyPending: () => readRekeyPending,
740
+ readStoredAccountAddress: () => readStoredAccountAddress,
741
+ readStoredCredentials: () => readStoredCredentials,
742
+ resolveAgentDirectoryBySlug: () => resolveAgentDirectoryBySlug,
743
+ rewriteCredentialFiles: () => rewriteCredentialFiles,
744
+ writeConnectOutcomeRecord: () => writeConnectOutcomeRecord,
745
+ writeCredentialFiles: () => writeCredentialFiles,
746
+ writeMcpServerBinding: () => writeMcpServerBinding,
747
+ writeRekeyPending: () => writeRekeyPending
748
+ });
749
+ async function preflightCredentialStorage(input = {}) {
750
+ const directory = defaultCredentialRoot(input.baseDir);
751
+ await mkdir(directory, { recursive: true, mode: 448 });
752
+ await restrictPermissions(directory, 448, input.warn);
753
+ const probePath = join(directory, `.haven-connect-preflight-${crypto.randomBytes(8).toString("hex")}`);
754
+ try {
755
+ await writeOwnerOnlyJson(probePath, { ok: true }, input.warn);
756
+ } finally {
757
+ await rm(probePath, { force: true }).catch(() => void 0);
665
758
  }
759
+ return directory;
666
760
  }
667
- function buildHostedServer(hostedMcpUrl, apiKey, runtime) {
668
- if (runtime === "vscode" || runtime === "vscode-insiders") {
669
- return {
670
- type: "http",
671
- url: hostedMcpUrl,
672
- headers: { Authorization: `Bearer ${apiKey}` }
673
- };
761
+ async function writeCredentialFiles(input) {
762
+ const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
763
+ await mkdir(directory, { recursive: true, mode: 448 });
764
+ await restrictPermissions(directory, 448, input.warn);
765
+ const identityPath = join(directory, "identity.json");
766
+ const signerPath = join(directory, "signer.json");
767
+ const agentPath = join(directory, "agent.json");
768
+ await assertDoesNotExist(identityPath);
769
+ await assertDoesNotExist(signerPath);
770
+ await assertDoesNotExist(agentPath);
771
+ await writeOwnerOnlyJson(signerPath, signerPayload(input), input.warn);
772
+ try {
773
+ await writeOwnerOnlyJson(identityPath, identityPayload(input), input.warn);
774
+ } catch (err) {
775
+ await rm(signerPath, { force: true }).catch(() => void 0);
776
+ throw err;
777
+ }
778
+ try {
779
+ await writeOwnerOnlyJson(agentPath, agentPayload(input), input.warn);
780
+ } catch (err) {
781
+ await rm(signerPath, { force: true }).catch(() => void 0);
782
+ await rm(identityPath, { force: true }).catch(() => void 0);
783
+ await rm(agentPath, { force: true }).catch(() => void 0);
784
+ throw err;
674
785
  }
786
+ return { directory, identityPath, signerPath, agentPath };
787
+ }
788
+ function signerPayload(input) {
675
789
  return {
676
- url: hostedMcpUrl,
677
- headers: { Authorization: `Bearer ${apiKey}` }
790
+ delegate_key: input.delegateKey,
791
+ delegate_address: input.delegateAddress,
792
+ agent_id: input.agentId,
793
+ account_address: input.accountAddress,
794
+ chain_id: input.chainId,
795
+ network: input.network,
796
+ x402_binding_signer: input.x402BindingSigner,
797
+ note: "Local signer credential. Haven backend never receives this private key."
678
798
  };
679
799
  }
680
- function resolveSignerLaunchSpec(input) {
681
- return input.signerCommand ?? {
682
- command: "npx",
683
- args: ["-y", signerPackageSpec(), "--credentials", input.signerPath]
800
+ function identityPayload(input) {
801
+ return {
802
+ api_key: input.apiKey,
803
+ agent_id: input.agentId,
804
+ account_address: input.accountAddress,
805
+ chain_id: input.chainId,
806
+ network: input.network,
807
+ api_url: input.apiUrl,
808
+ hosted_mcp_url: input.hostedMcpUrl,
809
+ agent_budget: input.agentBudget,
810
+ note: "Haven API key identifies the agent only. It cannot spend without the local signer key and on-chain Haven wallet rules."
684
811
  };
685
812
  }
686
- function buildSignerServer(spec, runtime) {
687
- const server = {
688
- command: spec.command,
689
- args: spec.args
813
+ function agentPayload(input) {
814
+ return {
815
+ agent_id: input.agentId,
816
+ delegate_address: input.delegateAddress,
817
+ account_address: input.accountAddress,
818
+ chain_id: input.chainId,
819
+ network: input.network,
820
+ agent_budget: input.agentBudget,
821
+ 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."
690
822
  };
691
- if (runtime === "vscode" || runtime === "vscode-insiders") return { type: "stdio", ...server };
692
- return server;
693
823
  }
694
- function mergeJsonMcpConfig(existingJson, serverRoot, hostedServer, signerServer, names = serverNamesFor(), configPath) {
695
- const config = existingJson?.trim() ? parseJsonObject(existingJson, configPath) : {};
696
- const existingRoot = config[serverRoot];
697
- const servers = existingRoot && typeof existingRoot === "object" && !Array.isArray(existingRoot) ? existingRoot : {};
698
- config[serverRoot] = {
699
- ...servers,
700
- [names.hosted]: hostedServer,
701
- [names.signer]: signerServer
824
+ async function readStoredCredentials(serverName, agentIdOrSlug, baseDir) {
825
+ const directory = serverName ? await resolveAgentDirectoryBySlug(serverName, { root: baseDir }) : agentIdOrSlug ? defaultAgentDirectory(agentIdOrSlug, baseDir) : await discoverSoleAgentDirectory(baseDir);
826
+ const identity = await readJsonFile(join(directory, "identity.json"));
827
+ if (!identity) {
828
+ throw new Error(
829
+ `No Haven credentials at ${directory}. Nothing to re-key \u2014 connect this agent first, or pass the --name you wired it under.`
830
+ );
831
+ }
832
+ let discoveredSlug;
833
+ if (!serverName && !agentIdOrSlug) {
834
+ const binding = await readJsonFile(join(directory, "mcp-server-binding.json"));
835
+ const hostedName = binding && typeof binding === "object" ? asString(binding.server_name) : void 0;
836
+ if (hostedName && hostedName.startsWith("haven-")) {
837
+ const slug = hostedName.slice("haven-".length);
838
+ try {
839
+ assertValidServerSlug(slug);
840
+ discoveredSlug = slug;
841
+ } catch {
842
+ }
843
+ }
844
+ }
845
+ const agent = await readJsonFile(join(directory, "agent.json")) ?? {};
846
+ const signer = await readJsonFile(join(directory, "signer.json")) ?? {};
847
+ const agentId = asString(identity.agent_id);
848
+ const apiKey = asString(identity.api_key);
849
+ const apiUrl = asString(identity.api_url);
850
+ const hostedMcpUrl = asString(identity.hosted_mcp_url);
851
+ if (!agentId || !apiKey || !apiUrl || !hostedMcpUrl) {
852
+ throw new Error(
853
+ `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.`
854
+ );
855
+ }
856
+ return {
857
+ directory,
858
+ agentId,
859
+ apiKey,
860
+ ...discoveredSlug ? { discoveredSlug } : {},
861
+ apiUrl,
862
+ hostedMcpUrl,
863
+ delegateAddress: asString(agent.delegate_address) ?? asString(signer.delegate_address),
864
+ ...readStoredAccountAddress(identity, agent),
865
+ chainId: typeof identity.chain_id === "number" ? identity.chain_id : void 0,
866
+ network: asString(identity.network),
867
+ x402BindingSigner: asString(signer.x402_binding_signer),
868
+ agentBudget: Array.isArray(identity.agent_budget) ? identity.agent_budget : void 0
702
869
  };
703
- return `${JSON.stringify(config, null, 2)}
704
- `;
705
870
  }
706
- function mergeHermesYaml(existingYaml, hostedServer, signerServer, names = serverNamesFor(), configPath) {
707
- if (!existingYaml?.trim()) {
708
- return renderHermesYaml({ [names.hosted]: hostedServer, [names.signer]: signerServer });
871
+ function readStoredAccountAddress(identity, agent) {
872
+ const fresh = asString(identity.account_address) ?? asString(agent.account_address);
873
+ if (fresh) return { accountAddress: fresh, accountAddressKey: "account_address" };
874
+ const legacy = asString(identity.safe_address) ?? asString(agent.safe_address);
875
+ if (legacy) return { accountAddress: legacy, accountAddressKey: "safe_address" };
876
+ return {};
877
+ }
878
+ async function discoverSoleAgentDirectory(baseDir) {
879
+ const root = defaultCredentialRoot(baseDir);
880
+ let entries = [];
881
+ try {
882
+ entries = await readdir(root);
883
+ } catch {
884
+ throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
709
885
  }
710
- const doc = parseDocument(existingYaml, { keepSourceTokens: true });
711
- if (doc.errors.length > 0 || !isMap(doc.contents)) {
712
- throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
886
+ const candidates = [];
887
+ for (const entry of entries) {
888
+ const directory = join(root, entry);
889
+ const identity = await readJsonFile(join(directory, "identity.json"));
890
+ if (!identity) continue;
891
+ if (!asString(identity.api_key)) continue;
892
+ if (await readJsonFile(join(directory, "TOMBSTONE.json"))) continue;
893
+ candidates.push(directory);
713
894
  }
714
- const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
715
- const existingServers = mcpPair && isMap(mcpPair.value) ? mcpPair.value.toJSON() : {};
716
- const servers = isRecord(existingServers) ? existingServers : {};
717
- const mergedServers = {
718
- ...servers,
719
- [names.hosted]: hostedServer,
720
- [names.signer]: signerServer
721
- };
722
- if (!mcpPair) {
723
- return appendHermesMcpServers(existingYaml, mergedServers);
895
+ if (candidates.length === 1) return candidates[0];
896
+ if (candidates.length === 0) {
897
+ throw new Error(`No Haven credentials found under ${root}. Connect an agent on this machine first.`);
724
898
  }
725
- if (!mcpPair.value?.range) return replaceEmptyHermesMcpServers(existingYaml, mcpPair.key?.range, mergedServers);
726
- return replaceHermesMcpServers(existingYaml, mcpPair.key?.range, mcpPair.value.range, mergedServers);
899
+ throw new Error(
900
+ `Several agents are wired on this machine, so --rekey cannot tell which one you mean:
901
+ ` + candidates.map((d) => ` ${d}`).join("\n") + "\nRe-run with --name <slug> to pick one."
902
+ );
727
903
  }
728
- function mergeHermesEnv(existingEnv, apiKey, envKey = HERMES_API_KEY_ENV) {
729
- if (/[\r\n]/.test(apiKey)) throw new Error("Hermes API key must be a single line");
730
- const assignment = `${envKey}=${apiKey}`;
731
- if (!existingEnv) return `${assignment}
732
- `;
733
- const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
734
- const hasTrailingNewline = /\r?\n$/.test(existingEnv);
735
- const lines = existingEnv.split(/\r?\n/);
736
- if (hasTrailingNewline) lines.pop();
737
- let found = false;
738
- const merged = lines.flatMap((line) => {
739
- if (isHermesEnvAssignment(line, envKey)) {
740
- if (found) return [];
741
- found = true;
742
- return [assignment];
743
- }
744
- if (isAmbiguousHermesEnvLine(line, envKey)) {
745
- throw new Error("Hermes environment contains an ambiguous managed key");
904
+ async function assertDirectoryRecordsSlug(directory, slug) {
905
+ assertValidServerSlug(slug);
906
+ const sidecar = await readRuntimeSidecar(directory);
907
+ const bindingRecord = await readJsonFile(join(directory, "mcp-server-binding.json"));
908
+ const bindingSlug = bindingRecord ? asString(bindingRecord.server_name) : void 0;
909
+ const claimed = sidecar?.server_name ?? bindingSlug;
910
+ if (claimed === slug) return;
911
+ if (!claimed && basename(directory) === slug) return;
912
+ throw new Error(
913
+ `--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}'.`
914
+ );
915
+ }
916
+ async function resolveAgentDirectoryBySlug(slug, opts = {}) {
917
+ assertValidServerSlug(slug);
918
+ const root = defaultCredentialRoot(opts.root);
919
+ let entries = [];
920
+ try {
921
+ entries = await readdir(root);
922
+ } catch {
923
+ throw new Error(
924
+ `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.`
925
+ );
926
+ }
927
+ const claims = [];
928
+ const recordedSlugs = [];
929
+ let barePairCount = 0;
930
+ let legacyFolder;
931
+ for (const entry of entries.sort()) {
932
+ const directory = join(root, entry);
933
+ const retired = await readJsonFile(join(directory, TOMBSTONE_FILENAME)) !== null;
934
+ const sidecar = await readRuntimeSidecar(directory);
935
+ const bindingRecord = await readJsonFile(join(directory, "mcp-server-binding.json"));
936
+ const bindingSlug = bindingRecord ? asString(bindingRecord.server_name) : void 0;
937
+ const claimedSlug = sidecar?.server_name ?? bindingSlug;
938
+ if (claimedSlug && !recordedSlugs.includes(claimedSlug)) recordedSlugs.push(claimedSlug);
939
+ if (sidecar && !sidecar.server_name) barePairCount++;
940
+ if (retired && !opts.allowRetired) continue;
941
+ if (claimedSlug === slug) {
942
+ const identity = await readJsonFile(join(directory, "identity.json"));
943
+ claims.push({ directory, agentId: identity ? asString(identity.agent_id) ?? null : null, retired });
746
944
  }
747
- return [line];
748
- });
749
- if (!found) {
750
- return `${existingEnv}${hasTrailingNewline ? "" : lineEnding}${assignment}${lineEnding}`;
945
+ if (!claimedSlug && !retired && entry === slug) legacyFolder = directory;
751
946
  }
752
- return `${merged.join(lineEnding)}${hasTrailingNewline ? lineEnding : ""}`;
753
- }
754
- function isHermesEnvAssignment(line, envKey) {
755
- return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(line);
756
- }
757
- function isAmbiguousHermesEnvLine(line, envKey) {
758
- return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}\\b`).test(line);
947
+ if (claims.length > 1) {
948
+ throw new Error(
949
+ `${claims.length} agent directories on this machine record the pair 'haven-${slug}' \u2014 the MCP server name changed hands (mcp_server_name_rebound):
950
+ ` + 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."
951
+ );
952
+ }
953
+ if (claims.length === 1) return claims[0].directory;
954
+ if (legacyFolder) return legacyFolder;
955
+ const known = [
956
+ ...recordedSlugs.map((s) => `'haven-${s}'`),
957
+ ...barePairCount > 0 ? [`${barePairCount} unnamed 'haven' / 'haven-signer' pair${barePairCount === 1 ? "" : "s"} (no --name)`] : []
958
+ ];
959
+ throw new Error(
960
+ `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>."
961
+ );
759
962
  }
760
- function removeHermesYaml(existingYaml, names, configPath) {
761
- if (!existingYaml?.trim()) return existingYaml ?? "";
762
- const doc = parseDocument(existingYaml, { keepSourceTokens: true });
763
- if (doc.errors.length > 0 || !isMap(doc.contents)) {
764
- throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
963
+ async function rewriteCredentialFiles(input) {
964
+ const directory = defaultAgentDirectory(input.serverName ?? input.agentId, input.baseDir);
965
+ const identityPath = join(directory, "identity.json");
966
+ const signerPath = join(directory, "signer.json");
967
+ const agentPath = join(directory, "agent.json");
968
+ const targets = [
969
+ { path: signerPath, payload: signerPayload(input) },
970
+ { path: identityPath, payload: identityPayload(input) },
971
+ { path: agentPath, payload: agentPayload(input) }
972
+ ];
973
+ const originals = /* @__PURE__ */ new Map();
974
+ for (const { path } of targets) {
975
+ originals.set(path, await readRawFile(path));
765
976
  }
766
- const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
767
- if (!mcpPair || !isMap(mcpPair.value)) return existingYaml;
768
- const toRemove = mcpPair.value.items.filter((item) => {
769
- const name = item.key?.toString();
770
- return name === names.hosted || name === names.signer;
771
- });
772
- if (toRemove.length === 0) return existingYaml;
773
- if (toRemove.length !== mcpPair.value.items.length && toRemove.some((item) => !item.key?.range || !item.value?.range)) {
774
- throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "an MCP server entry cannot be safely removed");
775
- }
776
- let out = existingYaml;
777
- const ranges = toRemove.map((item) => {
778
- const keyStart = item.key.range[0];
779
- const valueEnd = item.value.range[1];
780
- const lineStart = existingYaml.lastIndexOf("\n", keyStart - 1) + 1;
781
- let end = valueEnd;
782
- if (existingYaml.slice(end).startsWith("\r\n")) end += 2;
783
- else if (existingYaml[end] === "\n") end += 1;
784
- return [lineStart, end];
785
- });
786
- ranges.sort((a, b) => b[0] - a[0]);
787
- for (const [start, end] of ranges) out = out.slice(0, start) + out.slice(end);
788
- if (toRemove.length === mcpPair.value.items.length) {
789
- const reparsed = parseDocument(out, { keepSourceTokens: true });
790
- if (!reparsed.errors.length && isMap(reparsed.contents)) {
791
- const pair = reparsed.contents.items.find((item) => item.key?.toString() === "mcp_servers");
792
- if (pair && pair.key?.range && pair.value?.range) {
793
- const keyStart = pair.key.range[0];
794
- const valueEnd = pair.value.range[1];
795
- const lineStart = out.lastIndexOf("\n", keyStart - 1) + 1;
796
- let end = valueEnd;
797
- if (out.slice(end).startsWith("\r\n")) end += 2;
798
- else if (out[end] === "\n") end += 1;
799
- out = out.slice(0, lineStart) + out.slice(end);
977
+ const temps = [];
978
+ try {
979
+ for (const { path, payload } of targets) {
980
+ const temp = `${path}.rekey-${crypto.randomBytes(6).toString("hex")}.tmp`;
981
+ await writeOwnerOnlyJson(temp, payload, input.warn);
982
+ temps.push({ from: temp, to: path });
983
+ }
984
+ for (const { from, to } of temps) {
985
+ await rename(from, to);
986
+ }
987
+ } catch (err) {
988
+ for (const { from } of temps) await rm(from, { force: true }).catch(() => void 0);
989
+ for (const [path, contents] of originals) {
990
+ if (contents === null) {
991
+ await rm(path, { force: true }).catch(() => void 0);
992
+ } else {
993
+ await writeFile(path, contents, { mode: 384 }).catch(() => void 0);
800
994
  }
801
995
  }
996
+ throw err;
802
997
  }
803
- return out;
998
+ return { directory, identityPath, signerPath, agentPath };
804
999
  }
805
- function removeHermesEnv(existingEnv, envKey) {
806
- if (!existingEnv) return "";
807
- const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
808
- const hasTrailingNewline = /\r?\n$/.test(existingEnv);
809
- const lines = existingEnv.split(/\r?\n/);
810
- if (hasTrailingNewline) lines.pop();
811
- let removed = 0;
812
- const kept = lines.filter((line) => {
813
- if (isHermesEnvAssignment(line, envKey)) {
814
- removed += 1;
815
- return false;
816
- }
817
- if (isAmbiguousHermesEnvLine(line, envKey)) {
818
- throw new Error("Hermes environment contains an ambiguous managed key");
819
- }
820
- return true;
821
- });
822
- if (removed === 0) return existingEnv;
823
- const joined = kept.join(lineEnding);
824
- return hasTrailingNewline ? `${joined}${lineEnding}` : joined;
1000
+ async function writeRekeyPending(directory, pending, warn) {
1001
+ const path = join(directory, REKEY_PENDING_FILENAME);
1002
+ await rm(path, { force: true }).catch(() => void 0);
1003
+ await writeOwnerOnlyJson(path, { ...pending }, warn);
1004
+ return path;
825
1005
  }
826
- function removeJsonMcpConfig(existingJson, serverRoot, names, configPath) {
827
- if (!existingJson?.trim()) return existingJson ?? "";
828
- const config = parseJsonObject(existingJson, configPath);
829
- const root = config[serverRoot];
830
- if (!root || typeof root !== "object" || Array.isArray(root)) return existingJson;
831
- const servers = root;
832
- let removed = false;
833
- for (const name of [names.hosted, names.signer]) {
834
- if (name in servers) {
835
- delete servers[name];
836
- removed = true;
837
- }
1006
+ async function readRekeyPending(directory, now = Date.now()) {
1007
+ const path = join(directory, REKEY_PENDING_FILENAME);
1008
+ const raw = await readJsonFile(path);
1009
+ if (!raw) {
1010
+ throw new Error(
1011
+ `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.`
1012
+ );
838
1013
  }
839
- if (!removed) return existingJson;
840
- if (Object.keys(servers).length === 0) delete config[serverRoot];
841
- return `${JSON.stringify(config, null, 2)}
842
- `;
1014
+ const pending = raw;
1015
+ if (!pending.new_delegate_key || !pending.new_delegate_address || !pending.agent_id) {
1016
+ throw new Error(`The pending re-key at ${path} is unreadable. Delete it and start again with --rekey.`);
1017
+ }
1018
+ if (pending.expires_at && Date.parse(pending.expires_at) < now) {
1019
+ throw new Error(
1020
+ `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.`
1021
+ );
1022
+ }
1023
+ return pending;
843
1024
  }
844
- function removeCodexToml(existingToml, names) {
845
- let next = removeTomlTableTree(
846
- removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
847
- `mcp_servers.${names.codexSigner}`
848
- );
849
- return next;
1025
+ async function inspectRekeyPending(directory, now = Date.now()) {
1026
+ const path = join(directory, REKEY_PENDING_FILENAME);
1027
+ const raw = await readJsonFile(path);
1028
+ if (!raw) {
1029
+ const present = await readRawFile(path) !== null;
1030
+ return present ? { state: "unreadable", path } : null;
1031
+ }
1032
+ const agentId = asString(raw.agent_id);
1033
+ const newDelegateAddress = asString(raw.new_delegate_address);
1034
+ const startedAt = asString(raw.started_at);
1035
+ const expiresAt = asString(raw.expires_at);
1036
+ if (!agentId || !newDelegateAddress) {
1037
+ return { state: "unreadable", path, ...startedAt ? { startedAt } : {} };
1038
+ }
1039
+ const expired = expiresAt !== void 0 && Date.parse(expiresAt) < now;
1040
+ return {
1041
+ state: expired ? "expired" : "pending",
1042
+ path,
1043
+ agentId,
1044
+ newDelegateAddress,
1045
+ ...startedAt ? { startedAt } : {},
1046
+ ...expiresAt ? { expiresAt } : {}
1047
+ };
850
1048
  }
851
- function appendHermesMcpServers(source, servers) {
852
- const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
853
- if (documentEnd) {
854
- const markerStart = documentEnd.index + (documentEnd[0].startsWith("\n") ? 1 : 0);
855
- const beforeMarker = source.slice(0, markerStart);
856
- return `${beforeMarker}${beforeMarker.endsWith("\n") ? "" : "\n"}${renderHermesMcpServers(servers, "")}
857
- ${source.slice(markerStart)}`;
1049
+ async function clearRekeyPending(directory) {
1050
+ await rm(join(directory, REKEY_PENDING_FILENAME), { force: true }).catch(() => void 0);
1051
+ }
1052
+ async function readJsonFile(path) {
1053
+ const raw = await readRawFile(path);
1054
+ if (raw === null) return null;
1055
+ try {
1056
+ const parsed = JSON.parse(raw);
1057
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1058
+ } catch {
1059
+ return null;
858
1060
  }
859
- const separator = source.endsWith("\n") ? "" : "\n";
860
- return `${source}${separator}${renderHermesMcpServers(servers, "")}
861
- `;
862
1061
  }
863
- function replaceHermesMcpServers(source, keyRange, valueRange, servers) {
864
- const valueStart = valueRange[0];
865
- const valueEnd = valueRange[1];
866
- const valueLineStart = source.lastIndexOf("\n", valueStart - 1) + 1;
867
- const keyStartsOnValueLine = keyRange?.[0] !== void 0 && keyRange[0] >= valueLineStart;
868
- const nestedIndent = `${source.slice(valueLineStart, keyRange?.[0] ?? valueLineStart)} `;
869
- if (keyStartsOnValueLine) {
870
- const keyStart = keyRange[0];
871
- const keyEnd = keyRange[1];
872
- const replacement2 = `${source.slice(keyStart, keyEnd)}:
873
- ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
874
- const needsTrailingNewline2 = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
875
- return `${source.slice(0, keyStart)}${replacement2}${needsTrailingNewline2 ? "\n" : ""}${source.slice(valueEnd)}`;
1062
+ async function readRawFile(path) {
1063
+ try {
1064
+ return await readFile(path, "utf8");
1065
+ } catch {
1066
+ return null;
876
1067
  }
877
- const indent = source.slice(valueLineStart, valueStart);
878
- const rendered = stringify(servers, { lineWidth: 120 }).trimEnd();
879
- const replacement = rendered.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
880
- const needsTrailingNewline = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
881
- return `${source.slice(0, valueStart)}${replacement}${needsTrailingNewline ? "\n" : ""}${source.slice(valueEnd)}`;
882
1068
  }
883
- function replaceEmptyHermesMcpServers(source, keyRange, servers) {
884
- if (!keyRange) throw new Error("Hermes config mcp_servers key is invalid");
885
- const keyStart = keyRange[0];
886
- const keyEnd = keyRange[1];
887
- const lineStart = source.lastIndexOf("\n", keyStart - 1) + 1;
888
- const lineEnd = source.indexOf("\n", keyEnd);
889
- const nestedIndent = `${source.slice(lineStart, keyStart)} `;
890
- const replacement = `${source.slice(keyStart, keyEnd)}:
891
- ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
892
- return `${source.slice(0, keyStart)}${replacement}${lineEnd === -1 ? "\n" : source.slice(lineEnd)}`;
1069
+ function asString(value) {
1070
+ return typeof value === "string" && value.length > 0 ? value : void 0;
893
1071
  }
894
- function renderHermesYaml(servers) {
895
- return `${renderHermesMcpServers(servers, "")}
896
- `;
1072
+ async function assertServerSlugAvailable(serverName, baseDir) {
1073
+ const directory = defaultAgentDirectory(serverName, baseDir);
1074
+ try {
1075
+ await stat(join(directory, "identity.json"));
1076
+ } catch {
1077
+ return;
1078
+ }
1079
+ throw new Error(
1080
+ `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.`
1081
+ );
897
1082
  }
898
- function renderHermesMcpServers(servers, indent) {
899
- return `mcp_servers:
900
- ${renderHermesMcpServerEntries(servers, `${indent} `)}`;
1083
+ function defaultAgentDirectory(agentId, baseDir = join(homedir(), ".haven", "agents")) {
1084
+ return resolve(defaultCredentialRoot(baseDir), safePathPart(agentId));
901
1085
  }
902
- function renderHermesMcpServerEntries(servers, indent) {
903
- return stringify(servers, { lineWidth: 120 }).trimEnd().split("\n").map((line) => `${indent}${line}`).join("\n");
1086
+ function defaultCredentialRoot(baseDir = join(homedir(), ".haven", "agents")) {
1087
+ return resolve(baseDir);
904
1088
  }
905
- function isRecord(value) {
906
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1089
+ async function writeOwnerOnlyJson(path, value, warn) {
1090
+ const json = JSON.stringify(dropUndefined(value), null, 2);
1091
+ await writeFile(path, `${json}
1092
+ `, { mode: 384, flag: "wx" });
1093
+ await restrictPermissions(path, 384, warn);
907
1094
  }
908
- function mergeCodexToml(existingToml, localMcpCommand, names = serverNamesFor()) {
909
- let next = removeTomlTableTree(
910
- removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
911
- `mcp_servers.${names.codexSigner}`
912
- );
913
- next = next.trimEnd();
914
- const block = [
915
- `[mcp_servers.${names.codexHosted}]`,
916
- `command = ${tomlString(localMcpCommand)}`,
917
- "args = []",
918
- "startup_timeout_sec = 120"
919
- ].join("\n");
920
- validateCodexToml(block, "Generated Codex Haven config");
921
- const merged = `${next ? `${next}
922
-
923
- ` : ""}${block}
924
- `;
925
- return merged;
1095
+ function safePathPart(value) {
1096
+ return value.replace(/[^A-Za-z0-9_.-]/g, "_");
926
1097
  }
927
- function mergeCodexTomlHosted(existingToml, hostedMcpUrl, apiKey, signerSpec, names = serverNamesFor()) {
928
- let next = removeTomlTableTree(
929
- removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
930
- `mcp_servers.${names.codexSigner}`
931
- );
932
- next = next.trimEnd();
933
- const block = [
934
- `[mcp_servers.${names.codexHosted}]`,
935
- `url = ${tomlString(hostedMcpUrl)}`,
936
- `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)} }`,
937
- "",
938
- `[mcp_servers.${names.codexSigner}]`,
939
- `command = ${tomlString(signerSpec.command)}`,
940
- `args = [${signerSpec.args.map((arg) => tomlString(arg)).join(", ")}]`,
941
- "startup_timeout_sec = 120"
942
- ].join("\n");
943
- validateCodexToml(block, "Generated Codex Haven config");
944
- const merged = `${next ? `${next}
945
-
946
- ` : ""}${block}
947
- `;
948
- return merged;
1098
+ function dropUndefined(value) {
1099
+ return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
949
1100
  }
950
- async function writeJsonRuntimeConfig(input, target, serverRoot) {
1101
+ async function assertDoesNotExist(path) {
951
1102
  try {
952
- const existing = await readOptional(target);
953
- const merged = mergeJsonMcpConfig(
954
- existing,
955
- serverRoot,
956
- buildHostedServer(input.hostedMcpUrl, input.apiKey, input.runtime),
957
- buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
958
- serverNamesFor(input.serverName),
959
- target
960
- );
961
- await writeOwnerOnlyText(target, merged);
962
- return {
963
- hostedConfigured: true,
964
- signerConfigured: true,
965
- localMcpConfigured: false,
966
- runtimeMcpMode: "hosted_plus_signer",
967
- target: configTargetLabel(input.runtime),
968
- changed: existing !== merged,
969
- restartRequired: input.runtime === "claude-desktop",
970
- messages: [`Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`]
971
- };
1103
+ await access(path);
972
1104
  } catch (err) {
973
- const unreadable = err instanceof UnreadableRuntimeConfigError;
974
- return {
975
- hostedConfigured: false,
976
- signerConfigured: false,
977
- localMcpConfigured: false,
978
- runtimeMcpMode: "hosted_plus_signer",
979
- target: configTargetLabel(input.runtime),
980
- changed: false,
981
- restartRequired: true,
982
- messages: unreadable ? [
983
- `Could not update ${configTargetLabel(input.runtime)}: ${err.message}.`,
984
- `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.`
985
- ] : [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
986
- errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
987
- };
1105
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
1106
+ throw err;
988
1107
  }
1108
+ throw new Error(`Refusing to overwrite existing Haven credential file: ${path}`);
989
1109
  }
990
- async function writeHermesConfig(input, deps) {
991
- const target = hermesConfigPath(input.homeDir);
992
- const envTarget = hermesEnvPath(input.homeDir);
1110
+ async function restrictPermissions(path, mode, warn) {
993
1111
  try {
994
- const [existing, existingEnv] = await Promise.all([readOptional(target), readOptional(envTarget)]);
995
- const names = serverNamesFor(input.serverName);
996
- const hostedServer = {
997
- ...buildHostedServer(input.hostedMcpUrl, `\${${names.hermesEnvKey}}`, input.runtime),
998
- enabled: true
999
- };
1000
- const signerServer = {
1001
- ...buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
1002
- enabled: true
1003
- };
1004
- const merged = mergeHermesYaml(
1005
- existing,
1006
- hostedServer,
1007
- signerServer,
1008
- names,
1009
- target
1010
- );
1011
- const mergedEnv = mergeHermesEnv(existingEnv, input.apiKey, names.hermesEnvKey);
1012
- const writeText = deps.writeOwnerOnlyText ?? writeOwnerOnlyText;
1013
- await writeText(envTarget, mergedEnv);
1014
- try {
1015
- await writeText(target, merged);
1016
- } catch (err) {
1017
- const recovered = await restoreHermesFiles(target, existing, envTarget, existingEnv, writeText);
1018
- if (!recovered) throw new HermesConfigRecoveryError();
1019
- throw err;
1020
- }
1021
- return {
1022
- hostedConfigured: true,
1023
- signerConfigured: true,
1024
- localMcpConfigured: false,
1025
- runtimeMcpMode: "hosted_plus_signer",
1026
- target: "Hermes Agent config",
1027
- changed: existing !== merged || existingEnv !== mergedEnv,
1028
- restartRequired: true,
1029
- messages: [
1030
- `Updated Haven MCP entries in ${target}; stored the hosted MCP identity in ${envTarget}.`,
1031
- "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`.",
1032
- "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.",
1033
- "If no mcp_* tools appear after restart, ensure the MCP SDK is installed in Hermes: pip install mcp"
1034
- ]
1035
- };
1112
+ await chmod(path, mode);
1036
1113
  } catch (err) {
1037
- const recoveryIncomplete = err instanceof HermesConfigRecoveryError;
1038
- const unreadable = err instanceof UnreadableRuntimeConfigError;
1039
- return {
1040
- hostedConfigured: false,
1041
- signerConfigured: false,
1042
- localMcpConfigured: false,
1043
- runtimeMcpMode: "hosted_plus_signer",
1044
- target: "Hermes Agent config",
1045
- changed: false,
1046
- restartRequired: true,
1047
- messages: unreadable ? [
1048
- `Could not update Hermes Agent config: ${err.message}.`,
1049
- `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.`
1050
- ] : [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."],
1051
- errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
1052
- };
1114
+ warn?.(
1115
+ `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)}`
1116
+ );
1053
1117
  }
1054
1118
  }
1055
- async function restoreHermesFiles(configPath, existingConfig, envPath, existingEnv, writeText) {
1056
- const [envResult, configResult] = await Promise.allSettled([
1057
- restoreOptionalText(envPath, existingEnv, writeText),
1058
- restoreOptionalText(configPath, existingConfig, writeText)
1059
- ]);
1060
- return envResult.status === "fulfilled" && configResult.status === "fulfilled";
1119
+ async function writeConnectOutcomeRecord(directory, outcome, warn) {
1120
+ const path = join(directory, CONNECT_OUTCOME_FILENAME);
1121
+ await writeFile(path, `${JSON.stringify(outcome, null, 2)}
1122
+ `, { mode: 384 });
1123
+ await restrictPermissions(path, 384, warn);
1124
+ return path;
1061
1125
  }
1062
- async function restoreOptionalText(path, existing, writeText) {
1063
- if (existing !== null) {
1064
- await writeText(path, existing);
1065
- return;
1066
- }
1126
+ async function readConnectOutcomeRuntime(directory) {
1067
1127
  try {
1068
- await unlink(path);
1069
- } catch (err) {
1070
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
1071
- throw err;
1128
+ const parsed = JSON.parse(await readFile(join(directory, CONNECT_OUTCOME_FILENAME), "utf8"));
1129
+ if (typeof parsed !== "object" || parsed === null) return null;
1130
+ const runtime = parsed.runtime;
1131
+ return typeof runtime === "string" && runtime.length > 0 ? runtime : null;
1132
+ } catch {
1133
+ return null;
1072
1134
  }
1073
1135
  }
1074
- async function writeCodexConfig(input) {
1075
- const target = codexConfigPath(input.homeDir);
1076
- const local = input.mode === "local";
1136
+ async function writeMcpServerBinding(directory, binding) {
1137
+ const path = join(directory, MCP_SERVER_BINDING_FILENAME);
1138
+ await writeFile(path, `${JSON.stringify(binding, null, 2)}
1139
+ `, { mode: 384 });
1140
+ return path;
1141
+ }
1142
+ async function readMcpServerBinding(directory) {
1077
1143
  try {
1078
- const existing = await readOptional(target);
1079
- if (local) {
1080
- if (!input.localMcpCommand) {
1081
- throw new Error("local MCP wrapper command is required");
1082
- }
1083
- const merged2 = mergeCodexToml(existing ?? "", input.localMcpCommand, serverNamesFor(input.serverName));
1084
- await writeOwnerOnlyText(target, merged2);
1085
- return {
1086
- hostedConfigured: false,
1087
- signerConfigured: true,
1088
- localMcpConfigured: true,
1089
- runtimeMcpMode: "local_stdio",
1090
- target: configTargetLabel(input.runtime),
1091
- changed: existing !== merged2,
1092
- restartRequired: true,
1093
- messages: [
1094
- `Updated local Haven MCP entry in ${configTargetLabel(input.runtime)}.`
1095
- ]
1096
- };
1097
- }
1098
- const merged = mergeCodexTomlHosted(
1099
- existing ?? "",
1100
- input.hostedMcpUrl,
1101
- input.apiKey,
1102
- resolveSignerLaunchSpec(input),
1103
- serverNamesFor(input.serverName)
1104
- );
1105
- await writeOwnerOnlyText(target, merged);
1106
- return {
1107
- hostedConfigured: true,
1108
- signerConfigured: true,
1109
- localMcpConfigured: false,
1110
- runtimeMcpMode: "hosted_plus_signer",
1111
- target: configTargetLabel(input.runtime),
1112
- changed: existing !== merged,
1113
- restartRequired: true,
1114
- messages: [
1115
- `Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`
1116
- ]
1117
- };
1118
- } catch (err) {
1119
- const invalidToml = err instanceof InvalidCodexTomlError;
1120
- return {
1121
- hostedConfigured: false,
1122
- signerConfigured: false,
1123
- localMcpConfigured: false,
1124
- runtimeMcpMode: local ? "local_stdio" : "hosted_plus_signer",
1125
- target: configTargetLabel(input.runtime),
1126
- changed: false,
1127
- restartRequired: true,
1128
- messages: [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
1129
- errorCode: invalidToml ? "codex_config_invalid" : "runtime_config_write_failed"
1130
- };
1144
+ const parsed = JSON.parse(await readFile(join(directory, MCP_SERVER_BINDING_FILENAME), "utf8"));
1145
+ if (typeof parsed !== "object" || parsed === null) return null;
1146
+ const record = parsed;
1147
+ 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;
1148
+ return record;
1149
+ } catch {
1150
+ return null;
1131
1151
  }
1132
1152
  }
1133
- async function readOptional(path) {
1153
+ async function clearMcpServerBinding(directory) {
1134
1154
  try {
1135
- return await readFile(path, "utf8");
1136
- } catch (err) {
1137
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
1138
- throw err;
1155
+ await rm(join(directory, MCP_SERVER_BINDING_FILENAME));
1156
+ return true;
1157
+ } catch {
1158
+ return false;
1139
1159
  }
1140
1160
  }
1141
- async function writeOwnerOnlyText(path, value) {
1142
- await mkdir(dirname(path), { recursive: true, mode: 448 });
1143
- await writeFile(path, value, { mode: 384 });
1144
- await chmod(path, 384).catch(() => void 0);
1145
- }
1146
- function parseJsonObject(value, configPath) {
1147
- let parsed;
1161
+ async function listMcpServerBindings(baseDir, excludeDirectory) {
1162
+ const root = defaultCredentialRoot(baseDir);
1163
+ let entries = [];
1148
1164
  try {
1149
- parsed = JSON.parse(value);
1165
+ entries = await readdir(root);
1150
1166
  } catch {
1151
- throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "it is not valid JSON");
1167
+ return [];
1152
1168
  }
1153
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1154
- throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "the top level is not a JSON object");
1169
+ const out = [];
1170
+ for (const entry of entries) {
1171
+ const directory = join(root, entry);
1172
+ if (excludeDirectory && directory === excludeDirectory) continue;
1173
+ const binding = await readMcpServerBinding(directory);
1174
+ if (binding) out.push({ directory, binding });
1155
1175
  }
1156
- return parsed;
1176
+ return out;
1157
1177
  }
1158
- function removeTomlTableTree(toml, table) {
1159
- const lines = toml.split(/\r?\n/);
1160
- const kept = [];
1161
- let skipping = false;
1162
- for (const line of lines) {
1163
- const trimmed = line.trim();
1164
- const tableName = tomlTableName(trimmed);
1165
- if (tableName) {
1166
- skipping = tableName === table || tableName.startsWith(`${table}.`);
1167
- if (skipping) continue;
1178
+ var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME, MCP_SERVER_BINDING_FILENAME;
1179
+ var init_storage = __esm({
1180
+ "src/storage.ts"() {
1181
+ init_server_names();
1182
+ init_signer_runtime();
1183
+ init_tombstone();
1184
+ REKEY_PENDING_FILENAME = "rekey-pending.json";
1185
+ REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
1186
+ CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
1187
+ MCP_SERVER_BINDING_FILENAME = "mcp-server-binding.json";
1188
+ }
1189
+ });
1190
+ async function writeRuntimeConfig(input, deps = {}) {
1191
+ switch (input.runtime) {
1192
+ case "codex-cli":
1193
+ case "codex-desktop":
1194
+ return writeCodexConfig(input);
1195
+ case "cursor":
1196
+ return writeJsonRuntimeConfig(input, cursorConfigPath(input.homeDir), "mcpServers");
1197
+ case "vscode":
1198
+ return writeJsonRuntimeConfig(input, vscodeConfigPath(input.homeDir), "servers");
1199
+ case "vscode-insiders":
1200
+ return writeJsonRuntimeConfig(input, vscodeInsidersConfigPath(input.homeDir), "servers");
1201
+ case "claude-desktop":
1202
+ return writeJsonRuntimeConfig(input, claudeDesktopConfigPath(input.homeDir), "mcpServers");
1203
+ case "hermes":
1204
+ return writeHermesConfig(input, deps);
1205
+ default:
1206
+ return {
1207
+ hostedConfigured: false,
1208
+ signerConfigured: false,
1209
+ localMcpConfigured: false,
1210
+ runtimeMcpMode: "manual",
1211
+ target: "manual runtime setup",
1212
+ changed: false,
1213
+ restartRequired: true,
1214
+ messages: ["Runtime config needs to be added manually for this agent environment."],
1215
+ errorCode: "manual_runtime_setup_required"
1216
+ };
1217
+ }
1218
+ }
1219
+ function buildHostedServer(hostedMcpUrl, apiKey, runtime) {
1220
+ if (runtime === "vscode" || runtime === "vscode-insiders") {
1221
+ return {
1222
+ type: "http",
1223
+ url: hostedMcpUrl,
1224
+ headers: { Authorization: `Bearer ${apiKey}` }
1225
+ };
1226
+ }
1227
+ return {
1228
+ url: hostedMcpUrl,
1229
+ headers: { Authorization: `Bearer ${apiKey}` }
1230
+ };
1231
+ }
1232
+ function resolveSignerLaunchSpec(input) {
1233
+ return input.signerCommand ?? {
1234
+ command: "npx",
1235
+ args: ["-y", signerPackageSpec(), "--credentials", input.signerPath]
1236
+ };
1237
+ }
1238
+ function buildSignerServer(spec, runtime) {
1239
+ const server = {
1240
+ command: spec.command,
1241
+ args: spec.args
1242
+ };
1243
+ if (runtime === "vscode" || runtime === "vscode-insiders") return { type: "stdio", ...server };
1244
+ return server;
1245
+ }
1246
+ function mergeJsonMcpConfig(existingJson, serverRoot, hostedServer, signerServer, names = serverNamesFor(), configPath) {
1247
+ const config = existingJson?.trim() ? parseJsonObject(existingJson, configPath) : {};
1248
+ const existingRoot = config[serverRoot];
1249
+ const servers = existingRoot && typeof existingRoot === "object" && !Array.isArray(existingRoot) ? existingRoot : {};
1250
+ config[serverRoot] = {
1251
+ ...servers,
1252
+ [names.hosted]: hostedServer,
1253
+ [names.signer]: signerServer
1254
+ };
1255
+ return `${JSON.stringify(config, null, 2)}
1256
+ `;
1257
+ }
1258
+ function mergeHermesYaml(existingYaml, hostedServer, signerServer, names = serverNamesFor(), configPath) {
1259
+ if (!existingYaml?.trim()) {
1260
+ return renderHermesYaml({ [names.hosted]: hostedServer, [names.signer]: signerServer });
1261
+ }
1262
+ const doc = parseDocument(existingYaml, { keepSourceTokens: true });
1263
+ if (doc.errors.length > 0 || !isMap(doc.contents)) {
1264
+ throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
1265
+ }
1266
+ const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
1267
+ const existingServers = mcpPair && isMap(mcpPair.value) ? mcpPair.value.toJSON() : {};
1268
+ const servers = isRecord(existingServers) ? existingServers : {};
1269
+ const mergedServers = {
1270
+ ...servers,
1271
+ [names.hosted]: hostedServer,
1272
+ [names.signer]: signerServer
1273
+ };
1274
+ if (!mcpPair) {
1275
+ return appendHermesMcpServers(existingYaml, mergedServers);
1276
+ }
1277
+ if (!mcpPair.value?.range) return replaceEmptyHermesMcpServers(existingYaml, mcpPair.key?.range, mergedServers);
1278
+ return replaceHermesMcpServers(existingYaml, mcpPair.key?.range, mcpPair.value.range, mergedServers);
1279
+ }
1280
+ function mergeHermesEnv(existingEnv, apiKey, envKey = HERMES_API_KEY_ENV) {
1281
+ if (/[\r\n]/.test(apiKey)) throw new Error("Hermes API key must be a single line");
1282
+ const assignment = `${envKey}=${apiKey}`;
1283
+ if (!existingEnv) return `${assignment}
1284
+ `;
1285
+ const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
1286
+ const hasTrailingNewline = /\r?\n$/.test(existingEnv);
1287
+ const lines = existingEnv.split(/\r?\n/);
1288
+ if (hasTrailingNewline) lines.pop();
1289
+ let found = false;
1290
+ const merged = lines.flatMap((line) => {
1291
+ if (isHermesEnvAssignment(line, envKey)) {
1292
+ if (found) return [];
1293
+ found = true;
1294
+ return [assignment];
1295
+ }
1296
+ if (isAmbiguousHermesEnvLine(line, envKey)) {
1297
+ throw new Error("Hermes environment contains an ambiguous managed key");
1298
+ }
1299
+ return [line];
1300
+ });
1301
+ if (!found) {
1302
+ return `${existingEnv}${hasTrailingNewline ? "" : lineEnding}${assignment}${lineEnding}`;
1303
+ }
1304
+ return `${merged.join(lineEnding)}${hasTrailingNewline ? lineEnding : ""}`;
1305
+ }
1306
+ function isHermesEnvAssignment(line, envKey) {
1307
+ return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(line);
1308
+ }
1309
+ function isAmbiguousHermesEnvLine(line, envKey) {
1310
+ return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}\\b`).test(line);
1311
+ }
1312
+ function removeHermesYaml(existingYaml, names, configPath) {
1313
+ if (!existingYaml?.trim()) return existingYaml ?? "";
1314
+ const doc = parseDocument(existingYaml, { keepSourceTokens: true });
1315
+ if (doc.errors.length > 0 || !isMap(doc.contents)) {
1316
+ throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
1317
+ }
1318
+ const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
1319
+ if (!mcpPair || !isMap(mcpPair.value)) return existingYaml;
1320
+ const toRemove = mcpPair.value.items.filter((item) => {
1321
+ const name = item.key?.toString();
1322
+ return name === names.hosted || name === names.signer;
1323
+ });
1324
+ if (toRemove.length === 0) return existingYaml;
1325
+ if (toRemove.length !== mcpPair.value.items.length && toRemove.some((item) => !item.key?.range || !item.value?.range)) {
1326
+ throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "an MCP server entry cannot be safely removed");
1327
+ }
1328
+ let out = existingYaml;
1329
+ const ranges = toRemove.map((item) => {
1330
+ const keyStart = item.key.range[0];
1331
+ const valueEnd = item.value.range[1];
1332
+ const lineStart = existingYaml.lastIndexOf("\n", keyStart - 1) + 1;
1333
+ let end = valueEnd;
1334
+ if (existingYaml.slice(end).startsWith("\r\n")) end += 2;
1335
+ else if (existingYaml[end] === "\n") end += 1;
1336
+ return [lineStart, end];
1337
+ });
1338
+ ranges.sort((a, b) => b[0] - a[0]);
1339
+ for (const [start, end] of ranges) out = out.slice(0, start) + out.slice(end);
1340
+ if (toRemove.length === mcpPair.value.items.length) {
1341
+ const reparsed = parseDocument(out, { keepSourceTokens: true });
1342
+ if (!reparsed.errors.length && isMap(reparsed.contents)) {
1343
+ const pair = reparsed.contents.items.find((item) => item.key?.toString() === "mcp_servers");
1344
+ if (pair && pair.key?.range && pair.value?.range) {
1345
+ const keyStart = pair.key.range[0];
1346
+ const valueEnd = pair.value.range[1];
1347
+ const lineStart = out.lastIndexOf("\n", keyStart - 1) + 1;
1348
+ let end = valueEnd;
1349
+ if (out.slice(end).startsWith("\r\n")) end += 2;
1350
+ else if (out[end] === "\n") end += 1;
1351
+ out = out.slice(0, lineStart) + out.slice(end);
1352
+ }
1353
+ }
1354
+ }
1355
+ return out;
1356
+ }
1357
+ function removeHermesEnv(existingEnv, envKey) {
1358
+ if (!existingEnv) return "";
1359
+ const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
1360
+ const hasTrailingNewline = /\r?\n$/.test(existingEnv);
1361
+ const lines = existingEnv.split(/\r?\n/);
1362
+ if (hasTrailingNewline) lines.pop();
1363
+ let removed = 0;
1364
+ const kept = lines.filter((line) => {
1365
+ if (isHermesEnvAssignment(line, envKey)) {
1366
+ removed += 1;
1367
+ return false;
1368
+ }
1369
+ if (isAmbiguousHermesEnvLine(line, envKey)) {
1370
+ throw new Error("Hermes environment contains an ambiguous managed key");
1371
+ }
1372
+ return true;
1373
+ });
1374
+ if (removed === 0) return existingEnv;
1375
+ const joined = kept.join(lineEnding);
1376
+ return hasTrailingNewline ? `${joined}${lineEnding}` : joined;
1377
+ }
1378
+ function removeJsonMcpConfig(existingJson, serverRoot, names, configPath) {
1379
+ if (!existingJson?.trim()) return existingJson ?? "";
1380
+ const config = parseJsonObject(existingJson, configPath);
1381
+ const root = config[serverRoot];
1382
+ if (!root || typeof root !== "object" || Array.isArray(root)) return existingJson;
1383
+ const servers = root;
1384
+ let removed = false;
1385
+ for (const name of [names.hosted, names.signer]) {
1386
+ if (name in servers) {
1387
+ delete servers[name];
1388
+ removed = true;
1389
+ }
1390
+ }
1391
+ if (!removed) return existingJson;
1392
+ if (Object.keys(servers).length === 0) delete config[serverRoot];
1393
+ return `${JSON.stringify(config, null, 2)}
1394
+ `;
1395
+ }
1396
+ function removeCodexToml(existingToml, names) {
1397
+ let next = removeTomlTableTree(
1398
+ removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
1399
+ `mcp_servers.${names.codexSigner}`
1400
+ );
1401
+ return next;
1402
+ }
1403
+ function appendHermesMcpServers(source, servers) {
1404
+ const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
1405
+ if (documentEnd) {
1406
+ const markerStart = documentEnd.index + (documentEnd[0].startsWith("\n") ? 1 : 0);
1407
+ const beforeMarker = source.slice(0, markerStart);
1408
+ return `${beforeMarker}${beforeMarker.endsWith("\n") ? "" : "\n"}${renderHermesMcpServers(servers, "")}
1409
+ ${source.slice(markerStart)}`;
1410
+ }
1411
+ const separator = source.endsWith("\n") ? "" : "\n";
1412
+ return `${source}${separator}${renderHermesMcpServers(servers, "")}
1413
+ `;
1414
+ }
1415
+ function replaceHermesMcpServers(source, keyRange, valueRange, servers) {
1416
+ const valueStart = valueRange[0];
1417
+ const valueEnd = valueRange[1];
1418
+ const valueLineStart = source.lastIndexOf("\n", valueStart - 1) + 1;
1419
+ const keyStartsOnValueLine = keyRange?.[0] !== void 0 && keyRange[0] >= valueLineStart;
1420
+ const nestedIndent = `${source.slice(valueLineStart, keyRange?.[0] ?? valueLineStart)} `;
1421
+ if (keyStartsOnValueLine) {
1422
+ const keyStart = keyRange[0];
1423
+ const keyEnd = keyRange[1];
1424
+ const replacement2 = `${source.slice(keyStart, keyEnd)}:
1425
+ ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
1426
+ const needsTrailingNewline2 = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
1427
+ return `${source.slice(0, keyStart)}${replacement2}${needsTrailingNewline2 ? "\n" : ""}${source.slice(valueEnd)}`;
1428
+ }
1429
+ const indent = source.slice(valueLineStart, valueStart);
1430
+ const rendered = stringify(servers, { lineWidth: 120 }).trimEnd();
1431
+ const replacement = rendered.split("\n").map((line, index) => index === 0 ? line : `${indent}${line}`).join("\n");
1432
+ const needsTrailingNewline = valueEnd < source.length && !source.slice(valueEnd).startsWith("\n");
1433
+ return `${source.slice(0, valueStart)}${replacement}${needsTrailingNewline ? "\n" : ""}${source.slice(valueEnd)}`;
1434
+ }
1435
+ function replaceEmptyHermesMcpServers(source, keyRange, servers) {
1436
+ if (!keyRange) throw new Error("Hermes config mcp_servers key is invalid");
1437
+ const keyStart = keyRange[0];
1438
+ const keyEnd = keyRange[1];
1439
+ const lineStart = source.lastIndexOf("\n", keyStart - 1) + 1;
1440
+ const lineEnd = source.indexOf("\n", keyEnd);
1441
+ const nestedIndent = `${source.slice(lineStart, keyStart)} `;
1442
+ const replacement = `${source.slice(keyStart, keyEnd)}:
1443
+ ${renderHermesMcpServerEntries(servers, nestedIndent)}`;
1444
+ return `${source.slice(0, keyStart)}${replacement}${lineEnd === -1 ? "\n" : source.slice(lineEnd)}`;
1445
+ }
1446
+ function renderHermesYaml(servers) {
1447
+ return `${renderHermesMcpServers(servers, "")}
1448
+ `;
1449
+ }
1450
+ function renderHermesMcpServers(servers, indent) {
1451
+ return `mcp_servers:
1452
+ ${renderHermesMcpServerEntries(servers, `${indent} `)}`;
1453
+ }
1454
+ function renderHermesMcpServerEntries(servers, indent) {
1455
+ return stringify(servers, { lineWidth: 120 }).trimEnd().split("\n").map((line) => `${indent}${line}`).join("\n");
1456
+ }
1457
+ function isRecord(value) {
1458
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1459
+ }
1460
+ function mergeCodexToml(existingToml, localMcpCommand, names = serverNamesFor()) {
1461
+ let next = removeTomlTableTree(
1462
+ removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
1463
+ `mcp_servers.${names.codexSigner}`
1464
+ );
1465
+ next = next.trimEnd();
1466
+ const block = [
1467
+ `[mcp_servers.${names.codexHosted}]`,
1468
+ `command = ${tomlString(localMcpCommand)}`,
1469
+ "args = []",
1470
+ "startup_timeout_sec = 120"
1471
+ ].join("\n");
1472
+ validateCodexToml(block, "Generated Codex Haven config");
1473
+ const merged = `${next ? `${next}
1474
+
1475
+ ` : ""}${block}
1476
+ `;
1477
+ return merged;
1478
+ }
1479
+ function mergeCodexTomlHosted(existingToml, hostedMcpUrl, apiKey, signerSpec, names = serverNamesFor()) {
1480
+ let next = removeTomlTableTree(
1481
+ removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
1482
+ `mcp_servers.${names.codexSigner}`
1483
+ );
1484
+ next = next.trimEnd();
1485
+ const block = [
1486
+ `[mcp_servers.${names.codexHosted}]`,
1487
+ `url = ${tomlString(hostedMcpUrl)}`,
1488
+ `http_headers = { "Authorization" = ${tomlString(`Bearer ${apiKey}`)} }`,
1489
+ "",
1490
+ `[mcp_servers.${names.codexSigner}]`,
1491
+ `command = ${tomlString(signerSpec.command)}`,
1492
+ `args = [${signerSpec.args.map((arg) => tomlString(arg)).join(", ")}]`,
1493
+ "startup_timeout_sec = 120"
1494
+ ].join("\n");
1495
+ validateCodexToml(block, "Generated Codex Haven config");
1496
+ const merged = `${next ? `${next}
1497
+
1498
+ ` : ""}${block}
1499
+ `;
1500
+ return merged;
1501
+ }
1502
+ async function writeJsonRuntimeConfig(input, target, serverRoot) {
1503
+ try {
1504
+ const existing = await readOptional(target);
1505
+ const merged = mergeJsonMcpConfig(
1506
+ existing,
1507
+ serverRoot,
1508
+ buildHostedServer(input.hostedMcpUrl, input.apiKey, input.runtime),
1509
+ buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
1510
+ serverNamesFor(input.serverName),
1511
+ target
1512
+ );
1513
+ await writeOwnerOnlyText(target, merged);
1514
+ return {
1515
+ hostedConfigured: true,
1516
+ signerConfigured: true,
1517
+ localMcpConfigured: false,
1518
+ runtimeMcpMode: "hosted_plus_signer",
1519
+ target: configTargetLabel(input.runtime),
1520
+ changed: existing !== merged,
1521
+ restartRequired: input.runtime === "claude-desktop",
1522
+ messages: [`Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`]
1523
+ };
1524
+ } catch (err) {
1525
+ const unreadable = err instanceof UnreadableRuntimeConfigError;
1526
+ return {
1527
+ hostedConfigured: false,
1528
+ signerConfigured: false,
1529
+ localMcpConfigured: false,
1530
+ runtimeMcpMode: "hosted_plus_signer",
1531
+ target: configTargetLabel(input.runtime),
1532
+ changed: false,
1533
+ restartRequired: true,
1534
+ messages: unreadable ? [
1535
+ `Could not update ${configTargetLabel(input.runtime)}: ${err.message}.`,
1536
+ `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.`
1537
+ ] : [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
1538
+ errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
1539
+ };
1540
+ }
1541
+ }
1542
+ async function writeHermesConfig(input, deps) {
1543
+ const target = hermesConfigPath(input.homeDir);
1544
+ const envTarget = hermesEnvPath(input.homeDir);
1545
+ try {
1546
+ const [existing, existingEnv] = await Promise.all([readOptional(target), readOptional(envTarget)]);
1547
+ const names = serverNamesFor(input.serverName);
1548
+ const hostedServer = {
1549
+ ...buildHostedServer(input.hostedMcpUrl, `\${${names.hermesEnvKey}}`, input.runtime),
1550
+ enabled: true
1551
+ };
1552
+ const signerServer = {
1553
+ ...buildSignerServer(resolveSignerLaunchSpec(input), input.runtime),
1554
+ enabled: true
1555
+ };
1556
+ const merged = mergeHermesYaml(
1557
+ existing,
1558
+ hostedServer,
1559
+ signerServer,
1560
+ names,
1561
+ target
1562
+ );
1563
+ const mergedEnv = mergeHermesEnv(existingEnv, input.apiKey, names.hermesEnvKey);
1564
+ const writeText = deps.writeOwnerOnlyText ?? writeOwnerOnlyText;
1565
+ await writeText(envTarget, mergedEnv);
1566
+ try {
1567
+ await writeText(target, merged);
1568
+ } catch (err) {
1569
+ const recovered = await restoreHermesFiles(target, existing, envTarget, existingEnv, writeText);
1570
+ if (!recovered) throw new HermesConfigRecoveryError();
1571
+ throw err;
1168
1572
  }
1169
- if (!skipping) kept.push(line);
1573
+ return {
1574
+ hostedConfigured: true,
1575
+ signerConfigured: true,
1576
+ localMcpConfigured: false,
1577
+ runtimeMcpMode: "hosted_plus_signer",
1578
+ target: "Hermes Agent config",
1579
+ changed: existing !== merged || existingEnv !== mergedEnv,
1580
+ restartRequired: true,
1581
+ messages: [
1582
+ `Updated Haven MCP entries in ${target}; stored the hosted MCP identity in ${envTarget}.`,
1583
+ // #3737: name the pair actually written — a bare run prints exactly the
1584
+ // historical sentence; a named run names its own pair.
1585
+ `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}\`.`,
1586
+ "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.",
1587
+ "If no mcp_* tools appear after restart, ensure the MCP SDK is installed in Hermes: pip install mcp"
1588
+ ]
1589
+ };
1590
+ } catch (err) {
1591
+ const recoveryIncomplete = err instanceof HermesConfigRecoveryError;
1592
+ const unreadable = err instanceof UnreadableRuntimeConfigError;
1593
+ return {
1594
+ hostedConfigured: false,
1595
+ signerConfigured: false,
1596
+ localMcpConfigured: false,
1597
+ runtimeMcpMode: "hosted_plus_signer",
1598
+ target: "Hermes Agent config",
1599
+ changed: false,
1600
+ restartRequired: true,
1601
+ messages: unreadable ? [
1602
+ `Could not update Hermes Agent config: ${err.message}.`,
1603
+ `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.`
1604
+ ] : [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."],
1605
+ errorCode: unreadable ? "runtime_config_unreadable" : "runtime_config_write_failed"
1606
+ };
1170
1607
  }
1171
- return kept.join("\n");
1172
1608
  }
1173
- function tomlTableName(line) {
1174
- if (line.startsWith("[[") && line.endsWith("]]")) return line.slice(2, -2).trim();
1175
- if (line.startsWith("[") && line.endsWith("]")) return line.slice(1, -1).trim();
1176
- return null;
1609
+ async function restoreHermesFiles(configPath, existingConfig, envPath, existingEnv, writeText) {
1610
+ const [envResult, configResult] = await Promise.allSettled([
1611
+ restoreOptionalText(envPath, existingEnv, writeText),
1612
+ restoreOptionalText(configPath, existingConfig, writeText)
1613
+ ]);
1614
+ return envResult.status === "fulfilled" && configResult.status === "fulfilled";
1177
1615
  }
1178
- function validateCodexToml(toml, label = "Codex config") {
1179
- const lines = toml.split(/\r?\n/);
1180
- let pendingValue = null;
1181
- for (let index = 0; index < lines.length; index += 1) {
1182
- const raw = lines[index];
1183
- const line = stripTomlComment(raw).trim();
1184
- if (!line) continue;
1185
- if (pendingValue) {
1186
- pendingValue.value = `${pendingValue.value}
1187
- ${line}`;
1188
- if (hasBalancedTomlContainers(pendingValue.value)) {
1189
- if (!isTomlValue(pendingValue.value)) {
1190
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1191
- }
1192
- pendingValue = null;
1193
- }
1194
- continue;
1195
- }
1196
- if (isTomlTable(line)) continue;
1197
- const equalsIndex = line.indexOf("=");
1198
- if (equalsIndex <= 0) {
1199
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1200
- }
1201
- const key = line.slice(0, equalsIndex).trim();
1202
- const value = line.slice(equalsIndex + 1).trim();
1203
- if (!isTomlKey(key)) {
1204
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1205
- }
1206
- if (startsTomlContainer(value) && !hasBalancedTomlContainers(value)) {
1207
- pendingValue = { value, line: index + 1 };
1208
- continue;
1209
- }
1210
- if (!isTomlValue(value)) {
1211
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1212
- }
1616
+ async function restoreOptionalText(path, existing, writeText) {
1617
+ if (existing !== null) {
1618
+ await writeText(path, existing);
1619
+ return;
1213
1620
  }
1214
- if (pendingValue) {
1215
- throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1621
+ try {
1622
+ await unlink(path);
1623
+ } catch (err) {
1624
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return;
1625
+ throw err;
1216
1626
  }
1217
1627
  }
1218
- function isTomlTable(line) {
1219
- const table = tomlTableName(line);
1220
- return Boolean(table && splitTomlDottedKey(table).every(isTomlKeyPart));
1221
- }
1222
- function isTomlKey(value) {
1223
- return splitTomlDottedKey(value).every(isTomlKeyPart);
1224
- }
1225
- function splitTomlDottedKey(value) {
1226
- const parts = [];
1227
- let current = "";
1228
- let quote = null;
1229
- let escaped = false;
1230
- for (let i = 0; i < value.length; i += 1) {
1231
- const char = value[i];
1232
- if (quote) {
1233
- current += char;
1234
- if (quote === '"' && char === "\\" && !escaped) {
1235
- escaped = true;
1236
- continue;
1628
+ async function writeCodexConfig(input) {
1629
+ const target = codexConfigPath(input.homeDir);
1630
+ const local = input.mode === "local";
1631
+ try {
1632
+ const existing = await readOptional(target);
1633
+ if (local) {
1634
+ if (!input.localMcpCommand) {
1635
+ throw new Error("local MCP wrapper command is required");
1237
1636
  }
1238
- if (char === quote && !escaped) quote = null;
1239
- escaped = false;
1240
- continue;
1241
- }
1242
- if (char === '"' || char === "'") {
1243
- quote = char;
1244
- current += char;
1245
- continue;
1246
- }
1247
- if (char === ".") {
1248
- parts.push(current.trim());
1249
- current = "";
1250
- continue;
1637
+ const merged2 = mergeCodexToml(existing ?? "", input.localMcpCommand, serverNamesFor(input.serverName));
1638
+ await writeOwnerOnlyText(target, merged2);
1639
+ return {
1640
+ hostedConfigured: false,
1641
+ signerConfigured: true,
1642
+ localMcpConfigured: true,
1643
+ runtimeMcpMode: "local_stdio",
1644
+ target: configTargetLabel(input.runtime),
1645
+ changed: existing !== merged2,
1646
+ restartRequired: true,
1647
+ messages: [
1648
+ `Updated local Haven MCP entry in ${configTargetLabel(input.runtime)}.`
1649
+ ]
1650
+ };
1251
1651
  }
1252
- current += char;
1652
+ const merged = mergeCodexTomlHosted(
1653
+ existing ?? "",
1654
+ input.hostedMcpUrl,
1655
+ input.apiKey,
1656
+ resolveSignerLaunchSpec(input),
1657
+ serverNamesFor(input.serverName)
1658
+ );
1659
+ await writeOwnerOnlyText(target, merged);
1660
+ return {
1661
+ hostedConfigured: true,
1662
+ signerConfigured: true,
1663
+ localMcpConfigured: false,
1664
+ runtimeMcpMode: "hosted_plus_signer",
1665
+ target: configTargetLabel(input.runtime),
1666
+ changed: existing !== merged,
1667
+ restartRequired: true,
1668
+ messages: [
1669
+ `Updated Haven MCP entries in ${configTargetLabel(input.runtime)}.`
1670
+ ]
1671
+ };
1672
+ } catch (err) {
1673
+ const invalidToml = err instanceof InvalidCodexTomlError;
1674
+ return {
1675
+ hostedConfigured: false,
1676
+ signerConfigured: false,
1677
+ localMcpConfigured: false,
1678
+ runtimeMcpMode: local ? "local_stdio" : "hosted_plus_signer",
1679
+ target: configTargetLabel(input.runtime),
1680
+ changed: false,
1681
+ restartRequired: true,
1682
+ messages: [`Could not update ${configTargetLabel(input.runtime)}: ${err instanceof Error ? err.message : String(err)}`],
1683
+ errorCode: invalidToml ? "codex_config_invalid" : "runtime_config_write_failed"
1684
+ };
1253
1685
  }
1254
- parts.push(current.trim());
1255
- return quote ? [] : parts;
1256
1686
  }
1257
- function isTomlKeyPart(value) {
1258
- return isTomlBareKey(value) || isTomlQuotedString(value);
1259
- }
1260
- function isTomlBareKey(value) {
1261
- return /^[A-Za-z0-9_-]+$/.test(value);
1262
- }
1263
- function isTomlValue(value) {
1264
- if (!value) return false;
1265
- if (isTomlQuotedString(value)) return true;
1266
- if (/^(true|false)$/i.test(value)) return true;
1267
- if (/^[+-]?(?:inf|nan)$/i.test(value)) return true;
1268
- if (/^[+-]?(?:0|[1-9][0-9_]*)(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?$/.test(value)) return true;
1269
- if (/^\d{4}-\d{2}-\d{2}(?:[Tt ][0-9:.+-Zz]+)?$/.test(value)) return true;
1270
- if (value.startsWith("[") && value.endsWith("]") || value.startsWith("{") && value.endsWith("}")) {
1271
- return hasBalancedTomlContainers(value);
1687
+ async function readOptional(path) {
1688
+ try {
1689
+ return await readFile(path, "utf8");
1690
+ } catch (err) {
1691
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
1692
+ throw err;
1272
1693
  }
1273
- return false;
1274
1694
  }
1275
- function startsTomlContainer(value) {
1276
- return value.startsWith("[") || value.startsWith("{");
1695
+ async function writeOwnerOnlyText(path, value) {
1696
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
1697
+ await writeFile(path, value, { mode: 384 });
1698
+ await chmod(path, 384).catch(() => void 0);
1277
1699
  }
1278
- function isTomlQuotedString(value) {
1279
- if (value.startsWith('"""') || value.startsWith("'''")) {
1280
- const marker = value.slice(0, 3);
1281
- return value.length >= 6 && value.endsWith(marker);
1700
+ function parseJsonObject(value, configPath) {
1701
+ let parsed;
1702
+ try {
1703
+ parsed = JSON.parse(value);
1704
+ } catch {
1705
+ throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "it is not valid JSON");
1282
1706
  }
1283
- if ((!value.startsWith('"') || !value.endsWith('"')) && (!value.startsWith("'") || !value.endsWith("'"))) {
1284
- return false;
1707
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1708
+ throw new UnreadableRuntimeConfigError(configPath ?? "the runtime config", "the top level is not a JSON object");
1285
1709
  }
1286
- return hasBalancedTomlContainers(value);
1710
+ return parsed;
1711
+ }
1712
+ function removeTomlTableTree(toml, table) {
1713
+ const lines = toml.split(/\r?\n/);
1714
+ const kept = [];
1715
+ let skipping = false;
1716
+ for (const line of lines) {
1717
+ const trimmed = line.trim();
1718
+ const tableName = tomlTableName(trimmed);
1719
+ if (tableName) {
1720
+ skipping = tableName === table || tableName.startsWith(`${table}.`);
1721
+ if (skipping) continue;
1722
+ }
1723
+ if (!skipping) kept.push(line);
1724
+ }
1725
+ return kept.join("\n");
1726
+ }
1727
+ function tomlTableName(line) {
1728
+ if (line.startsWith("[[") && line.endsWith("]]")) return line.slice(2, -2).trim();
1729
+ if (line.startsWith("[") && line.endsWith("]")) return line.slice(1, -1).trim();
1730
+ return null;
1287
1731
  }
1288
- function stripTomlComment(value) {
1289
- let quote = null;
1290
- let escaped = false;
1291
- for (let i = 0; i < value.length; i += 1) {
1292
- const char = value[i];
1293
- if (quote) {
1294
- if (quote === '"' && char === "\\" && !escaped) {
1295
- escaped = true;
1296
- continue;
1732
+ function validateCodexToml(toml, label = "Codex config") {
1733
+ const lines = toml.split(/\r?\n/);
1734
+ let pendingValue = null;
1735
+ for (let index = 0; index < lines.length; index += 1) {
1736
+ const raw = lines[index];
1737
+ const line = stripTomlComment(raw).trim();
1738
+ if (!line) continue;
1739
+ if (pendingValue) {
1740
+ pendingValue.value = `${pendingValue.value}
1741
+ ${line}`;
1742
+ if (hasBalancedTomlContainers(pendingValue.value)) {
1743
+ if (!isTomlValue(pendingValue.value)) {
1744
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1745
+ }
1746
+ pendingValue = null;
1297
1747
  }
1298
- if (char === quote && !escaped) quote = null;
1299
- escaped = false;
1300
1748
  continue;
1301
1749
  }
1302
- if (char === '"' || char === "'") {
1303
- quote = char;
1750
+ if (isTomlTable(line)) continue;
1751
+ const equalsIndex = line.indexOf("=");
1752
+ if (equalsIndex <= 0) {
1753
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1754
+ }
1755
+ const key = line.slice(0, equalsIndex).trim();
1756
+ const value = line.slice(equalsIndex + 1).trim();
1757
+ if (!isTomlKey(key)) {
1758
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1759
+ }
1760
+ if (startsTomlContainer(value) && !hasBalancedTomlContainers(value)) {
1761
+ pendingValue = { value, line: index + 1 };
1304
1762
  continue;
1305
1763
  }
1306
- if (char === "#") return value.slice(0, i);
1764
+ if (!isTomlValue(value)) {
1765
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${index + 1}.`);
1766
+ }
1767
+ }
1768
+ if (pendingValue) {
1769
+ throw new InvalidCodexTomlError(`${label} has invalid TOML near line ${pendingValue.line}.`);
1307
1770
  }
1308
- return value;
1309
1771
  }
1310
- function hasBalancedTomlContainers(value) {
1311
- const stack = [];
1772
+ function isTomlTable(line) {
1773
+ const table = tomlTableName(line);
1774
+ return Boolean(table && splitTomlDottedKey(table).every(isTomlKeyPart));
1775
+ }
1776
+ function isTomlKey(value) {
1777
+ return splitTomlDottedKey(value).every(isTomlKeyPart);
1778
+ }
1779
+ function splitTomlDottedKey(value) {
1780
+ const parts = [];
1781
+ let current = "";
1312
1782
  let quote = null;
1313
1783
  let escaped = false;
1314
1784
  for (let i = 0; i < value.length; i += 1) {
1315
1785
  const char = value[i];
1316
1786
  if (quote) {
1787
+ current += char;
1317
1788
  if (quote === '"' && char === "\\" && !escaped) {
1318
1789
  escaped = true;
1319
1790
  continue;
@@ -1324,638 +1795,449 @@ function hasBalancedTomlContainers(value) {
1324
1795
  }
1325
1796
  if (char === '"' || char === "'") {
1326
1797
  quote = char;
1798
+ current += char;
1327
1799
  continue;
1328
1800
  }
1329
- if (char === "[" || char === "{") {
1330
- stack.push(char);
1331
- continue;
1332
- }
1333
- if (char === "]") {
1334
- if (stack.pop() !== "[") return false;
1801
+ if (char === ".") {
1802
+ parts.push(current.trim());
1803
+ current = "";
1335
1804
  continue;
1336
1805
  }
1337
- if (char === "}") {
1338
- if (stack.pop() !== "{") return false;
1339
- }
1340
- }
1341
- return stack.length === 0 && quote === null;
1342
- }
1343
- function tomlString(value) {
1344
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1345
- }
1346
- function runtimeConfigPathFor(runtime, homeDir = homedir()) {
1347
- switch (runtime) {
1348
- case "cursor":
1349
- return cursorConfigPath(homeDir);
1350
- case "codex-cli":
1351
- case "codex-desktop":
1352
- return codexConfigPath(homeDir);
1353
- case "vscode":
1354
- return vscodeConfigPath(homeDir);
1355
- case "vscode-insiders":
1356
- return vscodeInsidersConfigPath(homeDir);
1357
- case "claude-desktop":
1358
- return claudeDesktopConfigPath(homeDir);
1359
- case "hermes":
1360
- return hermesConfigPath(homeDir);
1361
- default:
1362
- return null;
1363
- }
1364
- }
1365
- function cursorConfigPath(homeDir = homedir()) {
1366
- return resolve(homeDir, ".cursor", "mcp.json");
1367
- }
1368
- function codexConfigPath(homeDir = homedir()) {
1369
- return resolve(homeDir, ".codex", "config.toml");
1370
- }
1371
- function vscodeConfigPath(homeDir = homedir()) {
1372
- if (platform() === "darwin") return resolve(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
1373
- if (platform() === "win32") {
1374
- return resolve(process.env.APPDATA ?? join(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
1375
- }
1376
- return resolve(homeDir, ".config", "Code", "User", "mcp.json");
1377
- }
1378
- function vscodeInsidersConfigPath(homeDir = homedir()) {
1379
- if (platform() === "darwin") return resolve(homeDir, "Library", "Application Support", "Code - Insiders", "User", "mcp.json");
1380
- if (platform() === "win32") {
1381
- return resolve(process.env.APPDATA ?? join(homeDir, "AppData", "Roaming"), "Code - Insiders", "User", "mcp.json");
1382
- }
1383
- return resolve(homeDir, ".config", "Code - Insiders", "User", "mcp.json");
1384
- }
1385
- function claudeDesktopConfigPath(homeDir = homedir()) {
1386
- if (platform() === "darwin") {
1387
- return resolve(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1388
- }
1389
- if (platform() === "win32") {
1390
- return resolve(process.env.APPDATA ?? join(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1391
- }
1392
- return resolve(homeDir, ".config", "Claude", "claude_desktop_config.json");
1393
- }
1394
- function hermesConfigPath(homeDir) {
1395
- return join(hermesHomePath(homeDir), "config.yaml");
1396
- }
1397
- function hermesEnvPath(homeDir) {
1398
- return join(hermesHomePath(homeDir), ".env");
1399
- }
1400
- function hermesHomePath(homeDir) {
1401
- return process.env.HERMES_HOME ?? join(homeDir ?? homedir(), ".hermes");
1402
- }
1403
- function configTargetLabel(runtime) {
1404
- switch (runtime) {
1405
- case "codex-cli":
1406
- return "Codex CLI config";
1407
- case "codex-desktop":
1408
- return "Codex Desktop config";
1409
- case "cursor":
1410
- return "Cursor MCP config";
1411
- case "vscode":
1412
- return "VS Code MCP config";
1413
- case "vscode-insiders":
1414
- return "VS Code Insiders MCP config";
1415
- case "claude-desktop":
1416
- return "Claude Desktop config";
1417
- default:
1418
- return "runtime MCP config";
1419
- }
1420
- }
1421
- var HERMES_API_KEY_ENV, HermesConfigRecoveryError, InvalidCodexTomlError, REPAIR_COMMAND_PREFIX, UnreadableRuntimeConfigError;
1422
- var init_config_writers = __esm({
1423
- "src/config-writers.ts"() {
1424
- init_runtime_manifest();
1425
- init_server_names();
1426
- HERMES_API_KEY_ENV = "MCP_HAVEN_API_KEY";
1427
- HermesConfigRecoveryError = class extends Error {
1428
- constructor() {
1429
- super("Hermes configuration recovery did not complete");
1430
- this.name = "HermesConfigRecoveryError";
1431
- }
1432
- };
1433
- InvalidCodexTomlError = class extends Error {
1434
- constructor(message) {
1435
- super(message);
1436
- this.name = "InvalidCodexTomlError";
1437
- }
1438
- };
1439
- REPAIR_COMMAND_PREFIX = connectorRerunCommand("--doctor --repair --runtime");
1440
- UnreadableRuntimeConfigError = class extends Error {
1441
- configPath;
1442
- constructor(configPath, detail) {
1443
- super(`${configPath} is not a config Haven can merge into (${detail})`);
1444
- this.name = "UnreadableRuntimeConfigError";
1445
- this.configPath = configPath;
1446
- }
1447
- };
1448
- }
1449
- });
1450
- async function acknowledgeLocalMcpConsent(identityPath, signerPath, log) {
1451
- try {
1452
- const input = await buildLocalMcpConsentInput(identityPath, signerPath);
1453
- const decision = await ensureConsent(input, {
1454
- credentialsPath: identityPath,
1455
- writeAck: true,
1456
- out: log ? { write: (chunk) => writeLogChunk(log, chunk) } : void 0
1457
- });
1458
- return {
1459
- acknowledged: decision.ok,
1460
- hash: decision.hash,
1461
- reason: decision.reason
1462
- };
1463
- } catch (err) {
1464
- return {
1465
- acknowledged: false,
1466
- error: err instanceof Error ? err.message : String(err)
1467
- };
1468
- }
1469
- }
1470
- async function getLocalMcpConsentStatus(identityPath, signerPath) {
1471
- try {
1472
- const input = await buildLocalMcpConsentInput(identityPath, signerPath);
1473
- const hash = computeConsentHash(input);
1474
- const stored = await readLocalMcpAckFile(localMcpAckPath(identityPath));
1475
- if (stored === hash) {
1476
- return { acknowledged: true, hash, reason: "ack_file_match" };
1477
- }
1478
- return {
1479
- acknowledged: false,
1480
- hash,
1481
- reason: stored ? "ack_file_mismatch" : "ack_file_missing"
1482
- };
1483
- } catch (err) {
1484
- return {
1485
- acknowledged: false,
1486
- error: err instanceof Error ? err.message : String(err)
1487
- };
1806
+ current += char;
1488
1807
  }
1808
+ parts.push(current.trim());
1809
+ return quote ? [] : parts;
1489
1810
  }
1490
- function localMcpAckPath(identityPath) {
1491
- return resolve(`${identityPath}.ack.json`);
1492
- }
1493
- async function buildLocalMcpConsentInput(identityPath, signerPath) {
1494
- const credentials = await loadCredentials({ identityPath, signerPath });
1495
- const unavailableDuringSetup = {
1496
- getAllowances: async () => {
1497
- throw new Error("Haven approval is not complete yet.");
1498
- }
1499
- };
1500
- return consentInputFromClient(
1501
- unavailableDuringSetup,
1502
- {
1503
- apiKey: credentials.apiKey,
1504
- apiUrl: credentials.apiUrl,
1505
- agentId: credentials.agentId,
1506
- accountAddress: credentials.accountAddress,
1507
- delegateAddress: credentials.delegateAddress,
1508
- chainId: credentials.chainId,
1509
- allowanceSummary: credentials.allowanceSummary
1510
- },
1511
- registeredToolNames()
1512
- );
1513
- }
1514
- async function readLocalMcpAckFile(path) {
1515
- try {
1516
- const parsed = JSON.parse(await readFile(path, "utf8"));
1517
- return typeof parsed.ack === "string" ? parsed.ack : null;
1518
- } catch {
1519
- return null;
1520
- }
1811
+ function isTomlKeyPart(value) {
1812
+ return isTomlBareKey(value) || isTomlQuotedString(value);
1521
1813
  }
1522
- function writeLogChunk(log, chunk) {
1523
- const message = String(chunk).trimEnd();
1524
- if (message) log(message);
1814
+ function isTomlBareKey(value) {
1815
+ return /^[A-Za-z0-9_-]+$/.test(value);
1525
1816
  }
1526
- var init_local_mcp_consent = __esm({
1527
- "src/local-mcp-consent.ts"() {
1528
- }
1529
- });
1530
- async function probeHostedAgentIdentity(apiKey, apiUrl, fetchImpl = fetch) {
1531
- let response;
1532
- try {
1533
- response = await fetchWithTimeout(fetchImpl, `${apiUrl.replace(/\/+$/, "")}/machine-payments/agent`, {
1534
- method: "GET",
1535
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }
1536
- });
1537
- } catch {
1538
- return { status: "network_error" };
1539
- }
1540
- if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
1541
- if (!response.ok) return { status: "bad_response" };
1542
- try {
1543
- const payload = JSON.parse(await response.text());
1544
- if (typeof payload?.delegate_address !== "string") return { status: "bad_response" };
1545
- return { status: "ok", agentId: payload.id, delegateAddress: payload.delegate_address };
1546
- } catch {
1547
- return { status: "bad_response" };
1817
+ function isTomlValue(value) {
1818
+ if (!value) return false;
1819
+ if (isTomlQuotedString(value)) return true;
1820
+ if (/^(true|false)$/i.test(value)) return true;
1821
+ if (/^[+-]?(?:inf|nan)$/i.test(value)) return true;
1822
+ if (/^[+-]?(?:0|[1-9][0-9_]*)(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?$/.test(value)) return true;
1823
+ if (/^\d{4}-\d{2}-\d{2}(?:[Tt ][0-9:.+-Zz]+)?$/.test(value)) return true;
1824
+ if (value.startsWith("[") && value.endsWith("]") || value.startsWith("{") && value.endsWith("}")) {
1825
+ return hasBalancedTomlContainers(value);
1548
1826
  }
1827
+ return false;
1549
1828
  }
1550
- async function probeHostedMcpTools(apiKey, hostedMcpUrl, fetchImpl = fetch) {
1551
- let response;
1552
- try {
1553
- response = await fetchWithTimeout(fetchImpl, hostedMcpUrl, {
1554
- method: "POST",
1555
- headers: {
1556
- Authorization: `Bearer ${apiKey}`,
1557
- "Content-Type": "application/json",
1558
- Accept: "application/json, text/event-stream"
1559
- },
1560
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
1561
- });
1562
- } catch {
1563
- return { status: "network_error" };
1564
- }
1565
- if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
1566
- if (!response.ok) return { status: "bad_response" };
1567
- try {
1568
- const payload = parseJsonRpcPayload(await response.text());
1569
- if (!payload || payload.error) return { status: "bad_response" };
1570
- const tools = payload.result?.tools;
1571
- return { status: "ok", toolCount: Array.isArray(tools) ? tools.length : void 0 };
1572
- } catch {
1573
- return { status: "bad_response" };
1574
- }
1829
+ function startsTomlContainer(value) {
1830
+ return value.startsWith("[") || value.startsWith("{");
1575
1831
  }
1576
- async function probeLocalSignerCredential(signerPath) {
1577
- try {
1578
- const parsed = JSON.parse(await readFile(signerPath, "utf8"));
1579
- return Boolean(
1580
- parsed && typeof parsed === "object" && "delegate_key" in parsed && typeof parsed.delegate_key === "string"
1581
- );
1582
- } catch {
1832
+ function isTomlQuotedString(value) {
1833
+ if (value.startsWith('"""') || value.startsWith("'''")) {
1834
+ const marker = value.slice(0, 3);
1835
+ return value.length >= 6 && value.endsWith(marker);
1836
+ }
1837
+ if ((!value.startsWith('"') || !value.endsWith('"')) && (!value.startsWith("'") || !value.endsWith("'"))) {
1583
1838
  return false;
1584
1839
  }
1840
+ return hasBalancedTomlContainers(value);
1585
1841
  }
1586
- async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
1587
- return new Promise((resolve11) => {
1588
- const child = spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
1589
- let stdout = "";
1590
- let settled = false;
1591
- let sawInitialize = false;
1592
- let serverInfo;
1593
- let capabilities;
1594
- const finish = (result) => {
1595
- if (settled) return;
1596
- settled = true;
1597
- clearTimeout(timeout);
1598
- child.kill();
1599
- resolve11(result);
1600
- };
1601
- const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1602
- child.on("error", () => finish({ status: "process_error" }));
1603
- child.on("exit", (code) => {
1604
- if (!settled && code !== 0) finish({ status: "process_error" });
1605
- });
1606
- child.stdout.on("data", (chunk) => {
1607
- stdout += chunk.toString("utf8");
1608
- const lines = stdout.split(/\r?\n/);
1609
- stdout = lines.pop() ?? "";
1610
- for (const line of lines) {
1611
- const trimmed = line.trim();
1612
- if (!trimmed) continue;
1613
- let payload;
1614
- try {
1615
- payload = JSON.parse(trimmed);
1616
- } catch {
1617
- continue;
1618
- }
1619
- if (payload.error) {
1620
- finish({ status: "bad_response" });
1621
- return;
1622
- }
1623
- if (payload.id === 1 && !sawInitialize) {
1624
- sawInitialize = true;
1625
- const init = payload.result;
1626
- serverInfo = init?.serverInfo;
1627
- capabilities = init?.capabilities;
1628
- writeJsonRpc(child, { jsonrpc: "2.0", method: "notifications/initialized", params: {} });
1629
- writeJsonRpc(child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
1630
- continue;
1631
- }
1632
- if (payload.id === 2) {
1633
- const tools = payload.result?.tools;
1634
- const toolNames = Array.isArray(tools) ? tools.map((tool) => tool && typeof tool === "object" && "name" in tool ? tool.name : void 0).filter((name) => typeof name === "string") : [];
1635
- const missing = requiredTools.filter((name) => !toolNames.includes(name));
1636
- finish({ status: missing.length === 0 ? "ok" : "missing_tools", toolNames, serverInfo, capabilities });
1637
- return;
1638
- }
1639
- }
1640
- });
1641
- writeJsonRpc(child, {
1642
- jsonrpc: "2.0",
1643
- id: 1,
1644
- method: "initialize",
1645
- params: {
1646
- protocolVersion: "2025-06-18",
1647
- capabilities: {},
1648
- clientInfo: { name: "haven-connect-probe", version: "0.0.0" }
1842
+ function stripTomlComment(value) {
1843
+ let quote = null;
1844
+ let escaped = false;
1845
+ for (let i = 0; i < value.length; i += 1) {
1846
+ const char = value[i];
1847
+ if (quote) {
1848
+ if (quote === '"' && char === "\\" && !escaped) {
1849
+ escaped = true;
1850
+ continue;
1649
1851
  }
1650
- });
1651
- });
1652
- }
1653
- function writeJsonRpc(child, payload) {
1654
- child.stdin?.write(`${JSON.stringify(payload)}
1655
- `);
1656
- }
1657
- function parseJsonRpcPayload(raw) {
1658
- const trimmed = raw.trim();
1659
- if (!trimmed) return null;
1660
- if (trimmed.startsWith("{")) {
1661
- try {
1662
- return JSON.parse(trimmed);
1663
- } catch {
1664
- return null;
1852
+ if (char === quote && !escaped) quote = null;
1853
+ escaped = false;
1854
+ continue;
1665
1855
  }
1666
- }
1667
- const dataLines = trimmed.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim());
1668
- for (let i = dataLines.length - 1; i >= 0; i -= 1) {
1669
- try {
1670
- return JSON.parse(dataLines[i]);
1671
- } catch {
1856
+ if (char === '"' || char === "'") {
1857
+ quote = char;
1858
+ continue;
1672
1859
  }
1860
+ if (char === "#") return value.slice(0, i);
1673
1861
  }
1674
- return null;
1862
+ return value;
1675
1863
  }
1676
- async function fetchWithTimeout(fetchImpl, url, init) {
1677
- const controller = new AbortController();
1678
- const timeout = setTimeout(() => controller.abort(), 3e3);
1679
- try {
1680
- return await fetchImpl(url, { ...init, signal: controller.signal });
1681
- } finally {
1682
- clearTimeout(timeout);
1864
+ function hasBalancedTomlContainers(value) {
1865
+ const stack = [];
1866
+ let quote = null;
1867
+ let escaped = false;
1868
+ for (let i = 0; i < value.length; i += 1) {
1869
+ const char = value[i];
1870
+ if (quote) {
1871
+ if (quote === '"' && char === "\\" && !escaped) {
1872
+ escaped = true;
1873
+ continue;
1874
+ }
1875
+ if (char === quote && !escaped) quote = null;
1876
+ escaped = false;
1877
+ continue;
1878
+ }
1879
+ if (char === '"' || char === "'") {
1880
+ quote = char;
1881
+ continue;
1882
+ }
1883
+ if (char === "[" || char === "{") {
1884
+ stack.push(char);
1885
+ continue;
1886
+ }
1887
+ if (char === "]") {
1888
+ if (stack.pop() !== "[") return false;
1889
+ continue;
1890
+ }
1891
+ if (char === "}") {
1892
+ if (stack.pop() !== "{") return false;
1893
+ }
1683
1894
  }
1895
+ return stack.length === 0 && quote === null;
1684
1896
  }
1685
- var init_probes = __esm({
1686
- "src/probes.ts"() {
1687
- }
1688
- });
1689
- function resolveRuntimeSpecOverride(env) {
1690
- const override = {};
1691
- for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
1692
- const variable = RUNTIME_SPEC_ENV[pkg];
1693
- const raw = env[variable];
1694
- if (raw === void 0) continue;
1695
- override[pkg] = validateSpec(variable, raw);
1897
+ function tomlString(value) {
1898
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1899
+ }
1900
+ function runtimeConfigPathFor(runtime, homeDir = homedir()) {
1901
+ switch (runtime) {
1902
+ case "cursor":
1903
+ return cursorConfigPath(homeDir);
1904
+ case "codex-cli":
1905
+ case "codex-desktop":
1906
+ return codexConfigPath(homeDir);
1907
+ case "vscode":
1908
+ return vscodeConfigPath(homeDir);
1909
+ case "vscode-insiders":
1910
+ return vscodeInsidersConfigPath(homeDir);
1911
+ case "claude-desktop":
1912
+ return claudeDesktopConfigPath(homeDir);
1913
+ case "hermes":
1914
+ return hermesConfigPath(homeDir);
1915
+ default:
1916
+ return null;
1696
1917
  }
1697
- return Object.keys(override).length > 0 ? override : void 0;
1698
1918
  }
1699
- function validateSpec(variable, raw) {
1700
- if (raw.length === 0 || raw.trim().length === 0) {
1701
- throw new RuntimeSpecOverrideError(variable, "it is empty");
1919
+ function cursorConfigPath(homeDir = homedir()) {
1920
+ return resolve(homeDir, ".cursor", "mcp.json");
1921
+ }
1922
+ function codexConfigPath(homeDir = homedir()) {
1923
+ return resolve(homeDir, ".codex", "config.toml");
1924
+ }
1925
+ function vscodeConfigPath(homeDir = homedir()) {
1926
+ if (platform() === "darwin") return resolve(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
1927
+ if (platform() === "win32") {
1928
+ return resolve(process.env.APPDATA ?? join(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
1702
1929
  }
1703
- if (/\s/.test(raw)) {
1704
- throw new RuntimeSpecOverrideError(variable, "it contains whitespace");
1930
+ return resolve(homeDir, ".config", "Code", "User", "mcp.json");
1931
+ }
1932
+ function vscodeInsidersConfigPath(homeDir = homedir()) {
1933
+ if (platform() === "darwin") return resolve(homeDir, "Library", "Application Support", "Code - Insiders", "User", "mcp.json");
1934
+ if (platform() === "win32") {
1935
+ return resolve(process.env.APPDATA ?? join(homeDir, "AppData", "Roaming"), "Code - Insiders", "User", "mcp.json");
1705
1936
  }
1706
- if (/[\u0000-\u001f\u007f]/.test(raw)) {
1707
- throw new RuntimeSpecOverrideError(variable, "it contains a control character");
1937
+ return resolve(homeDir, ".config", "Code - Insiders", "User", "mcp.json");
1938
+ }
1939
+ function claudeDesktopConfigPath(homeDir = homedir()) {
1940
+ if (platform() === "darwin") {
1941
+ return resolve(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1708
1942
  }
1709
- const meta = SHELL_METACHARACTERS.exec(raw);
1710
- if (meta) {
1711
- throw new RuntimeSpecOverrideError(variable, `it contains the shell metacharacter ${JSON.stringify(meta[0])}`);
1943
+ if (platform() === "win32") {
1944
+ return resolve(process.env.APPDATA ?? join(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1712
1945
  }
1713
- return raw;
1946
+ return resolve(homeDir, ".config", "Claude", "claude_desktop_config.json");
1714
1947
  }
1715
- function runtimeSpecOverrideDirectoryKey(resolvedSpecs) {
1716
- const digest = createHash("sha256").update(resolvedSpecs.join("\n")).digest("hex");
1717
- return `override-${digest.slice(0, 12)}`;
1948
+ function hermesConfigPath(homeDir) {
1949
+ return join(hermesHomePath(homeDir), "config.yaml");
1718
1950
  }
1719
- function overrideApplies(override, packages) {
1720
- return override !== void 0 && packages.some((pkg) => override[pkg] !== void 0);
1951
+ function hermesEnvPath(homeDir) {
1952
+ return join(hermesHomePath(homeDir), ".env");
1721
1953
  }
1722
- function runtimeSpecOverrideNotice(runtimeLabel, override, pinned, runtimeDirectory) {
1723
- const lines = [`RUNTIME SPEC OVERRIDE ACTIVE for the local Haven ${runtimeLabel} \u2014 this is NOT the pinned manifest.`];
1724
- for (const pkg of Object.keys(RUNTIME_SPEC_ENV)) {
1725
- const spec = override[pkg];
1726
- if (spec === void 0) continue;
1727
- const pin = pinned[pkg];
1728
- lines.push(` ${RUNTIME_SPEC_ENV[pkg]}=${spec}${pin ? ` (instead of ${pin})` : ""}`);
1729
- }
1730
- lines.push(` Installing into ${runtimeDirectory} \u2014 the pinned runtime directory is untouched.`);
1731
- lines.push(" Unset the variable(s) and re-run to return to the pinned manifest.");
1732
- return lines;
1954
+ function hermesHomePath(homeDir) {
1955
+ return process.env.HERMES_HOME ?? join(homeDir ?? homedir(), ".hermes");
1733
1956
  }
1734
- function describeRuntimeSpecOverride(override) {
1735
- return Object.keys(RUNTIME_SPEC_ENV).filter((pkg) => override[pkg] !== void 0).map((pkg) => `${RUNTIME_SPEC_ENV[pkg]}=${override[pkg]}`).join(" ");
1957
+ function configTargetLabel(runtime) {
1958
+ switch (runtime) {
1959
+ case "codex-cli":
1960
+ return "Codex CLI config";
1961
+ case "codex-desktop":
1962
+ return "Codex Desktop config";
1963
+ case "cursor":
1964
+ return "Cursor MCP config";
1965
+ case "vscode":
1966
+ return "VS Code MCP config";
1967
+ case "vscode-insiders":
1968
+ return "VS Code Insiders MCP config";
1969
+ case "claude-desktop":
1970
+ return "Claude Desktop config";
1971
+ default:
1972
+ return "runtime MCP config";
1973
+ }
1736
1974
  }
1737
- var RUNTIME_SPEC_ENV, SHELL_METACHARACTERS, RuntimeSpecOverrideError;
1738
- var init_runtime_spec_override = __esm({
1739
- "src/runtime-spec-override.ts"() {
1740
- RUNTIME_SPEC_ENV = {
1741
- signer: "HAVEN_SIGNER_SPEC",
1742
- sdk: "HAVEN_SDK_SPEC",
1743
- mcp: "HAVEN_MCP_SPEC"
1975
+ var HERMES_API_KEY_ENV, HermesConfigRecoveryError, InvalidCodexTomlError, REPAIR_COMMAND_PREFIX, UnreadableRuntimeConfigError;
1976
+ var init_config_writers = __esm({
1977
+ "src/config-writers.ts"() {
1978
+ init_runtime_manifest();
1979
+ init_server_names();
1980
+ HERMES_API_KEY_ENV = "MCP_HAVEN_API_KEY";
1981
+ HermesConfigRecoveryError = class extends Error {
1982
+ constructor() {
1983
+ super("Hermes configuration recovery did not complete");
1984
+ this.name = "HermesConfigRecoveryError";
1985
+ }
1744
1986
  };
1745
- SHELL_METACHARACTERS = /[;&|<>$`'"()!#*?[\]{}\\]/;
1746
- RuntimeSpecOverrideError = class extends Error {
1747
- code = "runtime_spec_override_invalid";
1748
- variable;
1749
- constructor(variable, reason) {
1750
- 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>).`);
1751
- this.name = "RuntimeSpecOverrideError";
1752
- this.variable = variable;
1987
+ InvalidCodexTomlError = class extends Error {
1988
+ constructor(message) {
1989
+ super(message);
1990
+ this.name = "InvalidCodexTomlError";
1991
+ }
1992
+ };
1993
+ REPAIR_COMMAND_PREFIX = connectorRerunCommand("--doctor --repair --runtime");
1994
+ UnreadableRuntimeConfigError = class extends Error {
1995
+ configPath;
1996
+ constructor(configPath, detail) {
1997
+ super(`${configPath} is not a config Haven can merge into (${detail})`);
1998
+ this.name = "UnreadableRuntimeConfigError";
1999
+ this.configPath = configPath;
1753
2000
  }
1754
2001
  };
1755
2002
  }
1756
2003
  });
1757
- async function prepareSignerRuntime(input, deps = {}) {
1758
- const homeDir = input.homeDir ?? homedir();
1759
- const override = resolveSignerRuntimeOverride(deps.env ?? process.env);
1760
- const signerSpec = override?.signer ?? signerPackageSpec();
1761
- const sdkSpec = override?.sdk ?? sdkPackageSpec();
1762
- const resolvedSpecs = [signerSpec, sdkSpec];
1763
- const overrideRecord = override ? { specs: override, resolved_specs: resolvedSpecs, directory_key: runtimeSpecOverrideDirectoryKey(resolvedSpecs) } : void 0;
1764
- const runtimeDirectory = resolve(
1765
- homeDir,
1766
- ".haven",
1767
- "signer-runtime",
1768
- overrideRecord ? overrideRecord.directory_key : MCP_RUNTIME_MANIFEST.signerVersion
1769
- );
1770
- const npmCacheDirectory = resolve(homeDir, ".haven", "npm-cache");
1771
- const cliPath = join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "dist", "cli.js");
1772
- const messages = [];
1773
- if (override) {
1774
- messages.push(...runtimeSpecOverrideNotice(
1775
- "signer runtime",
1776
- override,
1777
- { signer: signerPackageSpec(), sdk: sdkPackageSpec() },
1778
- runtimeDirectory
1779
- ));
2004
+ async function acknowledgeLocalMcpConsent(identityPath, signerPath, log) {
2005
+ try {
2006
+ const input = await buildLocalMcpConsentInput(identityPath, signerPath);
2007
+ const decision = await ensureConsent(input, {
2008
+ credentialsPath: identityPath,
2009
+ writeAck: true,
2010
+ out: log ? { write: (chunk) => writeLogChunk(log, chunk) } : void 0
2011
+ });
2012
+ return {
2013
+ acknowledged: decision.ok,
2014
+ hash: decision.hash,
2015
+ reason: decision.reason
2016
+ };
2017
+ } catch (err) {
2018
+ return {
2019
+ acknowledged: false,
2020
+ error: err instanceof Error ? err.message : String(err)
2021
+ };
1780
2022
  }
1781
- await mkdir(runtimeDirectory, { recursive: true, mode: 448 });
1782
- await chmod(runtimeDirectory, 448).catch(() => void 0);
1783
- await mkdir(npmCacheDirectory, { recursive: true, mode: 448 });
1784
- await chmod(npmCacheDirectory, 448).catch(() => void 0);
1785
- if (override) {
1786
- await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1787
- messages.push(`Installed local Haven signer runtime from override (${resolvedSpecs.join(" ")}).`);
1788
- } else if (await installedRuntimeMatches(runtimeDirectory, cliPath)) {
1789
- messages.push(`Using existing local Haven signer runtime ${signerPackageSpec()}.`);
1790
- } else {
1791
- await installRuntimePackages(runtimeDirectory, npmCacheDirectory, resolvedSpecs, deps);
1792
- messages.push(`Installed local Haven signer runtime ${signerPackageSpec()}.`);
2023
+ }
2024
+ async function getLocalMcpConsentStatus(identityPath, signerPath) {
2025
+ try {
2026
+ const input = await buildLocalMcpConsentInput(identityPath, signerPath);
2027
+ const hash = computeConsentHash(input);
2028
+ const stored = await readLocalMcpAckFile(localMcpAckPath(identityPath));
2029
+ if (stored === hash) {
2030
+ return { acknowledged: true, hash, reason: "ack_file_match" };
2031
+ }
2032
+ return {
2033
+ acknowledged: false,
2034
+ hash,
2035
+ reason: stored ? "ack_file_mismatch" : "ack_file_missing"
2036
+ };
2037
+ } catch (err) {
2038
+ return {
2039
+ acknowledged: false,
2040
+ error: err instanceof Error ? err.message : String(err)
2041
+ };
1793
2042
  }
1794
- await assertFileExists(cliPath, "local Haven signer CLI");
1795
- const installedVersions = override ? await readInstalledVersions(runtimeDirectory) : void 0;
1796
- const wrapperPath = join(input.credentialDirectory, "bin", "haven-signer.mjs");
1797
- await writeWrapper({
1798
- wrapperPath,
1799
- cliPath,
1800
- signerPath: input.signerPath,
1801
- overrideComment: override ? describeRuntimeSpecOverride(override) : void 0
1802
- });
1803
- await writeRuntimeSidecar({
1804
- path: join(input.credentialDirectory, "signer-runtime.json"),
1805
- wrapperPath,
1806
- runtimeDirectory,
1807
- npmCacheDirectory,
1808
- cliPath,
1809
- serverName: input.serverName,
1810
- override: overrideRecord,
1811
- installedVersions
1812
- });
1813
- messages.push(`Prepared stable local Haven signer wrapper: ${wrapperPath}`);
1814
- return {
1815
- command: wrapperPath,
1816
- args: [],
1817
- wrapperPath,
1818
- runtimeDirectory,
1819
- npmCacheDirectory,
1820
- cliPath,
1821
- messages,
1822
- ...overrideRecord ? { runtimeSpecOverride: overrideRecord } : {}
1823
- };
1824
2043
  }
1825
- function resolveSignerRuntimeOverride(env) {
1826
- const override = resolveRuntimeSpecOverride(env);
1827
- if (!overrideApplies(override, ["signer", "sdk"])) return void 0;
1828
- const { signer, sdk } = override;
1829
- return { ...signer !== void 0 ? { signer } : {}, ...sdk !== void 0 ? { sdk } : {} };
2044
+ function localMcpAckPath(identityPath) {
2045
+ return resolve(`${identityPath}.ack.json`);
1830
2046
  }
1831
- async function installRuntimePackages(runtimeDirectory, npmCacheDirectory, packageSpecs, deps) {
1832
- const { runCommand, onProgress } = deps;
1833
- const baseArgs = [
1834
- "install",
1835
- "--prefix",
1836
- runtimeDirectory,
1837
- "--no-audit",
1838
- "--no-fund",
1839
- "--omit=dev",
1840
- "--prefer-offline",
1841
- ...packageSpecs
1842
- ];
1843
- const run = async (args) => {
1844
- const startedAt = Date.now();
1845
- const heartbeat = setInterval(() => {
1846
- const seconds = Math.round((Date.now() - startedAt) / 1e3);
1847
- onProgress?.(`Still installing the local Haven signer runtime\u2026 (${seconds}s \u2014 a cold cache can take several minutes)`);
1848
- }, SIGNER_INSTALL_HEARTBEAT_MS);
1849
- heartbeat.unref?.();
1850
- try {
1851
- if (runCommand) await runCommand("npm", args);
1852
- else await execFileAsync("npm", args, { timeout: SIGNER_INSTALL_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
1853
- } finally {
1854
- clearInterval(heartbeat);
2047
+ async function buildLocalMcpConsentInput(identityPath, signerPath) {
2048
+ const credentials = await loadCredentials({ identityPath, signerPath });
2049
+ const unavailableDuringSetup = {
2050
+ getAllowances: async () => {
2051
+ throw new Error("Haven approval is not complete yet.");
1855
2052
  }
1856
2053
  };
2054
+ return consentInputFromClient(
2055
+ unavailableDuringSetup,
2056
+ {
2057
+ apiKey: credentials.apiKey,
2058
+ apiUrl: credentials.apiUrl,
2059
+ agentId: credentials.agentId,
2060
+ accountAddress: credentials.accountAddress,
2061
+ delegateAddress: credentials.delegateAddress,
2062
+ chainId: credentials.chainId,
2063
+ allowanceSummary: credentials.allowanceSummary
2064
+ },
2065
+ registeredToolNames()
2066
+ );
2067
+ }
2068
+ async function readLocalMcpAckFile(path) {
1857
2069
  try {
1858
- await run(baseArgs);
2070
+ const parsed = JSON.parse(await readFile(path, "utf8"));
2071
+ return typeof parsed.ack === "string" ? parsed.ack : null;
1859
2072
  } catch {
1860
- try {
1861
- await run([...baseArgs, "--cache", npmCacheDirectory]);
1862
- } catch (err) {
1863
- throw new Error(
1864
- `Could not install local Haven signer runtime ${packageSpecs.join(" ")}: ${err instanceof Error ? err.message : String(err)}`
1865
- );
1866
- }
2073
+ return null;
1867
2074
  }
1868
2075
  }
1869
- async function installedRuntimeMatches(runtimeDirectory, cliPath) {
1870
- return installedRuntimeMatchesVersions(runtimeDirectory, cliPath, {
1871
- signerVersion: MCP_RUNTIME_MANIFEST.signerVersion,
1872
- sdkVersion: MCP_RUNTIME_MANIFEST.sdkVersion
1873
- });
2076
+ function writeLogChunk(log, chunk) {
2077
+ const message = String(chunk).trimEnd();
2078
+ if (message) log(message);
1874
2079
  }
1875
- async function installedRuntimeMatchesVersions(runtimeDirectory, cliPath, expected) {
2080
+ var init_local_mcp_consent = __esm({
2081
+ "src/local-mcp-consent.ts"() {
2082
+ }
2083
+ });
2084
+ async function probeHostedAgentIdentity(apiKey, apiUrl, fetchImpl = fetch) {
2085
+ let response;
1876
2086
  try {
1877
- await assertFileExists(cliPath, "local Haven signer CLI");
1878
- const installed = await readInstalledVersions(runtimeDirectory);
1879
- return installed.signerVersion === expected.signerVersion && installed.sdkVersion === expected.sdkVersion;
2087
+ response = await fetchWithTimeout(fetchImpl, `${apiUrl.replace(/\/+$/, "")}/machine-payments/agent`, {
2088
+ method: "GET",
2089
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }
2090
+ });
1880
2091
  } catch {
1881
- return false;
2092
+ return { status: "network_error" };
2093
+ }
2094
+ if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
2095
+ if (!response.ok) return { status: "bad_response" };
2096
+ try {
2097
+ const payload = JSON.parse(await response.text());
2098
+ if (typeof payload?.delegate_address !== "string") return { status: "bad_response" };
2099
+ return { status: "ok", agentId: payload.id, delegateAddress: payload.delegate_address };
2100
+ } catch {
2101
+ return { status: "bad_response" };
1882
2102
  }
1883
2103
  }
1884
- async function readInstalledVersions(runtimeDirectory) {
1885
- const [signerPackage, sdkPackage] = await Promise.all([
1886
- readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "signer", "package.json")),
1887
- readPackageJson(join(runtimeDirectory, "node_modules", "@haven_ai", "sdk", "package.json"))
1888
- ]);
1889
- return { signerVersion: signerPackage.version ?? "", sdkVersion: sdkPackage.version ?? "" };
1890
- }
1891
- async function readPackageJson(path) {
1892
- return JSON.parse(await readFile(path, "utf8"));
1893
- }
1894
- async function writeWrapper(input) {
1895
- await mkdir(dirname(input.wrapperPath), { recursive: true, mode: 448 });
1896
- await chmod(dirname(input.wrapperPath), 448).catch(() => void 0);
1897
- const source = [
1898
- "#!/usr/bin/env node",
1899
- ...input.overrideComment ? [`// HAVEN RUNTIME SPEC OVERRIDE (#2424): ${input.overrideComment} \u2014 this wrapper launches a NON-pinned signer build.`] : [],
1900
- "import { spawn } from 'node:child_process'",
1901
- "",
1902
- `const cliPath = ${JSON.stringify(input.cliPath)}`,
1903
- `const signerPath = ${JSON.stringify(input.signerPath)}`,
1904
- "",
1905
- "const child = spawn(process.execPath, [cliPath, '--credentials', signerPath, ...process.argv.slice(2)], {",
1906
- " stdio: 'inherit',",
1907
- "})",
1908
- "",
1909
- "child.on('exit', (code, signal) => {",
1910
- " if (signal) process.kill(process.pid, signal)",
1911
- " else process.exit(code ?? 1)",
1912
- "})",
1913
- ""
1914
- ].join("\n");
1915
- await writeFile(input.wrapperPath, source, { mode: 448 });
1916
- await chmod(input.wrapperPath, 448).catch(() => void 0);
2104
+ async function probeHostedMcpTools(apiKey, hostedMcpUrl, fetchImpl = fetch) {
2105
+ let response;
2106
+ try {
2107
+ response = await fetchWithTimeout(fetchImpl, hostedMcpUrl, {
2108
+ method: "POST",
2109
+ headers: {
2110
+ Authorization: `Bearer ${apiKey}`,
2111
+ "Content-Type": "application/json",
2112
+ Accept: "application/json, text/event-stream"
2113
+ },
2114
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
2115
+ });
2116
+ } catch {
2117
+ return { status: "network_error" };
2118
+ }
2119
+ if (response.status === 401 || response.status === 403) return { status: "unauthorized" };
2120
+ if (!response.ok) return { status: "bad_response" };
2121
+ try {
2122
+ const payload = parseJsonRpcPayload(await response.text());
2123
+ if (!payload || payload.error) return { status: "bad_response" };
2124
+ const tools = payload.result?.tools;
2125
+ return { status: "ok", toolCount: Array.isArray(tools) ? tools.length : void 0 };
2126
+ } catch {
2127
+ return { status: "bad_response" };
2128
+ }
1917
2129
  }
1918
- async function readRuntimeSidecar(credentialDirectory) {
2130
+ async function probeLocalSignerCredential(signerPath) {
1919
2131
  try {
1920
- return JSON.parse(
1921
- await readFile(join(credentialDirectory, "signer-runtime.json"), "utf8")
2132
+ const parsed = JSON.parse(await readFile(signerPath, "utf8"));
2133
+ return Boolean(
2134
+ parsed && typeof parsed === "object" && "delegate_key" in parsed && typeof parsed.delegate_key === "string"
1922
2135
  );
1923
2136
  } catch {
1924
- return null;
2137
+ return false;
1925
2138
  }
1926
2139
  }
1927
- async function writeRuntimeSidecar(input) {
1928
- const value = {
1929
- ...input.serverName ? { server_name: input.serverName } : {},
1930
- signer_package: MCP_RUNTIME_MANIFEST.signerPackage,
1931
- signer_version: input.installedVersions?.signerVersion ?? MCP_RUNTIME_MANIFEST.signerVersion,
1932
- sdk_package: MCP_RUNTIME_MANIFEST.sdkPackage,
1933
- sdk_version: input.installedVersions?.sdkVersion ?? MCP_RUNTIME_MANIFEST.sdkVersion,
1934
- wrapper_path: input.wrapperPath,
1935
- runtime_directory: input.runtimeDirectory,
1936
- npm_cache_directory: input.npmCacheDirectory,
1937
- cli_path: input.cliPath,
1938
- ...input.override ? { runtime_spec_override: input.override } : {}
1939
- };
1940
- await writeFile(input.path, `${JSON.stringify(value, null, 2)}
1941
- `, { mode: 384 });
1942
- await chmod(input.path, 384).catch(() => void 0);
2140
+ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
2141
+ return new Promise((resolve11) => {
2142
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
2143
+ let stdout = "";
2144
+ let settled = false;
2145
+ let sawInitialize = false;
2146
+ let serverInfo;
2147
+ let capabilities;
2148
+ const finish = (result) => {
2149
+ if (settled) return;
2150
+ settled = true;
2151
+ clearTimeout(timeout);
2152
+ child.kill();
2153
+ resolve11(result);
2154
+ };
2155
+ const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
2156
+ child.on("error", () => finish({ status: "process_error" }));
2157
+ child.on("exit", (code) => {
2158
+ if (!settled && code !== 0) finish({ status: "process_error" });
2159
+ });
2160
+ child.stdout.on("data", (chunk) => {
2161
+ stdout += chunk.toString("utf8");
2162
+ const lines = stdout.split(/\r?\n/);
2163
+ stdout = lines.pop() ?? "";
2164
+ for (const line of lines) {
2165
+ const trimmed = line.trim();
2166
+ if (!trimmed) continue;
2167
+ let payload;
2168
+ try {
2169
+ payload = JSON.parse(trimmed);
2170
+ } catch {
2171
+ continue;
2172
+ }
2173
+ if (payload.error) {
2174
+ finish({ status: "bad_response" });
2175
+ return;
2176
+ }
2177
+ if (payload.id === 1 && !sawInitialize) {
2178
+ sawInitialize = true;
2179
+ const init = payload.result;
2180
+ serverInfo = init?.serverInfo;
2181
+ capabilities = init?.capabilities;
2182
+ writeJsonRpc(child, { jsonrpc: "2.0", method: "notifications/initialized", params: {} });
2183
+ writeJsonRpc(child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
2184
+ continue;
2185
+ }
2186
+ if (payload.id === 2) {
2187
+ const tools = payload.result?.tools;
2188
+ const toolNames = Array.isArray(tools) ? tools.map((tool) => tool && typeof tool === "object" && "name" in tool ? tool.name : void 0).filter((name) => typeof name === "string") : [];
2189
+ const missing = requiredTools.filter((name) => !toolNames.includes(name));
2190
+ finish({ status: missing.length === 0 ? "ok" : "missing_tools", toolNames, serverInfo, capabilities });
2191
+ return;
2192
+ }
2193
+ }
2194
+ });
2195
+ writeJsonRpc(child, {
2196
+ jsonrpc: "2.0",
2197
+ id: 1,
2198
+ method: "initialize",
2199
+ params: {
2200
+ protocolVersion: "2025-06-18",
2201
+ capabilities: {},
2202
+ clientInfo: { name: "haven-connect-probe", version: "0.0.0" }
2203
+ }
2204
+ });
2205
+ });
1943
2206
  }
1944
- async function assertFileExists(path, label) {
2207
+ function writeJsonRpc(child, payload) {
2208
+ child.stdin?.write(`${JSON.stringify(payload)}
2209
+ `);
2210
+ }
2211
+ function parseJsonRpcPayload(raw) {
2212
+ const trimmed = raw.trim();
2213
+ if (!trimmed) return null;
2214
+ if (trimmed.startsWith("{")) {
2215
+ try {
2216
+ return JSON.parse(trimmed);
2217
+ } catch {
2218
+ return null;
2219
+ }
2220
+ }
2221
+ const dataLines = trimmed.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim());
2222
+ for (let i = dataLines.length - 1; i >= 0; i -= 1) {
2223
+ try {
2224
+ return JSON.parse(dataLines[i]);
2225
+ } catch {
2226
+ }
2227
+ }
2228
+ return null;
2229
+ }
2230
+ async function fetchWithTimeout(fetchImpl, url, init) {
2231
+ const controller = new AbortController();
2232
+ const timeout = setTimeout(() => controller.abort(), 3e3);
1945
2233
  try {
1946
- await access(path);
1947
- } catch {
1948
- throw new Error(`Missing ${label}: ${path}`);
2234
+ return await fetchImpl(url, { ...init, signal: controller.signal });
2235
+ } finally {
2236
+ clearTimeout(timeout);
1949
2237
  }
1950
2238
  }
1951
- var execFileAsync, SIGNER_INSTALL_TIMEOUT_MS, SIGNER_INSTALL_HEARTBEAT_MS;
1952
- var init_signer_runtime = __esm({
1953
- "src/signer-runtime.ts"() {
1954
- init_runtime_manifest();
1955
- init_runtime_spec_override();
1956
- execFileAsync = promisify(execFile);
1957
- SIGNER_INSTALL_TIMEOUT_MS = 6e5;
1958
- SIGNER_INSTALL_HEARTBEAT_MS = 15e3;
2239
+ var init_probes = __esm({
2240
+ "src/probes.ts"() {
1959
2241
  }
1960
2242
  });
1961
2243
  async function prepareLocalMcpRuntime(input, deps = {}) {
@@ -2265,33 +2547,11 @@ function hermesHome(deps) {
2265
2547
  const env = deps.env ?? process.env;
2266
2548
  return env.HERMES_HOME ?? join(deps.homeDir ?? homedir(), ".hermes");
2267
2549
  }
2268
- var CODEX_AGENTS_BEGIN_MARKER, CODEX_AGENTS_END_MARKER;
2269
- var init_skill_install = __esm({
2270
- "src/skill-install.ts"() {
2271
- CODEX_AGENTS_BEGIN_MARKER = "<!-- BEGIN haven-pay (managed by @haven_ai/connect; edits inside this section are overwritten on re-setup) -->";
2272
- CODEX_AGENTS_END_MARKER = "<!-- END haven-pay -->";
2273
- }
2274
- });
2275
-
2276
- // src/connect-error.ts
2277
- function isConnectError(err) {
2278
- return err instanceof ConnectError;
2279
- }
2280
- var ConnectError;
2281
- var init_connect_error = __esm({
2282
- "src/connect-error.ts"() {
2283
- ConnectError = class extends Error {
2284
- code;
2285
- nextAction;
2286
- details;
2287
- constructor(code, message, nextAction2, details = {}) {
2288
- super(message);
2289
- this.name = "ConnectError";
2290
- this.code = code;
2291
- this.nextAction = nextAction2;
2292
- this.details = details;
2293
- }
2294
- };
2550
+ var CODEX_AGENTS_BEGIN_MARKER, CODEX_AGENTS_END_MARKER;
2551
+ var init_skill_install = __esm({
2552
+ "src/skill-install.ts"() {
2553
+ CODEX_AGENTS_BEGIN_MARKER = "<!-- BEGIN haven-pay (managed by @haven_ai/connect; edits inside this section are overwritten on re-setup) -->";
2554
+ CODEX_AGENTS_END_MARKER = "<!-- END haven-pay -->";
2295
2555
  }
2296
2556
  });
2297
2557
 
@@ -2996,9 +3256,23 @@ function installedClientTargets(homeDir = homedir(), cwd = process.cwd(), env =
2996
3256
  runtime: "claude-code",
2997
3257
  label: "Claude Code",
2998
3258
  // Claude Code is configured through its own CLI (`claude mcp add-json`),
2999
- // not by writing a file this module owns — so its evidence is the
3000
- // client directory, never a config path.
3259
+ // not by writing a file this module owns — so `configPath` stays null
3260
+ // and the write still goes through the CLI. But a user-scope
3261
+ // `~/.claude.json` carrying an `mcpServers` key IS config-file
3262
+ // evidence: the user has already pointed Claude Code at some MCP
3263
+ // server. It is not the file the connector would write, hence the
3264
+ // separate evidence path (#3732): without it, a Codex config won by
3265
+ // construction on any machine where the user runs Claude Code.
3001
3266
  configPath: null,
3267
+ evidencePath: join(homeDir, ".claude.json"),
3268
+ evidenceMatches: (contents) => {
3269
+ try {
3270
+ const parsed = JSON.parse(contents);
3271
+ return typeof parsed === "object" && parsed !== null && "mcpServers" in parsed;
3272
+ } catch {
3273
+ return false;
3274
+ }
3275
+ },
3002
3276
  markers: [join(homeDir, ".claude"), join(homeDir, ".claude.json")]
3003
3277
  },
3004
3278
  {
@@ -3030,7 +3304,10 @@ function installedClientTargets(homeDir = homedir(), cwd = process.cwd(), env =
3030
3304
  },
3031
3305
  {
3032
3306
  runtime: "claude-desktop",
3033
- label: "Claude Desktop",
3307
+ // The chat app, a separate runtime from Claude Code. Named so a user in
3308
+ // the desktop app's Code tab cannot read this row as "where I am" —
3309
+ // it sorted above Claude Code whenever both had config (#3732).
3310
+ label: "Claude Desktop (chat app)",
3034
3311
  configPath: runtimeConfigPathFor("claude-desktop", homeDir),
3035
3312
  markers: []
3036
3313
  },
@@ -3045,6 +3322,7 @@ function installedClientTargets(homeDir = homedir(), cwd = process.cwd(), env =
3045
3322
  }
3046
3323
  async function scanInstalledClients(options = {}) {
3047
3324
  const exists = options.exists ?? pathExists;
3325
+ const readFile15 = options.readFile ?? ((path) => readFile(path, "utf8"));
3048
3326
  const targets = installedClientTargets(options.homeDir, options.cwd, options.env ?? process.env);
3049
3327
  const found = [];
3050
3328
  for (const target of targets) {
@@ -3054,6 +3332,18 @@ async function scanInstalledClients(options = {}) {
3054
3332
  label: target.label,
3055
3333
  detail: `MCP config found at ${target.configPath}`,
3056
3334
  configPath: target.configPath,
3335
+ evidencePath: target.configPath,
3336
+ evidence: "config-file"
3337
+ });
3338
+ continue;
3339
+ }
3340
+ if (target.evidencePath && await hasConfigEvidence(target, exists, readFile15)) {
3341
+ found.push({
3342
+ runtime: target.runtime,
3343
+ label: target.label,
3344
+ detail: `MCP config found at ${target.evidencePath}`,
3345
+ configPath: target.configPath,
3346
+ evidencePath: target.evidencePath,
3057
3347
  evidence: "config-file"
3058
3348
  });
3059
3349
  continue;
@@ -3065,6 +3355,7 @@ async function scanInstalledClients(options = {}) {
3065
3355
  label: target.label,
3066
3356
  detail: `installed (${marker})`,
3067
3357
  configPath: target.configPath,
3358
+ evidencePath: null,
3068
3359
  evidence: "client-directory"
3069
3360
  });
3070
3361
  break;
@@ -3075,6 +3366,16 @@ async function scanInstalledClients(options = {}) {
3075
3366
  return SCAN_ORDER.indexOf(a.runtime) - SCAN_ORDER.indexOf(b.runtime);
3076
3367
  });
3077
3368
  }
3369
+ async function hasConfigEvidence(target, exists, readFile15) {
3370
+ const path = target.evidencePath;
3371
+ if (!await exists(path)) return false;
3372
+ if (!target.evidenceMatches) return true;
3373
+ try {
3374
+ return target.evidenceMatches(await readFile15(path));
3375
+ } catch {
3376
+ return false;
3377
+ }
3378
+ }
3078
3379
  function installedClientHint(candidates) {
3079
3380
  const installedClients = candidates.map((candidate) => candidate.runtime);
3080
3381
  if (candidates.length === 1) {
@@ -3085,18 +3386,24 @@ function installedClientHint(candidates) {
3085
3386
  }
3086
3387
  async function promptForInstalledClient(candidates, io = defaultPromptIo()) {
3087
3388
  if (candidates.length === 0) throw noInstalledClientsError();
3389
+ const hint = installedClientHint(candidates);
3088
3390
  io.write("Haven could not detect which agent runtime this is.\n");
3089
3391
  io.write("These agent clients are installed on this machine:\n");
3090
3392
  candidates.forEach((candidate, index) => {
3091
- io.write(` ${index + 1}) ${candidate.label} \u2014 ${candidate.detail}
3393
+ const suggested = hint.suggestedRuntime === candidate.runtime ? " (suggested)" : "";
3394
+ io.write(` ${index + 1}) ${candidate.label}${suggested} \u2014 ${candidate.detail}
3092
3395
  `);
3093
3396
  });
3094
3397
  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");
3095
3398
  for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt += 1) {
3096
- const answer = await io.question(`Which one? [1-${candidates.length}] (default 1 \u2014 ${candidates[0].label}): `);
3399
+ const answer = await io.question(`Which one? [1-${candidates.length}]: `);
3097
3400
  if (answer === null) throw promptAbortedError("the prompt was cancelled");
3098
3401
  const trimmed = answer.trim();
3099
- if (trimmed === "") return candidates[0].runtime;
3402
+ if (trimmed === "") {
3403
+ io.write(`Type the number of the client to configure (1-${candidates.length}).
3404
+ `);
3405
+ continue;
3406
+ }
3100
3407
  const picked = Number.parseInt(trimmed, 10);
3101
3408
  if (Number.isInteger(picked) && picked >= 1 && picked <= candidates.length) {
3102
3409
  return candidates[picked - 1].runtime;
@@ -3169,138 +3476,6 @@ var init_installed_clients = __esm({
3169
3476
  MAX_PROMPT_ATTEMPTS = 3;
3170
3477
  }
3171
3478
  });
3172
-
3173
- // src/tombstone.ts
3174
- var tombstone_exports = {};
3175
- __export(tombstone_exports, {
3176
- ROOT_TOMBSTONES_DIRNAME: () => ROOT_TOMBSTONES_DIRNAME,
3177
- TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
3178
- TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
3179
- defaultTombstonesDir: () => defaultTombstonesDir,
3180
- readAgentTombstone: () => readAgentTombstone,
3181
- readTombstoneRecords: () => readTombstoneRecords,
3182
- tombstonesDirForAgentDirectory: () => tombstonesDirForAgentDirectory,
3183
- tombstonesDirForCredentialRoot: () => tombstonesDirForCredentialRoot,
3184
- writeAgentTombstone: () => writeAgentTombstone
3185
- });
3186
- function defaultTombstonesDir(baseDir) {
3187
- return join(baseDir ?? join(homedir(), ".haven"), "tombstones");
3188
- }
3189
- function tombstonesDirForCredentialRoot(credentialRoot, homeDir = homedir()) {
3190
- if (!credentialRoot) return defaultTombstonesDir(join(homeDir, ".haven"));
3191
- const root = resolve(credentialRoot);
3192
- if (root === resolve(homeDir, ".haven", "agents")) return defaultTombstonesDir(join(homeDir, ".haven"));
3193
- return join(root, ROOT_TOMBSTONES_DIRNAME);
3194
- }
3195
- function tombstonesDirForAgentDirectory(directory, homeDir = homedir()) {
3196
- return tombstonesDirForCredentialRoot(dirname(resolve(directory)), homeDir);
3197
- }
3198
- async function readTombstoneRecords(tombstonesDir) {
3199
- const root = tombstonesDir ?? defaultTombstonesDir();
3200
- let entries = [];
3201
- try {
3202
- entries = await readdir(root);
3203
- } catch {
3204
- return [];
3205
- }
3206
- const records = [];
3207
- for (const entry of entries) {
3208
- if (!entry.endsWith(".json")) continue;
3209
- const recordPath = join(root, entry);
3210
- try {
3211
- const parsed = JSON.parse(await readFile(recordPath, "utf8"));
3212
- if (typeof parsed?.agent_id === "string") {
3213
- records.push({ ...parsed, recordPath });
3214
- }
3215
- } catch {
3216
- }
3217
- }
3218
- return records;
3219
- }
3220
- function tombstoneScript(info) {
3221
- const lines = [
3222
- `${TOMBSTONE_MARKER}: this Haven agent was retired.`,
3223
- "",
3224
- ` agent: ${info.agent_id}`,
3225
- ` retired at: ${info.retired_at}`,
3226
- ` reason: ${info.reason}`,
3227
- ...info.replaced_by ? [` replaced by: ${info.replaced_by}`] : [],
3228
- "",
3229
- "This process is running with a wiring snapshot that predates the",
3230
- "retirement \u2014 it loaded its MCP config at startup and has kept it since.",
3231
- "Restart THIS host to pick up the current wiring. If several long-lived",
3232
- "hosts are running (a gateway, a TUI worker, an editor), restart EVERY",
3233
- "one of them: each holds the snapshot from its own start time, so after",
3234
- "a chain of recreations each can be parked on a DIFFERENT old agent.",
3235
- "",
3236
- `Then verify with: ${connectorRerunCommand("--doctor --runtime <runtime>")}`
3237
- ];
3238
- return [
3239
- "#!/usr/bin/env node",
3240
- `// ${TOMBSTONE_MARKER} \u2014 written by @haven_ai/connect (#1681). Safe to delete`,
3241
- "// once every long-lived MCP host on this machine has been restarted.",
3242
- `process.stderr.write(${JSON.stringify(lines.join("\n") + "\n")})`,
3243
- "process.exit(1)",
3244
- ""
3245
- ].join("\n");
3246
- }
3247
- async function writeAgentTombstone(input) {
3248
- const dirStat = await stat(input.directory).catch(() => null);
3249
- if (!dirStat?.isDirectory()) {
3250
- throw new ConnectError(
3251
- "tombstone_directory_not_found",
3252
- `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.`,
3253
- "retry_with_an_existing_agent_directory"
3254
- );
3255
- }
3256
- const info = {
3257
- // reason / replaced_by are persisted to disk and re-emitted to the host's
3258
- // MCP stderr log on EVERY stale probe, potentially for months — redact
3259
- // like every other output path, at the write layer so any future caller
3260
- // inherits it. (#1681 review, finding 1)
3261
- agent_id: input.agentId,
3262
- retired_at: input.retiredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3263
- reason: redactSecrets(input.reason),
3264
- ...input.replacedBy ? { replaced_by: redactSecrets(input.replacedBy) } : {}
3265
- };
3266
- const binDir = join(input.directory, "bin");
3267
- await mkdir(binDir, { recursive: true });
3268
- const wrapperPath = join(binDir, "haven-signer.mjs");
3269
- await writeFile(wrapperPath, tombstoneScript(info), "utf8");
3270
- await chmod(wrapperPath, 493);
3271
- const record = JSON.stringify(info, null, 2) + "\n";
3272
- await writeFile(join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
3273
- const root = input.tombstonesDir ?? defaultTombstonesDir();
3274
- const recordPath = join(root, `${info.agent_id}.json`);
3275
- try {
3276
- await mkdir(root, { recursive: true, mode: 448 });
3277
- await writeFile(recordPath, record, { mode: MIRROR_MODE });
3278
- } catch (err) {
3279
- const code = err?.code;
3280
- return { ...info, recordPath: null, mirrorError: typeof code === "string" && /^E[A-Z]+$/.test(code) ? code : "mirror_write_failed" };
3281
- }
3282
- return { ...info, recordPath };
3283
- }
3284
- async function readAgentTombstone(directory) {
3285
- try {
3286
- const parsed = JSON.parse(await readFile(join(directory, TOMBSTONE_FILENAME), "utf8"));
3287
- if (typeof parsed?.agent_id !== "string") return null;
3288
- return parsed;
3289
- } catch {
3290
- return null;
3291
- }
3292
- }
3293
- var TOMBSTONE_FILENAME, ROOT_TOMBSTONES_DIRNAME, MIRROR_MODE, TOMBSTONE_MARKER;
3294
- var init_tombstone = __esm({
3295
- "src/tombstone.ts"() {
3296
- init_redact();
3297
- init_connect_error();
3298
- TOMBSTONE_FILENAME = "TOMBSTONE.json";
3299
- ROOT_TOMBSTONES_DIRNAME = ".tombstones";
3300
- MIRROR_MODE = 384;
3301
- TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
3302
- }
3303
- });
3304
3479
  async function detectWiringCollision(input) {
3305
3480
  const root = defaultCredentialRoot(input.credentialsDir);
3306
3481
  let entries = [];
@@ -3338,17 +3513,45 @@ async function detectWiringCollision(input) {
3338
3513
  suggestedServerName: proposeServerSlug(input.agentName, taken)
3339
3514
  };
3340
3515
  }
3341
- function proposeServerSlug(agentName, taken) {
3342
- let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
3516
+ function proposeServerSlug(agentName, taken, maxBaseLength = 32) {
3517
+ let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, maxBaseLength).replace(/-+$/g, "");
3343
3518
  if (!slugIsValid(base)) base = "agent";
3344
3519
  if (!taken.has(base)) return base;
3345
3520
  for (let n = 2; n < 1e3; n += 1) {
3346
3521
  const suffix = `-${n}`;
3347
- const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
3522
+ const candidate = `${base.slice(0, maxBaseLength - suffix.length).replace(/-+$/g, "")}${suffix}`;
3348
3523
  if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
3349
3524
  }
3350
3525
  throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
3351
3526
  }
3527
+ function deriveServerSlug(agentName, taken, markNonProduction) {
3528
+ const mark = "-dev";
3529
+ if (!markNonProduction) return proposeServerSlug(agentName, taken);
3530
+ const base = proposeServerSlug(agentName, taken, 32 - mark.length);
3531
+ if (base.endsWith(mark)) return base;
3532
+ const marked = `${base}${mark}`;
3533
+ if (!taken.has(marked)) return marked;
3534
+ return proposeServerSlug(marked, taken);
3535
+ }
3536
+ async function listTakenDirectoryNames(credentialsDir) {
3537
+ const root = defaultCredentialRoot(credentialsDir);
3538
+ let entries = [];
3539
+ try {
3540
+ entries = await readdir(root);
3541
+ } catch {
3542
+ return /* @__PURE__ */ new Set();
3543
+ }
3544
+ const taken = /* @__PURE__ */ new Set();
3545
+ for (const entry of entries) {
3546
+ try {
3547
+ if (!(await stat(join(root, entry))).isDirectory()) continue;
3548
+ } catch {
3549
+ continue;
3550
+ }
3551
+ taken.add(entry);
3552
+ }
3553
+ return taken;
3554
+ }
3352
3555
  function slugIsValid(slug) {
3353
3556
  try {
3354
3557
  assertValidServerSlug(slug);
@@ -3364,7 +3567,7 @@ async function promptWiringCollisionResolution(collision, agentName, io) {
3364
3567
  io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
3365
3568
  `);
3366
3569
  io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
3367
- io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
3570
+ io.write(" (tombstoned, local key files removed; you still use Remove agent\u2026 on the Haven agent page)\n");
3368
3571
  io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
3369
3572
  `);
3370
3573
  io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
@@ -3587,7 +3790,7 @@ async function decideTeardown(identity, input) {
3587
3790
  status: "retained",
3588
3791
  probe: "ok",
3589
3792
  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.",
3590
- 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}.`
3793
+ 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}.`
3591
3794
  };
3592
3795
  case "unauthorized":
3593
3796
  return {
@@ -3748,7 +3951,16 @@ async function executeConnect(options, deps, trace) {
3748
3951
  log("Checked local credential storage \u2014 all clear.");
3749
3952
  let serverName = options.serverName;
3750
3953
  let replacing;
3751
- if (!serverName) {
3954
+ if (!serverName && options.deriveServerName) {
3955
+ const taken = await listTakenDirectoryNames(options.credentialsDir);
3956
+ const nonProduction = setup.haven_wallet.chain_id !== PRODUCTION_CHAIN_ID;
3957
+ serverName = deriveServerSlug(setup.agent.name, taken, nonProduction);
3958
+ await assertServerSlugAvailable(serverName, options.credentialsDir);
3959
+ const derivedNames = serverNamesFor(serverName);
3960
+ log(
3961
+ `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>.`
3962
+ );
3963
+ } else if (!serverName) {
3752
3964
  const collision = await detectWiringCollision({
3753
3965
  credentialsDir: options.credentialsDir,
3754
3966
  agentName: setup.agent.name
@@ -3974,15 +4186,15 @@ async function executeConnect(options, deps, trace) {
3974
4186
  if (supersededAgentsRetiredLocally === true) {
3975
4187
  log("");
3976
4188
  log(
3977
- `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.`
4189
+ `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.`
3978
4190
  );
3979
4191
  } else if (supersededAgentIds.length > 0) {
3980
4192
  log("");
3981
4193
  log(
3982
- `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.`
4194
+ `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.`
3983
4195
  );
3984
4196
  log(
3985
- `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.`
4197
+ `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.`
3986
4198
  );
3987
4199
  }
3988
4200
  } catch {
@@ -4013,6 +4225,18 @@ async function executeConnect(options, deps, trace) {
4013
4225
  } catch (err) {
4014
4226
  log(`Could not report install status to Haven: ${err instanceof Error ? err.message : String(err)}`);
4015
4227
  }
4228
+ const reportableRetiredIds = [...new Set(retiredAgentIds.filter((id) => UUID_SHAPE.test(id)))];
4229
+ if (reportableRetiredIds.length > 0 && api.reportRetiredAgents) {
4230
+ try {
4231
+ await api.reportRetiredAgents({
4232
+ setupId: registration.setup_id,
4233
+ apiKey: localApiKey,
4234
+ retiredAgentIds: reportableRetiredIds
4235
+ });
4236
+ } catch (err) {
4237
+ log(`Could not tell Haven which agents were retired: ${err instanceof Error ? err.message : String(err)}`);
4238
+ }
4239
+ }
4016
4240
  let approval;
4017
4241
  const narrateApprovalWait = options.waitForApproval === true || options.waitForApproval === void 0 && (deps.isStdoutTty ?? Boolean(process.stdout.isTTY));
4018
4242
  if (narrateApprovalWait && !runtimeInstall.errorCode) {
@@ -4023,12 +4247,29 @@ async function executeConnect(options, deps, trace) {
4023
4247
  runtimeInstall,
4024
4248
  delegateAddress: registration.delegate_address,
4025
4249
  hostedMcpUrl: registration.hosted_mcp_url,
4250
+ // #3737: the resolved name — derived, explicit, or the bare pair — rides
4251
+ // the machine-readable outcome alongside the prose completion text.
4252
+ serverName,
4026
4253
  supersededAgentIds,
4027
4254
  supersededAgentsRetiredLocally,
4028
4255
  ...replacing ? { retiredAgentIds } : {},
4029
4256
  ...Object.keys(retirementMirrorErrors).length > 0 ? { retirementMirrorErrors } : {},
4030
4257
  existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
4031
4258
  ...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
4259
+ // #3772: only when the install completed. An install that ended with an
4260
+ // error code may not have written the wiring, and on --replace it leaves the
4261
+ // old agent's wiring as possibly the only working one (retirement is skipped
4262
+ // on the same condition) — so it gets no such sentence. That includes
4263
+ // manual_runtime_setup_required, deliberately: the connector wrote nothing
4264
+ // and the manual instruction already says to start a fresh session. A
4265
+ // --replace names every displaced agent even when no binding record exists
4266
+ // (pre-0.4.0 wiring, or a failed best-effort write).
4267
+ ...!runtimeInstall.errorCode && (replacing || reboundFrom) ? {
4268
+ staleSession: {
4269
+ agentIds: [.../* @__PURE__ */ new Set([...replacing?.superseded.map((entry) => entry.agentId) ?? [], ...reboundFrom ? [reboundFrom.agent_id] : []])],
4270
+ ...reboundFrom?.backend_changed ? { otherBackendAgentId: reboundFrom.agent_id } : {}
4271
+ }
4272
+ } : {},
4032
4273
  setupChallengeExpiresAt: setup.challenge.expires_at,
4033
4274
  approvalRequired: registration.agent_status === "pending_approval",
4034
4275
  approvalUrl: registration.approval_url
@@ -4044,6 +4285,10 @@ async function executeConnect(options, deps, trace) {
4044
4285
  outcome
4045
4286
  };
4046
4287
  }
4288
+ function staleSessionNotice(previousAgentIds, otherBackendAgentId) {
4289
+ const one = previousAgentIds.length === 1;
4290
+ 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).`;
4291
+ }
4047
4292
  function completionOutcome(input) {
4048
4293
  const { runtimeInstall } = input;
4049
4294
  const manualSetup = runtimeInstall.errorCode === "manual_runtime_setup_required";
@@ -4061,7 +4306,10 @@ function completionOutcome(input) {
4061
4306
  probe: { result: runtimeInstall.probeResult },
4062
4307
  activation: {
4063
4308
  restart_required: runtimeInstall.restartRequired,
4064
- 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
4309
+ 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
4310
+ // session that is already running keeps the config it loaded — it
4311
+ // goes on acting as the previous agent, which Connect never revokes.
4312
+ (input.staleSession && input.staleSession.agentIds.length > 0 ? ` ${staleSessionNotice(input.staleSession.agentIds, input.staleSession.otherBackendAgentId)}` : "")
4065
4313
  },
4066
4314
  next_action: nextAction2,
4067
4315
  approval: {
@@ -4097,6 +4345,9 @@ function completionOutcome(input) {
4097
4345
  // #3122: always emitted on a completed run (empty list included), for the
4098
4346
  // same reason as superseded_agent_ids above.
4099
4347
  existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
4348
+ // #3737: the resolved hosted server name — always present on a completed
4349
+ // run, bare pair included, so a caller never has to parse the prose.
4350
+ server_name: serverNamesFor(input.serverName).hosted,
4100
4351
  ...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
4101
4352
  ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4102
4353
  ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
@@ -4120,7 +4371,8 @@ function installedClientProse(hint) {
4120
4371
  }
4121
4372
  function runtimeSelectionPrompt(options, deps) {
4122
4373
  if (!interactivePromptAllowed(options, deps)) return void 0;
4123
- return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
4374
+ if (deps.promptRuntime) return deps.promptRuntime;
4375
+ return () => (deps.promptRuntimeByInstalledClient ?? resolveRuntimeByInstalledClientPrompt)({ env: deps.env });
4124
4376
  }
4125
4377
  function interactivePromptAllowed(options, deps) {
4126
4378
  if (options.interactive !== true) return false;
@@ -4146,7 +4398,7 @@ async function resolveWiringCollision(collision, agentName, options, deps) {
4146
4398
  }
4147
4399
  throw new ConnectError(
4148
4400
  "wiring_collision",
4149
- `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.`,
4401
+ `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.`,
4150
4402
  "relay_wiring_collision_to_user",
4151
4403
  { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4152
4404
  );
@@ -4427,7 +4679,7 @@ async function listOtherAgentIds(baseDir, currentDirectory) {
4427
4679
  function printNextSteps(result, log, approval, approvalUrl) {
4428
4680
  for (const line of completionHandoffLines(result, approval, approvalUrl)) log(line);
4429
4681
  }
4430
- var CONNECTOR_VERSION, CONNECTOR_CLIENT_IDENTITY, CONNECT_OUTCOME_SCHEMA_VERSION, failureOutcomesByError, RERUN_HINT;
4682
+ var CONNECTOR_VERSION, PRODUCTION_CHAIN_ID, UUID_SHAPE, CONNECTOR_CLIENT_IDENTITY, CONNECT_OUTCOME_SCHEMA_VERSION, failureOutcomesByError, RERUN_HINT;
4431
4683
  var init_runtime = __esm({
4432
4684
  "src/runtime.ts"() {
4433
4685
  init_api();
@@ -4444,7 +4696,9 @@ var init_runtime = __esm({
4444
4696
  init_tombstone();
4445
4697
  init_local_mcp_runtime();
4446
4698
  init_runtime_manifest();
4447
- CONNECTOR_VERSION = "0.8.1-alpha.0";
4699
+ CONNECTOR_VERSION = "0.10.0-alpha.0";
4700
+ PRODUCTION_CHAIN_ID = 8453;
4701
+ UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4448
4702
  CONNECTOR_CLIENT_IDENTITY = `@haven_ai/connect/${CONNECTOR_VERSION}`;
4449
4703
  CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4450
4704
  failureOutcomesByError = /* @__PURE__ */ new WeakMap();
@@ -4654,9 +4908,10 @@ async function startRekey(options, deps = {}) {
4654
4908
  started_at: startedAt,
4655
4909
  expires_at: expiresAt
4656
4910
  });
4911
+ const finishName = options.serverName ?? stored.discoveredSlug;
4657
4912
  const finishCommand = [
4658
4913
  connectorRerunCommand("--rekey-finish"),
4659
- options.serverName ? `--name ${options.serverName}` : void 0,
4914
+ finishName ? `--name ${finishName}` : void 0,
4660
4915
  "--api-key <the key the dashboard showed you>",
4661
4916
  options.runtime ? `--runtime ${options.runtime}` : "--runtime <your runtime>"
4662
4917
  ].filter(Boolean).join(" ");
@@ -4924,6 +5179,40 @@ function rollUpLevel(checks) {
4924
5179
  if (checks.some((check) => check.level === "advisory")) return "advisory";
4925
5180
  return "ok";
4926
5181
  }
5182
+ function runWrapperAck(wrapperPath, timeoutMs = 3e4) {
5183
+ return new Promise((resolve11, reject) => {
5184
+ const child = spawn(wrapperPath, ["--ack"], { stdio: ["ignore", "pipe", "pipe"] });
5185
+ let stdout = "";
5186
+ let settled = false;
5187
+ const timer = setTimeout(() => {
5188
+ if (settled) return;
5189
+ settled = true;
5190
+ child.kill();
5191
+ reject(new Error(`signer --ack timed out after ${Math.round(timeoutMs / 1e3)}s`));
5192
+ }, timeoutMs);
5193
+ child.stdout?.on("data", (chunk) => {
5194
+ stdout += chunk.toString("utf8");
5195
+ });
5196
+ child.on("error", (err) => {
5197
+ if (settled) return;
5198
+ settled = true;
5199
+ clearTimeout(timer);
5200
+ reject(err);
5201
+ });
5202
+ child.on("close", (code) => {
5203
+ if (settled) return;
5204
+ settled = true;
5205
+ clearTimeout(timer);
5206
+ if (code === 0) resolve11({ stdout });
5207
+ else reject(new Error(`signer --ack exited ${code ?? "abnormally"}`));
5208
+ });
5209
+ });
5210
+ }
5211
+ function repairCommandNamedFor(runtime, slug) {
5212
+ const nameFlag = ` --name ${slug}`;
5213
+ if (normalizeRuntimeName(runtime)) return `Run: ${RERUN} --doctor --repair --runtime ${runtime}${nameFlag}`;
5214
+ return `Run: ${RERUN} --doctor --repair --runtime <runtime>${nameFlag} \u2014 --repair needs the runtime named; one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`;
5215
+ }
4927
5216
  function runtimeFlagFor(runtime) {
4928
5217
  return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
4929
5218
  }
@@ -5005,6 +5294,35 @@ async function discoverCredentialDirectory(homeDir, explicit) {
5005
5294
  identityDirectories
5006
5295
  };
5007
5296
  }
5297
+ async function readClaudeCodeWiringEvidence(homeDir) {
5298
+ let parsed;
5299
+ try {
5300
+ parsed = JSON.parse(await readFile(join(homeDir, ".claude.json"), "utf8"));
5301
+ } catch {
5302
+ return null;
5303
+ }
5304
+ if (typeof parsed !== "object" || parsed === null) return null;
5305
+ const blocks = [parsed.mcpServers];
5306
+ const projects = parsed.projects;
5307
+ if (typeof projects === "object" && projects !== null) {
5308
+ for (const project of Object.values(projects)) {
5309
+ if (typeof project === "object" && project !== null) blocks.push(project.mcpServers);
5310
+ }
5311
+ }
5312
+ const lines = [];
5313
+ for (const block of blocks) {
5314
+ if (typeof block !== "object" || block === null) continue;
5315
+ for (const [name, server] of Object.entries(block)) {
5316
+ const entry = typeof server === "object" && server !== null ? server : {};
5317
+ const parts = [JSON.stringify(name) + ":"];
5318
+ if (typeof entry.command === "string") parts.push(entry.command);
5319
+ if (Array.isArray(entry.args)) parts.push(...entry.args.filter((arg) => typeof arg === "string"));
5320
+ if (typeof entry.url === "string") parts.push(entry.url);
5321
+ lines.push(parts.join(" "));
5322
+ }
5323
+ }
5324
+ return lines.length > 0 ? lines.join("\n") : null;
5325
+ }
5008
5326
  function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bareOwnerExists) {
5009
5327
  if (configText === null) return isPrimary;
5010
5328
  if (slug) {
@@ -5250,8 +5568,11 @@ async function verdictsForAgent(entry, input, deps) {
5250
5568
  id: "signer_process",
5251
5569
  label: "Signer stdio handshake",
5252
5570
  level: "failed",
5253
- detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
5254
- repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
5571
+ // #3799: honest about what the mismatch does and does not prove. The
5572
+ // stored ack covers a tool SET; a doctor bundled with a newer signer
5573
+ // reports exactly this against an older, still-working install.
5574
+ 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.",
5575
+ 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)}.`
5255
5576
  });
5256
5577
  } else {
5257
5578
  const probe = await (deps.probeSignerTools ?? probeLocalMcpTools)(
@@ -5282,12 +5603,21 @@ async function verdictsForAgent(entry, input, deps) {
5282
5603
  }
5283
5604
  return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
5284
5605
  }
5606
+ async function resolveNamedDirectory(input, homeDir) {
5607
+ if (!input.serverName) return input.credentialsDir;
5608
+ if (input.credentialsDir) {
5609
+ await assertDirectoryRecordsSlug(input.credentialsDir, input.serverName);
5610
+ return input.credentialsDir;
5611
+ }
5612
+ return resolveAgentDirectoryBySlug(input.serverName, { root: join(homeDir, ".haven", "agents") });
5613
+ }
5285
5614
  async function runDoctor(input, deps = {}) {
5286
5615
  const homeDir = deps.homeDir ?? homedir();
5287
5616
  const checks = [];
5288
5617
  let signerCapabilities;
5289
- const { directory, others, parkedOnly, identityDirectories } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
5290
- const nameRepairDir = identityDirectories.length > 1 || Boolean(input.credentialsDir);
5618
+ const namedDir = await resolveNamedDirectory(input, homeDir);
5619
+ const { directory, others, parkedOnly, identityDirectories } = await discoverCredentialDirectory(homeDir, namedDir);
5620
+ const nameRepairDir = identityDirectories.length > 1 || Boolean(namedDir);
5291
5621
  const resolution = await resolveDoctorRuntime(input, directory);
5292
5622
  const runtime = resolution.runtime;
5293
5623
  const input2 = { ...input, runtime };
@@ -5301,6 +5631,7 @@ async function runDoctor(input, deps = {}) {
5301
5631
  configText = null;
5302
5632
  }
5303
5633
  }
5634
+ const claudeCodeWiring = configText === null && normalizedRuntime === "claude-code" ? await readClaudeCodeWiringEvidence(homeDir) : null;
5304
5635
  const allDirectories = directory ? [directory, ...others] : others;
5305
5636
  let bareOwnerExists = false;
5306
5637
  for (const dir of allDirectories) {
@@ -5334,7 +5665,10 @@ async function runDoctor(input, deps = {}) {
5334
5665
  });
5335
5666
  continue;
5336
5667
  }
5337
- const wired = agentIsWired(configText, names, slug, identity, sidecar, dir === directory, bareOwnerExists);
5668
+ const wired = agentIsWired(configText, names, slug, identity, sidecar, dir === directory, bareOwnerExists) || // Additive only: a name or wrapper path in ~/.claude.json proves this
5669
+ // directory is wired; its absence proves nothing (#3830), so the
5670
+ // fallback verdict above is never downgraded.
5671
+ claudeCodeWiring !== null && agentIsWired(claudeCodeWiring, names, slug, identity, sidecar, false, false);
5338
5672
  const entry = {
5339
5673
  ...slug ? { slug } : {},
5340
5674
  ...identity.agent_id ? { agentId: identity.agent_id } : {},
@@ -5402,15 +5736,20 @@ async function runDoctor(input, deps = {}) {
5402
5736
  id: "runtime_config",
5403
5737
  label: "Runtime MCP config",
5404
5738
  level: "failed",
5405
- 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(", ")}.`,
5739
+ 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(", ")}.`,
5406
5740
  repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
5407
5741
  });
5408
5742
  } else if (configPath === null) {
5743
+ const claudePairSuffix = input2.runtime === "claude-code" && primaryDirectory ? await (async () => {
5744
+ const sidecar = await readRuntimeSidecar(primaryDirectory);
5745
+ const names = serverNamesFor(sidecar?.server_name);
5746
+ return ` The MCP pair recorded for this folder is ${names.hosted} / ${names.signer} \u2014 confirm with \`claude mcp get ${names.signer}\`.`;
5747
+ })() : "";
5409
5748
  checks.push({
5410
5749
  id: "runtime_config",
5411
5750
  label: "Runtime MCP config",
5412
5751
  level: "ok",
5413
- 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.`
5752
+ 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}`
5414
5753
  });
5415
5754
  } else if (configText === null) {
5416
5755
  checks.push({
@@ -5484,13 +5823,17 @@ async function runDoctor(input, deps = {}) {
5484
5823
  const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
5485
5824
  checks.push({
5486
5825
  id: "superseded_agents",
5487
- label: "Superseded agent credentials",
5826
+ // #3830: an advisory means the connector could not tell wired from
5827
+ // superseded, so its label must not assert "superseded" either. The
5828
+ // inventory `classification` stays as is (#3121 decision 5); the label
5829
+ // is what a person reads.
5830
+ label: supersededLevel === "advisory" ? "Other agent credentials (wiring not verifiable)" : "Superseded agent credentials",
5488
5831
  level: supersededLevel,
5489
- 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("; ")}.`,
5832
+ 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("; ")}.`,
5490
5833
  ...supersededLevel === "failed" ? {
5491
- 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.`
5834
+ 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.`
5492
5835
  } : supersededLevel === "advisory" ? {
5493
- 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.`
5836
+ 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.`
5494
5837
  } : {}
5495
5838
  });
5496
5839
  }
@@ -5515,7 +5858,7 @@ async function runDoctor(input, deps = {}) {
5515
5858
  label: "MCP server names bound more than once",
5516
5859
  level: "advisory",
5517
5860
  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.`,
5518
- repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
5861
+ 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.`
5519
5862
  });
5520
5863
  }
5521
5864
  const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
@@ -5575,20 +5918,24 @@ async function runDoctor(input, deps = {}) {
5575
5918
  async function runRepair(input, deps = {}) {
5576
5919
  const homeDir = deps.homeDir ?? homedir();
5577
5920
  const messages = [];
5578
- const { directory, others, identityDirectories } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
5579
- if (!input.credentialsDir && identityDirectories.length > 1) {
5921
+ const namedDir = await resolveNamedDirectory(input, homeDir);
5922
+ const { directory, others, identityDirectories } = await discoverCredentialDirectory(homeDir, namedDir);
5923
+ if (!namedDir && identityDirectories.length > 1) {
5580
5924
  const lines = await Promise.all(
5581
5925
  identityDirectories.map(async (dir) => {
5582
5926
  const identity2 = await readIdentity(dir);
5583
5927
  const who = identity2?.agent_id ? `agent ${identity2.agent_id}` : "agent (no agent_id recorded)";
5584
5928
  const retired = !identity2?.api_key || await pathExists3(join(dir, TOMBSTONE_FILENAME));
5585
- return retired ? ` \u2022 ${who} (${dir}): retired \u2014 not a repair target` : ` \u2022 ${who}: ${repairCommandFor(input.runtime, "", dir).replace(/^Run: /, "")}`;
5929
+ const sidecar = await readRuntimeSidecar(dir);
5930
+ const slug = sidecar?.server_name;
5931
+ const pair = slug ? `${serverNamesFor(slug).hosted} / ${serverNamesFor(slug).signer}` : "the bare haven / haven-signer pair";
5932
+ 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: /, "")}`;
5586
5933
  })
5587
5934
  );
5588
5935
  return {
5589
5936
  ok: false,
5590
5937
  messages: [
5591
- `${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:`,
5938
+ `${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:`,
5592
5939
  ...lines,
5593
5940
  "Not sure which agent is which? Compare the agent ids against the Haven agent page, or run --doctor first."
5594
5941
  ]
@@ -5646,13 +5993,62 @@ async function runRepair(input, deps = {}) {
5646
5993
  const existingSidecar = await readRuntimeSidecar(directory);
5647
5994
  const serverName = existingSidecar?.server_name;
5648
5995
  const signerPath = join(directory, "signer.json");
5996
+ const consent = await getLocalSignerConsentStatus(signerPath);
5997
+ const consentMismatch = !consent.acknowledged && (consent.reason === "ack_file_missing" || consent.reason === "ack_file_mismatch");
5998
+ if (consentMismatch && !input.ackLocalTools) {
5999
+ const wrapperPath = existingSidecar?.wrapper_path ?? join(directory, "bin", "haven-signer.mjs");
6000
+ return {
6001
+ ok: false,
6002
+ messages: [
6003
+ "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.",
6004
+ "The mismatch does not prove the currently running signer is broken; a newer tool set is enough to explain it.",
6005
+ `Re-consent without a setup token, produced by the installed signer: ${wrapperPath} --ack < /dev/null`,
6006
+ `Or re-run repair with --ack-local-tools to re-consent during the repair: ${repairCommandFor(input2.runtime, " --ack-local-tools", directory)}`
6007
+ ]
6008
+ };
6009
+ }
5649
6010
  const prepared = await prepareSignerRuntime(
5650
6011
  { credentialDirectory: directory, signerPath, homeDir, serverName },
5651
6012
  { runCommand: deps.runCommand, env: deps.env }
5652
6013
  );
5653
6014
  messages.push(...prepared.messages);
5654
6015
  const names = serverNamesFor(serverName);
5655
- messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
6016
+ const rewritesConfig = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir) !== null;
6017
+ if (rewritesConfig) {
6018
+ messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
6019
+ } else {
6020
+ messages.push(
6021
+ `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.`)
6022
+ );
6023
+ }
6024
+ if (consentMismatch && input.ackLocalTools) {
6025
+ messages.push(`Re-acknowledging local-tools consent with the installed signer (${signerPackageSpec()}):`);
6026
+ try {
6027
+ const ack = await (deps.runSignerAck ?? runWrapperAck)(prepared.wrapperPath);
6028
+ for (const line of ack.stdout.split("\n")) {
6029
+ if (line.trim()) messages.push(` ${line}`);
6030
+ }
6031
+ } catch (err) {
6032
+ return {
6033
+ ok: false,
6034
+ messages: [
6035
+ ...messages,
6036
+ `The re-acknowledgement failed: ${err instanceof Error ? err.message : String(err)} \u2014 consent was NOT written; the runtime config was not rewritten.`
6037
+ ]
6038
+ };
6039
+ }
6040
+ const after = await getLocalSignerConsentStatus(signerPath);
6041
+ if (!after.acknowledged) {
6042
+ return {
6043
+ ok: false,
6044
+ messages: [
6045
+ ...messages,
6046
+ `Consent still does not match after the re-acknowledgement (${after.reason ?? after.error}). The runtime config was not rewritten.`
6047
+ ]
6048
+ };
6049
+ }
6050
+ messages.push("Local-tools consent acknowledged for the installed signer.");
6051
+ }
5656
6052
  const configResult = await writeRuntimeConfig({
5657
6053
  // Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
5658
6054
  // switches on the id, and the raw alias fell to its "manual runtime" arm
@@ -5715,6 +6111,7 @@ function parseArgs(argv, env = process.env) {
5715
6111
  let dryRun = false;
5716
6112
  let unwireDir;
5717
6113
  let replace = false;
6114
+ let bare = false;
5718
6115
  for (let i = 0; i < argv.length; i += 1) {
5719
6116
  const arg = argv[i];
5720
6117
  if (arg === "--help" || arg === "-h") {
@@ -5762,6 +6159,8 @@ function parseArgs(argv, env = process.env) {
5762
6159
  options.credentialsDir = requireValue(argv, ++i, arg);
5763
6160
  } else if (arg === "--replace") {
5764
6161
  replace = true;
6162
+ } else if (arg === "--bare") {
6163
+ bare = true;
5765
6164
  } else if (arg === "--name") {
5766
6165
  options.serverName = requireValue(argv, ++i, arg);
5767
6166
  assertValidServerSlug(options.serverName);
@@ -5787,16 +6186,23 @@ function parseArgs(argv, env = process.env) {
5787
6186
  if (help) {
5788
6187
  return { options, help, json, doctor, repair, tombstone, rekey };
5789
6188
  }
5790
- if (replace) {
6189
+ if (replace || bare) {
5791
6190
  if (rekeyPhase || tombstoneDir || unwire || doctor || repair || pruneSignerRuntimes2) {
5792
- 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.");
6191
+ 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.`);
5793
6192
  }
5794
- if (options.serverName) {
6193
+ if (bare && options.serverName) {
5795
6194
  throw new Error(
5796
- "--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."
6195
+ "--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."
5797
6196
  );
5798
6197
  }
5799
- options.replaceExistingWiring = true;
6198
+ if (replace) {
6199
+ if (options.serverName) {
6200
+ throw new Error(
6201
+ "--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
+ );
6203
+ }
6204
+ options.replaceExistingWiring = true;
6205
+ }
5800
6206
  }
5801
6207
  if (destroyKeyMaterial && !unwire) {
5802
6208
  throw new Error("--destroy-key-material only applies to --unwire.");
@@ -5862,6 +6268,11 @@ function parseArgs(argv, env = process.env) {
5862
6268
  "--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."
5863
6269
  );
5864
6270
  }
6271
+ if (options.ackLocalTools && !repair) {
6272
+ throw new Error(
6273
+ "--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"
6274
+ );
6275
+ }
5865
6276
  return { options, help, json, doctor, repair, tombstone, rekey };
5866
6277
  }
5867
6278
  if (!options.setupToken) {
@@ -5870,6 +6281,9 @@ function parseArgs(argv, env = process.env) {
5870
6281
  if (!options.apiBaseUrl) {
5871
6282
  throw new Error("Missing --api <Haven API URL>.");
5872
6283
  }
6284
+ if (!options.serverName && !bare && !replace) {
6285
+ options.deriveServerName = true;
6286
+ }
5873
6287
  options.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, "");
5874
6288
  return { options, help, json, doctor, repair, tombstone, rekey };
5875
6289
  }
@@ -5897,14 +6311,22 @@ function helpText() {
5897
6311
  " --replace When this machine is already wired to a different Haven agent on the bare",
5898
6312
  " haven / haven-signer pair, re-point that pair at the new agent and retire the",
5899
6313
  " previous agent directory locally (tombstoned, local key files removed).",
5900
- " Nothing is revoked \u2014 revoke the old agent on the Haven agent page. Without",
5901
- " this flag a non-interactive run REFUSES such a collision (wiring_collision)",
5902
- " and an interactive terminal is asked; --name installs alongside instead.",
6314
+ " Nothing is revoked \u2014 use Remove agent\u2026 on the Haven agent page. Implies",
6315
+ " the bare pair. Without it (or --bare) a non-interactive run REFUSES such a",
6316
+ " collision (wiring_collision) and an interactive terminal is asked.",
6317
+ " --bare Wire the bare haven / haven-signer pair. Without --name a setup now names",
6318
+ " its pair from the agent's display name (haven-<slug> /",
6319
+ " haven-signer-<slug>, de-collided against this machine, ending -dev on a",
6320
+ " non-production backend), so adding an agent never has to displace anyone;",
6321
+ " --bare is the explicit opt-in to the unnamed pair. --name overrides it.",
5903
6322
  " --name <slug> Wiring slug for a NAMED agent: writes haven-<slug> / haven-signer-<slug>",
5904
6323
  " MCP entries and stores credentials at ~/.haven/agents/<slug>/, so several",
5905
6324
  " agents can run side by side in one runtime. 1-32 lowercase letters, digits,",
5906
- " single hyphens; immutable once wired. Omit for the bare haven / haven-signer pair.",
5907
- " --ack-local-tools Write the one-time local Haven tools acknowledgement during setup.",
6325
+ " single hyphens; immutable once wired. Without it the pair is named from the",
6326
+ " agent's display name; --bare wires the bare haven / haven-signer pair.",
6327
+ " --ack-local-tools Write the local Haven tools acknowledgement: during setup, or (with --repair)",
6328
+ " re-acknowledge consent for the signer the repair installs \u2014 no setup token.",
6329
+ " With a bare --doctor it is refused: --doctor is read-only.",
5908
6330
  " --ack-signer Backward-compatible alias for --ack-local-tools.",
5909
6331
  " --local Advanced: install the fully-local Haven MCP (no hosted dependency).",
5910
6332
  " Only available for Claude Code and Codex. Default is hosted MCP + local signer.",
@@ -5913,10 +6335,13 @@ function helpText() {
5913
6335
  " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero only on a failed check; an advisory (!) exits 0.",
5914
6336
  " --runtime is optional: without it the doctor checks the runtime the setup recorded,",
5915
6337
  " and reports a failure (not a pass) when no record names one.",
6338
+ " --name <slug> selects the agent directory by its recorded MCP pair.",
5916
6339
  " --repair Repair, then re-diagnose (implies --doctor): reinstall the pinned signer",
5917
6340
  " runtime, rewrite the wrapper and runtime config from stored credentials.",
5918
6341
  " Hosted topology only (refuses to touch a --local config). No keys, no token.",
5919
6342
  " Requires --runtime <runtime>: it rewrites that config, so the runtime is never inherited.",
6343
+ " --name <slug> selects the agent to repair by its recorded pair; --ack-local-tools",
6344
+ " re-consents the installed signer (the old token-free path: <agent dir>/bin/haven-signer.mjs --ack).",
5920
6345
  " --rekey Replace this agent's signing key (no token). Generates a fresh keypair HERE and",
5921
6346
  " prints its public address to paste into the Haven agent page. Nothing changes",
5922
6347
  " until you finish; the agent keeps working on its old key throughout.",
@@ -6049,7 +6474,7 @@ async function runCli(argv, io = {
6049
6474
  io.stdout(redactSecrets(`Tombstoned agent ${info.agent_id} at ${parsed.tombstone.directory}.
6050
6475
  `));
6051
6476
  io.stdout(
6052
- "Key files were NOT touched and nothing was revoked \u2014 revoke the agent on the Haven agent page if you have not already.\n"
6477
+ "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"
6053
6478
  );
6054
6479
  io.stdout(
6055
6480
  info.recordPath !== null ? `A surviving tombstone record was mirrored to ${redactSecrets(info.recordPath)}
@@ -6077,11 +6502,12 @@ async function runCli(argv, io = {
6077
6502
  if (parsed.unwire) {
6078
6503
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
6079
6504
  const { tombstonesDirForAgentDirectory: tombstonesDirForAgentDirectory2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
6505
+ const { resolveAgentDirectoryBySlug: resolveAgentDirectoryBySlug2 } = await Promise.resolve().then(() => (init_storage(), storage_exports));
6080
6506
  const { homedir: homedir11 } = await import('os');
6081
6507
  const { join: join13 } = await import('path');
6082
6508
  const homeDir = homedir11();
6083
6509
  const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
6084
- const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
6510
+ const directory = parsed.unwireDir ?? (parsed.options.serverName ? await resolveAgentDirectoryBySlug2(parsed.options.serverName, { root, allowRetired: true }) : root);
6085
6511
  const ledgerDir = tombstonesDirForAgentDirectory2(directory, homeDir);
6086
6512
  try {
6087
6513
  const result = await unwireAgent2({
@@ -6158,7 +6584,7 @@ async function runCli(argv, io = {
6158
6584
  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"
6159
6585
  );
6160
6586
  io.stdout(
6161
- "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"
6587
+ "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"
6162
6588
  );
6163
6589
  }
6164
6590
  return failures.length > 0 || retained ? 1 : 0;
@@ -6283,14 +6709,16 @@ async function runCli(argv, io = {
6283
6709
  const { runDoctor: runDoctor2, runRepair: runRepair2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
6284
6710
  const runtime = parsed.options.runtime ?? "";
6285
6711
  const credentialsDir = parsed.options.credentialsDir;
6712
+ const serverName = parsed.options.serverName;
6713
+ const ackLocalTools = parsed.options.ackLocalTools === true;
6286
6714
  try {
6287
6715
  if (parsed.repair) {
6288
- const repair = await runRepair2({ runtime, credentialsDir });
6716
+ const repair = await runRepair2({ runtime, credentialsDir, serverName, ackLocalTools });
6289
6717
  for (const message of repair.messages) io.stderr(`${redactSecrets(message)}
6290
6718
  `);
6291
6719
  if (!repair.ok) return 1;
6292
6720
  }
6293
- const report = await runDoctor2({ runtime, credentialsDir });
6721
+ const report = await runDoctor2({ runtime, credentialsDir, serverName });
6294
6722
  if (parsed.json) {
6295
6723
  io.stdout(`${redactSecrets(JSON.stringify(report))}
6296
6724
  `);
@@ -6302,13 +6730,16 @@ async function runCli(argv, io = {
6302
6730
  `));
6303
6731
  }
6304
6732
  const otherAgents = report.agents.filter((agent) => agent.directory !== report.credentialDirectory);
6733
+ const wiringUnverifiable = report.checks.some(
6734
+ (check) => check.id === "superseded_agents" && check.level === "advisory"
6735
+ );
6305
6736
  if (otherAgents.length > 0) {
6306
6737
  io.stdout("\nOther agents on this machine:\n");
6307
6738
  for (const agent of otherAgents) {
6308
6739
  const name = agent.slug ? `${agent.slug} (${agent.agentId ?? "unknown"})` : agent.agentId ?? "unknown";
6309
6740
  const failed = agent.checks.filter((check) => check.level === "failed");
6310
6741
  const advised = agent.checks.filter((check) => check.level === "advisory");
6311
- 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;
6742
+ 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;
6312
6743
  io.stdout(redactSecrets(` ${failed.length > 0 ? "\u2717" : advised.length > 0 ? "!" : "\u2022"} ${name}: ${verdict}
6313
6744
  `));
6314
6745
  for (const check of [...failed, ...advised]) {