@haven_ai/connect 0.4.0-alpha.0 → 0.6.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/index.cjs CHANGED
@@ -31,10 +31,11 @@ var __export = (target, all) => {
31
31
  };
32
32
 
33
33
  // src/api.ts
34
- function createConnectApiClient(baseUrl, fetchImpl = fetch) {
34
+ function createConnectApiClient(baseUrl, fetchImpl = fetch, clientIdentity) {
35
35
  const root = baseUrl.replace(/\/+$/, "");
36
+ const send = (url, init) => request(fetchImpl, url, clientIdentity ? { ...init, headers: { ...init.headers, "X-Haven-Client": clientIdentity } } : init);
36
37
  return {
37
- resolveSetup: (input) => request(fetchImpl, `${root}/agent-connection-setups/resolve`, {
38
+ resolveSetup: (input) => send(`${root}/agent-connection-setups/resolve`, {
38
39
  method: "POST",
39
40
  body: JSON.stringify({
40
41
  setup_token: input.setupToken,
@@ -42,7 +43,7 @@ function createConnectApiClient(baseUrl, fetchImpl = fetch) {
42
43
  runtime: input.runtime
43
44
  })
44
45
  }),
45
- registerSetup: (input) => request(fetchImpl, `${root}/agent-connection-setups/register`, {
46
+ registerSetup: (input) => send(`${root}/agent-connection-setups/register`, {
46
47
  method: "POST",
47
48
  body: JSON.stringify({
48
49
  setup_token: input.setupToken,
@@ -62,16 +63,16 @@ function createConnectApiClient(baseUrl, fetchImpl = fetch) {
62
63
  }
63
64
  })
64
65
  }),
65
- getAgentIdentity: (apiKey) => request(fetchImpl, `${root}/machine-payments/agent`, {
66
+ getAgentIdentity: (apiKey) => send(`${root}/machine-payments/agent`, {
66
67
  method: "GET",
67
68
  headers: { Authorization: `Bearer ${apiKey}` }
68
69
  }),
69
- getConnectorStatus: (setupId, apiKey) => request(fetchImpl, `${root}/agent-connection-setups/${encodeURIComponent(setupId)}/connector-status`, {
70
+ getConnectorStatus: (setupId, apiKey) => send(`${root}/agent-connection-setups/${encodeURIComponent(setupId)}/connector-status`, {
70
71
  method: "GET",
71
72
  headers: { Authorization: `Bearer ${apiKey}` }
72
73
  }),
73
74
  updateInstallStatus: async (setupId, apiKey, input) => {
74
- await request(fetchImpl, `${root}/agent-connection-setups/${encodeURIComponent(setupId)}/install-status`, {
75
+ await send(`${root}/agent-connection-setups/${encodeURIComponent(setupId)}/install-status`, {
75
76
  method: "POST",
76
77
  headers: { Authorization: `Bearer ${apiKey}` },
77
78
  body: JSON.stringify({
@@ -618,9 +619,9 @@ var init_runtime_manifest = __esm({
618
619
  mcpPackage: "@haven_ai/mcp",
619
620
  mcpVersion: mcp.MCP_VERSION,
620
621
  sdkPackage: "@haven_ai/sdk",
621
- sdkVersion: "0.4.0-alpha.0",
622
+ sdkVersion: "0.6.0-alpha.0",
622
623
  signerPackage: "@haven_ai/signer",
623
- signerVersion: "0.4.0-alpha.0",
624
+ signerVersion: "0.6.0-alpha.0",
624
625
  // Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
625
626
  // while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
626
627
  // so the guard that was supposed to enforce the floor waved Node v23 through
@@ -1589,7 +1590,7 @@ async function probeLocalSignerCredential(signerPath) {
1589
1590
  }
1590
1591
  }
1591
1592
  async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4) {
1592
- return new Promise((resolve10) => {
1593
+ return new Promise((resolve11) => {
1593
1594
  const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "ignore"] });
1594
1595
  let stdout = "";
1595
1596
  let settled = false;
@@ -1601,7 +1602,7 @@ async function probeLocalMcpTools(command, args, requiredTools, timeoutMs = 1e4)
1601
1602
  settled = true;
1602
1603
  clearTimeout(timeout);
1603
1604
  child.kill();
1604
- resolve10(result);
1605
+ resolve11(result);
1605
1606
  };
1606
1607
  const timeout = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1607
1608
  child.on("error", () => finish({ status: "process_error" }));
@@ -2995,20 +2996,211 @@ var init_runtime_install = __esm({
2995
2996
  execFileAsync3 = util.promisify(child_process.execFile);
2996
2997
  }
2997
2998
  });
2999
+ function installedClientTargets(homeDir = os.homedir(), cwd = process.cwd(), env = process.env) {
3000
+ const targets = [
3001
+ {
3002
+ runtime: "claude-code",
3003
+ label: "Claude Code",
3004
+ // Claude Code is configured through its own CLI (`claude mcp add-json`),
3005
+ // not by writing a file this module owns — so its evidence is the
3006
+ // client directory, never a config path.
3007
+ configPath: null,
3008
+ markers: [path.join(homeDir, ".claude"), path.join(homeDir, ".claude.json")]
3009
+ },
3010
+ {
3011
+ runtime: "codex-cli",
3012
+ // Both Codex surfaces write the same ~/.codex/config.toml, so they are
3013
+ // ONE candidate. Splitting them would ask the user to answer a question
3014
+ // whose answers are the same write.
3015
+ label: "Codex (CLI or Desktop)",
3016
+ configPath: runtimeConfigPathFor("codex-cli", homeDir),
3017
+ markers: [path.join(homeDir, ".codex")]
3018
+ },
3019
+ {
3020
+ runtime: "cursor",
3021
+ label: "Cursor",
3022
+ configPath: runtimeConfigPathFor("cursor", homeDir),
3023
+ markers: [path.join(homeDir, ".cursor")]
3024
+ },
3025
+ {
3026
+ runtime: "vscode",
3027
+ label: "VS Code",
3028
+ configPath: runtimeConfigPathFor("vscode", homeDir),
3029
+ markers: [path.resolve(cwd, ".vscode")]
3030
+ },
3031
+ {
3032
+ runtime: "vscode-insiders",
3033
+ label: "VS Code Insiders",
3034
+ configPath: runtimeConfigPathFor("vscode-insiders", homeDir),
3035
+ markers: []
3036
+ },
3037
+ {
3038
+ runtime: "claude-desktop",
3039
+ label: "Claude Desktop",
3040
+ configPath: runtimeConfigPathFor("claude-desktop", homeDir),
3041
+ markers: []
3042
+ },
3043
+ {
3044
+ runtime: "hermes",
3045
+ label: "Hermes Agent",
3046
+ configPath: runtimeConfigPathFor("hermes", homeDir),
3047
+ markers: [env.HERMES_HOME ?? path.join(homeDir, ".hermes")]
3048
+ }
3049
+ ];
3050
+ return targets.filter((target) => runtimeProfile(target.runtime, {}).canWriteRuntimeConfig);
3051
+ }
3052
+ async function scanInstalledClients(options = {}) {
3053
+ const exists = options.exists ?? pathExists;
3054
+ const targets = installedClientTargets(options.homeDir, options.cwd, options.env ?? process.env);
3055
+ const found = [];
3056
+ for (const target of targets) {
3057
+ if (target.configPath && await exists(target.configPath)) {
3058
+ found.push({
3059
+ runtime: target.runtime,
3060
+ label: target.label,
3061
+ detail: `MCP config found at ${target.configPath}`,
3062
+ configPath: target.configPath,
3063
+ evidence: "config-file"
3064
+ });
3065
+ continue;
3066
+ }
3067
+ for (const marker of target.markers) {
3068
+ if (!await exists(marker)) continue;
3069
+ found.push({
3070
+ runtime: target.runtime,
3071
+ label: target.label,
3072
+ detail: `installed (${marker})`,
3073
+ configPath: target.configPath,
3074
+ evidence: "client-directory"
3075
+ });
3076
+ break;
3077
+ }
3078
+ }
3079
+ return found.sort((a, b) => {
3080
+ if (a.evidence !== b.evidence) return a.evidence === "config-file" ? -1 : 1;
3081
+ return SCAN_ORDER.indexOf(a.runtime) - SCAN_ORDER.indexOf(b.runtime);
3082
+ });
3083
+ }
3084
+ function installedClientHint(candidates) {
3085
+ const installedClients = candidates.map((candidate) => candidate.runtime);
3086
+ if (candidates.length === 1) {
3087
+ return { installedClients, suggestedRuntime: candidates[0].runtime };
3088
+ }
3089
+ const configured = candidates.filter((candidate) => candidate.evidence === "config-file");
3090
+ return configured.length === 1 ? { installedClients, suggestedRuntime: configured[0].runtime } : { installedClients };
3091
+ }
3092
+ async function promptForInstalledClient(candidates, io = defaultPromptIo()) {
3093
+ if (candidates.length === 0) throw noInstalledClientsError();
3094
+ io.write("Haven could not detect which agent runtime this is.\n");
3095
+ io.write("These agent clients are installed on this machine:\n");
3096
+ candidates.forEach((candidate, index) => {
3097
+ io.write(` ${index + 1}) ${candidate.label} \u2014 ${candidate.detail}
3098
+ `);
3099
+ });
3100
+ 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");
3101
+ for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt += 1) {
3102
+ const answer = await io.question(`Which one? [1-${candidates.length}] (default 1 \u2014 ${candidates[0].label}): `);
3103
+ if (answer === null) throw promptAbortedError("the prompt was cancelled");
3104
+ const trimmed = answer.trim();
3105
+ if (trimmed === "") return candidates[0].runtime;
3106
+ const picked = Number.parseInt(trimmed, 10);
3107
+ if (Number.isInteger(picked) && picked >= 1 && picked <= candidates.length) {
3108
+ return candidates[picked - 1].runtime;
3109
+ }
3110
+ io.write(`"${trimmed}" is not one of 1-${candidates.length}.
3111
+ `);
3112
+ }
3113
+ throw promptAbortedError(`no valid choice after ${MAX_PROMPT_ATTEMPTS} attempts`);
3114
+ }
3115
+ async function resolveRuntimeByInstalledClientPrompt(options = {}) {
3116
+ const candidates = await scanInstalledClients(options);
3117
+ if (candidates.length === 0) throw noInstalledClientsError();
3118
+ return promptForInstalledClient(candidates, options.io ?? defaultPromptIo());
3119
+ }
3120
+ function noInstalledClientsError() {
3121
+ return new exports.ConnectError(
3122
+ "runtime_no_installed_clients",
3123
+ "Could not determine the agent runtime: nothing was detected in this environment, and no agent client Haven can configure is installed on this machine. Re-run with --runtime <name> naming the client you want configured, or --runtime other to store credentials and finish the MCP setup by hand.",
3124
+ "rerun_connect_with_explicit_runtime"
3125
+ );
3126
+ }
3127
+ function promptAbortedError(reason) {
3128
+ return new exports.ConnectError(
3129
+ "runtime_prompt_aborted",
3130
+ `Runtime not chosen (${reason}). Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the connector command again, or pass --runtime <name> to skip the prompt.`,
3131
+ "rerun_connect_and_choose_a_runtime"
3132
+ );
3133
+ }
3134
+ async function pathExists(path) {
3135
+ try {
3136
+ await promises.access(path);
3137
+ return true;
3138
+ } catch {
3139
+ return false;
3140
+ }
3141
+ }
3142
+ function defaultPromptIo() {
3143
+ return {
3144
+ write: (text) => process.stdout.write(text),
3145
+ question: (query) => new Promise((resolvePromise) => {
3146
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3147
+ let settled = false;
3148
+ const settle = (value) => {
3149
+ if (settled) return;
3150
+ settled = true;
3151
+ rl.close();
3152
+ resolvePromise(value);
3153
+ };
3154
+ rl.once("SIGINT", () => settle(null));
3155
+ rl.once("close", () => settle(null));
3156
+ rl.question(query, (answer) => settle(answer));
3157
+ })
3158
+ };
3159
+ }
3160
+ var SCAN_ORDER, MAX_PROMPT_ATTEMPTS;
3161
+ var init_installed_clients = __esm({
3162
+ "src/installed-clients.ts"() {
3163
+ init_connect_error();
3164
+ init_config_writers();
3165
+ init_runtime_registry();
3166
+ SCAN_ORDER = [
3167
+ "claude-code",
3168
+ "codex-cli",
3169
+ "cursor",
3170
+ "vscode",
3171
+ "vscode-insiders",
3172
+ "claude-desktop",
3173
+ "hermes"
3174
+ ];
3175
+ MAX_PROMPT_ATTEMPTS = 3;
3176
+ }
3177
+ });
2998
3178
 
2999
3179
  // src/tombstone.ts
3000
3180
  var tombstone_exports = {};
3001
3181
  __export(tombstone_exports, {
3182
+ ROOT_TOMBSTONES_DIRNAME: () => ROOT_TOMBSTONES_DIRNAME,
3002
3183
  TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
3003
3184
  TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
3004
3185
  defaultTombstonesDir: () => defaultTombstonesDir,
3005
3186
  readAgentTombstone: () => readAgentTombstone,
3006
3187
  readTombstoneRecords: () => readTombstoneRecords,
3188
+ tombstonesDirForAgentDirectory: () => tombstonesDirForAgentDirectory,
3189
+ tombstonesDirForCredentialRoot: () => tombstonesDirForCredentialRoot,
3007
3190
  writeAgentTombstone: () => writeAgentTombstone
3008
3191
  });
3009
3192
  function defaultTombstonesDir(baseDir) {
3010
3193
  return path.join(baseDir ?? path.join(os.homedir(), ".haven"), "tombstones");
3011
3194
  }
3195
+ function tombstonesDirForCredentialRoot(credentialRoot, homeDir = os.homedir()) {
3196
+ if (!credentialRoot) return defaultTombstonesDir(path.join(homeDir, ".haven"));
3197
+ const root = path.resolve(credentialRoot);
3198
+ if (root === path.resolve(homeDir, ".haven", "agents")) return defaultTombstonesDir(path.join(homeDir, ".haven"));
3199
+ return path.join(root, ROOT_TOMBSTONES_DIRNAME);
3200
+ }
3201
+ function tombstonesDirForAgentDirectory(directory, homeDir = os.homedir()) {
3202
+ return tombstonesDirForCredentialRoot(path.dirname(path.resolve(directory)), homeDir);
3203
+ }
3012
3204
  async function readTombstoneRecords(tombstonesDir) {
3013
3205
  const root = tombstonesDir ?? defaultTombstonesDir();
3014
3206
  let entries = [];
@@ -3085,9 +3277,14 @@ async function writeAgentTombstone(input) {
3085
3277
  const record = JSON.stringify(info, null, 2) + "\n";
3086
3278
  await promises.writeFile(path.join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
3087
3279
  const root = input.tombstonesDir ?? defaultTombstonesDir();
3088
- await promises.mkdir(root, { recursive: true, mode: 448 });
3089
3280
  const recordPath = path.join(root, `${info.agent_id}.json`);
3090
- await promises.writeFile(recordPath, record, { mode: MIRROR_MODE });
3281
+ try {
3282
+ await promises.mkdir(root, { recursive: true, mode: 448 });
3283
+ await promises.writeFile(recordPath, record, { mode: MIRROR_MODE });
3284
+ } catch (err) {
3285
+ const code = err?.code;
3286
+ return { ...info, recordPath: null, mirrorError: typeof code === "string" && /^E[A-Z]+$/.test(code) ? code : "mirror_write_failed" };
3287
+ }
3091
3288
  return { ...info, recordPath };
3092
3289
  }
3093
3290
  async function readAgentTombstone(directory) {
@@ -3099,53 +3296,165 @@ async function readAgentTombstone(directory) {
3099
3296
  return null;
3100
3297
  }
3101
3298
  }
3102
- var TOMBSTONE_FILENAME, MIRROR_MODE, TOMBSTONE_MARKER;
3299
+ var TOMBSTONE_FILENAME, ROOT_TOMBSTONES_DIRNAME, MIRROR_MODE, TOMBSTONE_MARKER;
3103
3300
  var init_tombstone = __esm({
3104
3301
  "src/tombstone.ts"() {
3105
3302
  init_redact();
3106
3303
  init_connect_error();
3107
3304
  TOMBSTONE_FILENAME = "TOMBSTONE.json";
3305
+ ROOT_TOMBSTONES_DIRNAME = ".tombstones";
3108
3306
  MIRROR_MODE = 384;
3109
3307
  TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
3110
3308
  }
3111
3309
  });
3112
-
3113
- // src/unwire.ts
3114
- var unwire_exports = {};
3115
- __export(unwire_exports, {
3116
- readIdentityFile: () => readIdentityFile,
3117
- teardownLocalKeyMaterial: () => teardownLocalKeyMaterial,
3118
- tombstoneDirectoryIfAbsent: () => tombstoneDirectoryIfAbsent,
3119
- unwireAgent: () => unwireAgent
3120
- });
3121
- function identityAt(directory) {
3122
- return promises.readFile(path.join(directory, "identity.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
3123
- }
3124
- function removeForModel(text, model, names, path) {
3125
- switch (model.kind) {
3126
- case "yaml":
3127
- return removeHermesYaml(text, names, path);
3128
- case "toml":
3129
- return removeCodexToml(text, names);
3130
- case "json":
3131
- return removeJsonMcpConfig(text, model.serverRoot ?? "mcpServers", names, path);
3310
+ async function detectWiringCollision(input) {
3311
+ const root = defaultCredentialRoot(input.credentialsDir);
3312
+ let entries = [];
3313
+ try {
3314
+ entries = await promises.readdir(root);
3315
+ } catch {
3316
+ return null;
3317
+ }
3318
+ const superseded = [];
3319
+ const taken = /* @__PURE__ */ new Set();
3320
+ for (const entry of entries) {
3321
+ const directory = path.join(root, entry);
3322
+ let identityRaw;
3323
+ try {
3324
+ identityRaw = await promises.readFile(path.join(directory, "identity.json"), "utf8");
3325
+ } catch {
3326
+ continue;
3327
+ }
3328
+ taken.add(entry);
3329
+ let identity;
3330
+ try {
3331
+ identity = JSON.parse(identityRaw);
3332
+ } catch {
3333
+ identity = void 0;
3334
+ }
3335
+ if (!identity?.api_key) continue;
3336
+ if (await pathExists2(path.join(directory, TOMBSTONE_FILENAME))) continue;
3337
+ const sidecar = await readRuntimeSidecar(directory);
3338
+ if (sidecar?.server_name) continue;
3339
+ superseded.push({ directory, agentId: identity.agent_id ?? entry });
3132
3340
  }
3341
+ if (superseded.length === 0) return null;
3342
+ return {
3343
+ superseded,
3344
+ suggestedServerName: proposeServerSlug(input.agentName, taken)
3345
+ };
3133
3346
  }
3134
- function envLineValue(envText, envKey) {
3135
- const line = envText.split(/\r?\n/).find((candidate) => new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(candidate));
3136
- if (!line) return void 0;
3137
- let value = line.slice(line.indexOf("=") + 1);
3138
- value = value.trim();
3139
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3140
- value = value.slice(1, -1);
3347
+ function proposeServerSlug(agentName, taken) {
3348
+ let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
3349
+ if (!slugIsValid(base)) base = "agent";
3350
+ if (!taken.has(base)) return base;
3351
+ for (let n = 2; n < 1e3; n += 1) {
3352
+ const suffix = `-${n}`;
3353
+ const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
3354
+ if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
3141
3355
  }
3142
- return value;
3356
+ throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
3143
3357
  }
3144
- async function readOptionalText(path) {
3358
+ function slugIsValid(slug) {
3145
3359
  try {
3146
- return await promises.readFile(path, "utf8");
3360
+ assertValidServerSlug(slug);
3361
+ return true;
3147
3362
  } catch {
3148
- return null;
3363
+ return false;
3364
+ }
3365
+ }
3366
+ async function promptWiringCollisionResolution(collision, agentName, io) {
3367
+ const ids = collision.superseded.map((entry) => entry.agentId).join(", ");
3368
+ io.write(`This machine is already wired to a Haven agent with a live key: ${ids}.
3369
+ `);
3370
+ io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
3371
+ `);
3372
+ io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
3373
+ io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
3374
+ io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
3375
+ `);
3376
+ io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
3377
+ `);
3378
+ io.write(" q) quit \u2014 nothing is written and the setup token stays unused\n");
3379
+ for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS2; attempt += 1) {
3380
+ const answer = await io.question("Replace, install alongside, or quit? [r/a/q]: ");
3381
+ if (answer === null) return { action: "abort" };
3382
+ const trimmed = answer.trim().toLowerCase();
3383
+ if (trimmed === "r" || trimmed === "replace") return { action: "replace" };
3384
+ if (trimmed === "q" || trimmed === "quit") return { action: "abort" };
3385
+ if (trimmed === "a" || trimmed === "alongside") {
3386
+ const typed = await io.question(`Server name [${collision.suggestedServerName}]: `);
3387
+ if (typed === null) return { action: "abort" };
3388
+ const serverName = typed.trim() === "" ? collision.suggestedServerName : typed.trim();
3389
+ try {
3390
+ assertValidServerSlug(serverName);
3391
+ } catch (err) {
3392
+ io.write(`${err instanceof Error ? err.message : String(err)}
3393
+ `);
3394
+ continue;
3395
+ }
3396
+ return { action: "alongside", serverName };
3397
+ }
3398
+ io.write(`"${trimmed}" is not one of r, a, q.
3399
+ `);
3400
+ }
3401
+ return { action: "abort" };
3402
+ }
3403
+ async function pathExists2(path) {
3404
+ try {
3405
+ await promises.stat(path);
3406
+ return true;
3407
+ } catch {
3408
+ return false;
3409
+ }
3410
+ }
3411
+ var MAX_PROMPT_ATTEMPTS2;
3412
+ var init_wiring_collision = __esm({
3413
+ "src/wiring-collision.ts"() {
3414
+ init_server_names();
3415
+ init_signer_runtime();
3416
+ init_storage();
3417
+ init_tombstone();
3418
+ MAX_PROMPT_ATTEMPTS2 = 3;
3419
+ }
3420
+ });
3421
+
3422
+ // src/unwire.ts
3423
+ var unwire_exports = {};
3424
+ __export(unwire_exports, {
3425
+ readIdentityFile: () => readIdentityFile,
3426
+ teardownLocalKeyMaterial: () => teardownLocalKeyMaterial,
3427
+ tombstoneDirectoryIfAbsent: () => tombstoneDirectoryIfAbsent,
3428
+ unwireAgent: () => unwireAgent
3429
+ });
3430
+ function identityAt(directory) {
3431
+ return promises.readFile(path.join(directory, "identity.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
3432
+ }
3433
+ function removeForModel(text, model, names, path) {
3434
+ switch (model.kind) {
3435
+ case "yaml":
3436
+ return removeHermesYaml(text, names, path);
3437
+ case "toml":
3438
+ return removeCodexToml(text, names);
3439
+ case "json":
3440
+ return removeJsonMcpConfig(text, model.serverRoot ?? "mcpServers", names, path);
3441
+ }
3442
+ }
3443
+ function envLineValue(envText, envKey) {
3444
+ const line = envText.split(/\r?\n/).find((candidate) => new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(candidate));
3445
+ if (!line) return void 0;
3446
+ let value = line.slice(line.indexOf("=") + 1);
3447
+ value = value.trim();
3448
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3449
+ value = value.slice(1, -1);
3450
+ }
3451
+ return value;
3452
+ }
3453
+ async function readOptionalText(path) {
3454
+ try {
3455
+ return await promises.readFile(path, "utf8");
3456
+ } catch {
3457
+ return null;
3149
3458
  }
3150
3459
  }
3151
3460
  async function unwireAgent(input) {
@@ -3155,7 +3464,7 @@ async function unwireAgent(input) {
3155
3464
  const slug = input.slug ?? sidecar?.server_name;
3156
3465
  const names = serverNamesFor(slug);
3157
3466
  const runtimes = [];
3158
- const tombstoned = await tombstoneDirectoryIfAbsent({
3467
+ const { written: tombstoned, mirrorError } = await tombstoneDirectoryIfAbsent({
3159
3468
  directory: input.directory,
3160
3469
  agentId,
3161
3470
  reason: input.reason ?? "unwired via --unwire",
@@ -3249,7 +3558,16 @@ async function unwireAgent(input) {
3249
3558
  const bindingReleased = await clearMcpServerBinding(input.directory);
3250
3559
  const teardown = await decideTeardown(identity, input);
3251
3560
  if (teardown.status !== "retained") await teardownLocalKeyMaterial(input.directory, identity);
3252
- return { directory: input.directory, agentId, slug, tombstoned, runtimes, teardown, bindingReleased };
3561
+ return {
3562
+ directory: input.directory,
3563
+ agentId,
3564
+ slug,
3565
+ tombstoned,
3566
+ runtimes,
3567
+ teardown,
3568
+ bindingReleased,
3569
+ ...mirrorError === void 0 ? {} : { mirrorError }
3570
+ };
3253
3571
  }
3254
3572
  async function decideTeardown(identity, input) {
3255
3573
  const ended = "Local recovery of a stranded delegate balance ends with it: the sweep-recovery routes accept only this agent's API key and delegate signature, and neither exists on this machine any more.";
@@ -3294,9 +3612,9 @@ async function decideTeardown(identity, input) {
3294
3612
  }
3295
3613
  }
3296
3614
  async function tombstoneDirectoryIfAbsent(input) {
3297
- if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return false;
3298
- await writeAgentTombstone(input);
3299
- return true;
3615
+ if (await readOptionalText(path.join(input.directory, TOMBSTONE_FILENAME)) !== null) return { written: false };
3616
+ const { mirrorError } = await writeAgentTombstone(input);
3617
+ return mirrorError === void 0 ? { written: true } : { written: true, mirrorError };
3300
3618
  }
3301
3619
  async function teardownLocalKeyMaterial(directory, identity) {
3302
3620
  await Promise.all([
@@ -3332,2276 +3650,2022 @@ var init_unwire = __esm({
3332
3650
  DESTROY_FLAG = "--destroy-key-material";
3333
3651
  }
3334
3652
  });
3335
-
3336
- // src/rekey-messages.ts
3337
- var REKEY_FINISH_NEEDS_API_KEY;
3338
- var init_rekey_messages = __esm({
3339
- "src/rekey-messages.ts"() {
3340
- REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
3653
+ function failureOutcomeFor(runtimeHint, error) {
3654
+ if (error !== null && typeof error === "object") {
3655
+ const recorded = failureOutcomesByError.get(error);
3656
+ if (recorded) return recorded;
3341
3657
  }
3342
- });
3343
-
3344
- // src/prune-runtimes.ts
3345
- var prune_runtimes_exports = {};
3346
- __export(prune_runtimes_exports, {
3347
- normalizeRuntimePath: () => normalizeRuntimePath,
3348
- pruneSignerRuntimes: () => pruneSignerRuntimes,
3349
- referencedRuntimeDirectories: () => referencedRuntimeDirectories,
3350
- rollUpPruneLevel: () => rollUpPruneLevel,
3351
- signerRuntimeRoot: () => signerRuntimeRoot
3352
- });
3353
- function signerRuntimeRoot(homeDir) {
3354
- return path.join(homeDir, ".haven", "signer-runtime");
3355
- }
3356
- function classifyKey(key) {
3357
- if (key.startsWith("override-")) return "override";
3358
- if (key === exports.MCP_RUNTIME_MANIFEST.signerVersion || /^\d+\.\d+\.\d+/.test(key)) return "version";
3359
- return "unknown";
3658
+ return failedConnectOutcome(runtimeHint, error);
3360
3659
  }
3361
- async function directoryBytes(directory) {
3362
- let total = 0;
3363
- const walk = async (dir) => {
3364
- let entries = [];
3365
- try {
3366
- entries = await promises.readdir(dir, { withFileTypes: true });
3367
- } catch {
3368
- return;
3369
- }
3370
- for (const entry of entries) {
3371
- const path$1 = path.join(dir, entry.name);
3372
- if (entry.isDirectory()) await walk(path$1);
3373
- else if (entry.isFile()) {
3374
- try {
3375
- total += (await promises.stat(path$1)).size;
3376
- } catch {
3377
- }
3378
- }
3379
- }
3380
- };
3381
- await walk(directory);
3382
- return total;
3660
+ async function runConnect(options, deps = {}) {
3661
+ const trace = {};
3662
+ try {
3663
+ return await executeConnect(options, deps, trace);
3664
+ } catch (err) {
3665
+ const outcome = failedConnectOutcome(trace.runtime ?? options.runtime, err);
3666
+ if (err !== null && typeof err === "object") failureOutcomesByError.set(err, outcome);
3667
+ if (trace.directory) await recordConnectOutcome(deps, trace.directory, outcome);
3668
+ throw err;
3669
+ }
3383
3670
  }
3384
- async function normalizeRuntimePath(path$1) {
3385
- const resolved = path.resolve(path$1).replace(/[\\/]+$/, "");
3671
+ async function recordConnectOutcome(deps, directory, outcome) {
3386
3672
  try {
3387
- return await promises.realpath(resolved);
3673
+ return await (deps.writeOutcomeRecord ?? writeConnectOutcomeRecord)(directory, outcome);
3388
3674
  } catch {
3389
- return resolved;
3675
+ return void 0;
3390
3676
  }
3391
3677
  }
3392
- function escapeRegExp(text) {
3393
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3394
- }
3395
- async function referencedRuntimeDirectories(homeDir, credentialsDir) {
3396
- const roots = /* @__PURE__ */ new Set([path.join(homeDir, ".haven", "agents")]);
3397
- if (credentialsDir) roots.add(path.dirname(path.resolve(credentialsDir)));
3398
- const referenced = /* @__PURE__ */ new Map();
3399
- const runtimeRoot = await normalizeRuntimePath(signerRuntimeRoot(homeDir));
3400
- const rootSpellings = [.../* @__PURE__ */ new Set([path.resolve(signerRuntimeRoot(homeDir)), runtimeRoot])];
3401
- const wrapperRe = new RegExp(`(?:${rootSpellings.map(escapeRegExp).join("|")})[\\/]+([^\\/'"\\s]+)`, "g");
3402
- const add = async (runtimeDirectory, by) => {
3403
- const key = await normalizeRuntimePath(runtimeDirectory);
3404
- const list = referenced.get(key) ?? [];
3405
- if (!list.includes(by)) list.push(by);
3406
- referenced.set(key, list);
3407
- };
3408
- for (const root of roots) {
3409
- let entries = [];
3410
- try {
3411
- entries = await promises.readdir(root);
3412
- } catch {
3413
- continue;
3414
- }
3415
- for (const entry of entries) {
3416
- const directory = path.join(root, entry);
3417
- try {
3418
- const sidecar = JSON.parse(await promises.readFile(path.join(directory, "signer-runtime.json"), "utf8"));
3419
- if (typeof sidecar.runtime_directory === "string" && sidecar.runtime_directory.length > 0) {
3420
- await add(sidecar.runtime_directory, directory);
3421
- }
3422
- } catch {
3423
- }
3424
- try {
3425
- const wrapper = await promises.readFile(path.join(directory, "bin", "haven-signer.mjs"), "utf8");
3426
- for (const match of wrapper.matchAll(wrapperRe)) await add(path.join(runtimeRoot, match[1]), directory);
3427
- } catch {
3428
- }
3678
+ async function executeConnect(options, deps, trace) {
3679
+ assertSupportedNodeVersion(deps.nodeVersion, exports.MCP_RUNTIME_MANIFEST.minimumNodeVersion);
3680
+ const connectorVersion = options.connectorVersion ?? exports.CONNECTOR_VERSION;
3681
+ const api = deps.api ?? createConnectApiClient(options.apiBaseUrl, void 0, CONNECTOR_CLIENT_IDENTITY);
3682
+ const log = secureLogger(
3683
+ deps.log ?? ((message) => process.stdout.write(`${message}
3684
+ `)),
3685
+ deps.redactPaths === true
3686
+ );
3687
+ const writeCredentials = deps.writeCredentials ?? writeCredentialFiles;
3688
+ const preflightStorage = deps.preflightStorage ?? preflightCredentialStorage;
3689
+ const runRuntimeInstall = deps.installRuntime ?? installRuntime;
3690
+ const generateKey = deps.generateKey ?? generateDelegateKey;
3691
+ const generateLocalApiKey = deps.generateApiKey ?? generateAgentApiKey;
3692
+ const selection = await resolveRuntimeSelection(options.runtime, options.runtimeForce, {
3693
+ env: deps.env ?? process.env,
3694
+ selfReported: options.runtimeSelfReport,
3695
+ promptForRuntime: runtimeSelectionPrompt(options, deps)
3696
+ });
3697
+ if (!selection.runtime) {
3698
+ const hint = await installedClientHintFor(deps);
3699
+ throw new exports.ConnectError(
3700
+ "runtime_undetermined",
3701
+ `Could not determine the agent runtime: nothing was detected in this environment and no --runtime was given. If you are an AI agent running this command: re-run it once, unchanged except for adding --runtime <name>, naming the harness you are running in \u2014 one of: ${exports.RUNTIME_FLAG_VALUES} (the aliases cowork, codex and openclaw are accepted too). Do not guess: if your harness is not one of those, use --runtime other, which stores the credentials and prints the manual MCP steps. ` + installedClientProse(hint) + "Nothing was written and the Haven setup token is still unused.",
3702
+ "rerun_connect_with_explicit_runtime",
3703
+ // #2091: the values must ride structurally too. The backend's setup
3704
+ // prompt permits a retry only with "one of the values that refusal
3705
+ // lists" — and --json discards prose, so a prose-only list deadlocked
3706
+ // every automation run in an undetected runtime (Codex in the field:
3707
+ // npx needs network, Codex runs network commands unsandboxed, and the
3708
+ // unsandboxed path carries none of the CODEX_* detection vars).
3709
+ //
3710
+ // #2174: the list alone still leaves the retry a nine-value guess. The
3711
+ // scan below narrows it to what is actually on this machine — as a
3712
+ // HINT. It does not select, and this stays a refusal: see
3713
+ // `installedClientHint`.
3714
+ { allowedRuntimes: RUNTIME_FLAG_VALUE_LIST, ...hint }
3715
+ );
3716
+ }
3717
+ const runtime = selection.runtime;
3718
+ trace.runtime = runtime;
3719
+ const installCapabilities = runtimeInstallCapabilities(runtime);
3720
+ if (options.localMcp) {
3721
+ if (!supportsLocalMcp(runtime)) {
3722
+ throw new Error(
3723
+ `--local (fully-local Haven MCP) is only available for Claude Code and Codex. The detected runtime is ${runtimeProfile(runtime).label}. Re-run without --local to use the default hosted MCP + local signer setup.`
3724
+ );
3429
3725
  }
3430
3726
  }
3431
- return referenced;
3432
- }
3433
- function rollUpPruneLevel(entries) {
3434
- if (entries.some((e) => e.level === "failed")) return "failed";
3435
- if (entries.some((e) => e.level === "advisory")) return "advisory";
3436
- return "ok";
3437
- }
3438
- async function pruneSignerRuntimes(input, deps = {}) {
3439
- const homeDir = deps.homeDir ?? os.homedir();
3440
- const root = signerRuntimeRoot(homeDir);
3441
- const measure = input.measure ?? true;
3442
- const referenced = await referencedRuntimeDirectories(homeDir, deps.credentialsDir);
3443
- const pin = await normalizeRuntimePath(path.join(root, exports.MCP_RUNTIME_MANIFEST.signerVersion));
3444
- const remove = deps.rm ?? (async (directory) => promises.rm(directory, { recursive: true, force: false }));
3445
- const entries = [];
3446
- let keys = [];
3447
- try {
3448
- keys = (await promises.readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
3449
- } catch {
3450
- return { version: 1, root, dryRun: input.dryRun, entries, removed: 0, reclaimedBytes: 0, level: "ok" };
3727
+ if (selection.overrodeHint) {
3728
+ log(`runtime: ${runtime} (detected; ignoring the ${selection.overrodeHint} hint \u2014 pass --runtime-force ${selection.overrodeHint} to override)`);
3451
3729
  }
3452
- let removed = 0;
3453
- let reclaimedBytes = 0;
3454
- for (const key of keys) {
3455
- const directory = path.join(root, key);
3456
- const normalized = await normalizeRuntimePath(directory);
3457
- const referencedBy = referenced.get(normalized) ?? [];
3458
- const kind = classifyKey(key);
3459
- if (referencedBy.length > 0 || normalized === pin) {
3460
- entries.push({
3461
- directory,
3462
- key,
3463
- kind,
3464
- bytes: 0,
3465
- referencedBy,
3466
- action: "kept",
3467
- level: "ok",
3468
- detail: referencedBy.length > 0 ? `kept \u2014 named by ${referencedBy.length} credential director${referencedBy.length === 1 ? "y" : "ies"} (sidecar or wrapper)` : "kept \u2014 the connector's current pinned version (what --repair installs)"
3469
- });
3470
- continue;
3471
- }
3472
- const bytes = measure ? await directoryBytes(directory) : 0;
3473
- if (input.dryRun) {
3474
- entries.push({
3475
- directory,
3476
- key,
3477
- kind,
3478
- bytes,
3479
- referencedBy,
3480
- action: "would_remove",
3481
- level: "advisory",
3482
- detail: `would remove \u2014 no credential directory names it (${kind}-keyed)`
3483
- });
3484
- continue;
3485
- }
3486
- try {
3487
- await remove(directory);
3488
- removed += 1;
3489
- reclaimedBytes += bytes;
3490
- entries.push({ directory, key, kind, bytes, referencedBy, action: "removed", level: "ok", detail: `removed \u2014 no credential directory named it (${kind}-keyed)` });
3491
- } catch (err) {
3492
- entries.push({
3493
- directory,
3494
- key,
3495
- kind,
3496
- bytes,
3497
- referencedBy,
3498
- action: "failed",
3499
- level: "failed",
3500
- detail: `removal failed: ${err instanceof Error ? err.message : String(err)} \u2014 a signer process may still hold it open; stop it and re-run`
3501
- });
3502
- }
3730
+ if (selection.discardedHint) {
3731
+ log(`runtime: ${runtime} (detected; "${selection.discardedHint}" is not a runtime Haven knows \u2014 valid values: ${exports.RUNTIME_FLAG_VALUES})`);
3503
3732
  }
3504
- return { version: 1, root, dryRun: input.dryRun, entries, removed, reclaimedBytes, level: rollUpPruneLevel(entries) };
3505
- }
3506
- var init_prune_runtimes = __esm({
3507
- "src/prune-runtimes.ts"() {
3508
- init_runtime_manifest();
3733
+ if (selection.source === "prompted") {
3734
+ log(`runtime: ${runtime} (chosen at the prompt \u2014 nothing was detected in this environment)`);
3509
3735
  }
3510
- });
3511
-
3512
- // src/rekey.ts
3513
- var rekey_exports = {};
3514
- __export(rekey_exports, {
3515
- finishRekey: () => finishRekey,
3516
- startRekey: () => startRekey
3517
- });
3518
- async function startRekey(options, deps = {}) {
3519
- const now = deps.now ?? (() => Date.now());
3520
- const stored = await readStoredCredentials(
3521
- options.serverName,
3522
- options.agentId,
3523
- options.credentialsDir
3524
- );
3525
- const api = (deps.createApi ?? ((url) => createConnectApiClient(url)))(stored.apiUrl);
3526
- const identity = await probeIdentity(api, stored.apiKey, "current");
3527
- assertRekeyable(identity, stored);
3528
- const key = (deps.generateKey ?? generateDelegateKey)();
3529
- const startedAt = new Date(now()).toISOString();
3530
- const expiresAt = new Date(now() + REKEY_PENDING_TTL_MS).toISOString();
3531
- await writeRekeyPending(stored.directory, {
3532
- agent_id: stored.agentId,
3533
- new_delegate_address: key.address,
3534
- new_delegate_key: key.privateKey,
3535
- started_at: startedAt,
3536
- expires_at: expiresAt
3537
- });
3538
- const finishCommand = [
3539
- sdk.connectorRerunCommand("--rekey-finish"),
3540
- options.serverName ? `--name ${options.serverName}` : void 0,
3541
- "--api-key <the key the dashboard showed you>",
3542
- options.runtime ? `--runtime ${options.runtime}` : "--runtime <your runtime>"
3543
- ].filter(Boolean).join(" ");
3544
- return {
3545
- started: true,
3546
- agentId: stored.agentId,
3547
- directory: stored.directory,
3548
- newDelegateAddress: key.address,
3549
- expiresAt,
3550
- messages: [
3551
- `New signing key generated on this machine for agent ${identity.name || stored.agentId}.`,
3552
- "",
3553
- ` New signing address: ${key.address}`,
3554
- "",
3555
- "The private half stays here \u2014 Haven never receives it, and there is no way to move it",
3556
- "between machines. That is what keeps the account non-custodial.",
3557
- "",
3558
- 'Next, on the Haven agent page: choose "Replace signing key" and paste that address.',
3559
- "When it finishes it shows a new API key ONCE. Come back here and run:",
3560
- "",
3561
- ` ${finishCommand}`,
3562
- "",
3563
- `Nothing has changed yet \u2014 the agent keeps working on its old key until you finish.`,
3564
- `This pending re-key expires ${expiresAt}.`
3565
- ]
3566
- };
3567
- }
3568
- async function finishRekey(options, deps = {}) {
3569
- const now = deps.now ?? (() => Date.now());
3570
- if (!options.newApiKey) {
3571
- throw new Error(REKEY_FINISH_NEEDS_API_KEY);
3736
+ log("Warming up your connection to Haven\u2026");
3737
+ let setup;
3738
+ try {
3739
+ setup = await api.resolveSetup({
3740
+ setupToken: options.setupToken,
3741
+ connectorVersion,
3742
+ runtime
3743
+ });
3744
+ } catch (err) {
3745
+ throw deadSetupTokenError(err) ?? err;
3572
3746
  }
3573
- const stored = await readStoredCredentials(
3574
- options.serverName,
3575
- options.agentId,
3576
- options.credentialsDir
3577
- );
3578
- const pending = await readRekeyPending(stored.directory, now());
3579
- if (pending.agent_id !== stored.agentId) {
3580
- throw new Error(
3581
- `The pending re-key at ${stored.directory} belongs to agent ${pending.agent_id}, but that directory now holds ${stored.agentId}. Refusing to write a key into the wrong agent.`
3582
- );
3747
+ assertSetupChallengeIsUsable(setup.challenge.expires_at);
3748
+ printSetupSummary(setup, log);
3749
+ await preflightStorage({ baseDir: options.credentialsDir, warn: log });
3750
+ if (options.serverName) {
3751
+ assertValidServerSlug(options.serverName);
3752
+ await assertServerSlugAvailable(options.serverName, options.credentialsDir);
3583
3753
  }
3584
- const api = (deps.createApi ?? ((url) => createConnectApiClient(url)))(stored.apiUrl);
3585
- const identity = await probeIdentity(api, options.newApiKey, "new");
3586
- if (identity.id !== stored.agentId) {
3587
- throw new Error(
3588
- `That API key belongs to agent ${identity.id}, not ${stored.agentId}. Nothing was changed.`
3754
+ log("Checked local credential storage \u2014 all clear.");
3755
+ let serverName = options.serverName;
3756
+ let replacing;
3757
+ if (!serverName) {
3758
+ const collision = await detectWiringCollision({
3759
+ credentialsDir: options.credentialsDir,
3760
+ agentName: setup.agent.name
3761
+ });
3762
+ if (collision) {
3763
+ const resolution = await resolveWiringCollision(collision, setup.agent.name, options, deps);
3764
+ if (resolution.action === "replace") {
3765
+ replacing = collision;
3766
+ log(
3767
+ `Replacing the existing haven / haven-signer wiring (previous agent(s): ${supersededIds(collision)}). The previous directory is retired locally once the new wiring is written; nothing is revoked.`
3768
+ );
3769
+ } else {
3770
+ serverName = resolution.serverName;
3771
+ assertValidServerSlug(serverName);
3772
+ await assertServerSlugAvailable(serverName, options.credentialsDir);
3773
+ log(`Installing alongside the existing wiring as a named agent: ${serverNamesFor(serverName).hosted} / ${serverNamesFor(serverName).signer}.`);
3774
+ }
3775
+ }
3776
+ }
3777
+ const existingAgents = await listExistingKeyedAgents(options.credentialsDir);
3778
+ if (existingAgents.length > 0) {
3779
+ log("");
3780
+ log(
3781
+ `Heads-up (before anything is written to this machine): it already carries ${existingAgents.length} agent director${existingAgents.length === 1 ? "y" : "ies"} with stored keys \u2014 ` + existingAgents.map((a) => `${a.agentId}${a.accountAddress ? ` (spends from ${shortAddress(a.accountAddress)})` : ""}`).join(", ") + ". This setup creates a NEW agent alongside them and revokes nothing."
3589
3782
  );
3590
3783
  }
3591
- const onChain = (identity.delegate_address ?? "").toLowerCase();
3592
- const expected = pending.new_delegate_address.toLowerCase();
3593
- if (onChain !== expected) {
3594
- throw new Error(
3595
- `Haven says this agent's signing address is ${identity.delegate_address ?? "unset"}, but this machine generated ${pending.new_delegate_address}. Nothing was changed. Either the re-key on the agent page used a different address, or it has not finished yet.`
3784
+ const hostedNameForRun = serverNamesFor(serverName).hosted;
3785
+ let reboundFrom;
3786
+ const holders = (await listMcpServerBindings(options.credentialsDir)).filter(({ binding }) => binding.server_name === hostedNameForRun).sort((a, b) => a.binding.bound_at < b.binding.bound_at ? 1 : a.binding.bound_at > b.binding.bound_at ? -1 : 0);
3787
+ const newest = holders[0];
3788
+ if (newest) {
3789
+ const { binding, directory } = newest;
3790
+ const backendChanged = withoutUserinfo(binding.api_url) !== withoutUserinfo(options.apiBaseUrl);
3791
+ const previousApiUrl = withoutUserinfo(binding.api_url);
3792
+ reboundFrom = { server_name: binding.server_name, agent_id: binding.agent_id, api_url: previousApiUrl, bound_at: binding.bound_at, backend_changed: backendChanged };
3793
+ const beingReplaced = replacing?.superseded.some((entry) => entry.directory === directory) ?? false;
3794
+ log(
3795
+ `Heads-up: the MCP server name '${binding.server_name}' was bound to agent ${binding.agent_id} on ${previousApiUrl} at ${binding.bound_at} (locally recorded${holders.length > 1 ? `; ${holders.length - 1} older record${holders.length > 2 ? "s" : ""} also claim${holders.length > 2 ? "" : "s"} it` : ""}). ` + (beingReplaced ? `This run replaces that wiring, as you chose, with a new agent on ${withoutUserinfo(options.apiBaseUrl)}` : `This run rebinds it to a new agent on ${withoutUserinfo(options.apiBaseUrl)}`) + (backendChanged ? " \u2014 a DIFFERENT backend: any saved session, script or document naming this MCP server now resolves to a different backend and agent." : ".")
3596
3796
  );
3597
3797
  }
3598
- await rewriteCredentialFiles({
3798
+ const localKey = generateKey();
3799
+ const localApiKey = generateLocalApiKey();
3800
+ log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
3801
+ const proofSignature = await localKey.signChallenge(setup.challenge.message);
3802
+ log("Introducing your agent to Haven\u2026");
3803
+ let registration;
3804
+ try {
3805
+ registration = await api.registerSetup({
3806
+ setupToken: options.setupToken,
3807
+ connectorVersion,
3808
+ runtime,
3809
+ challengeId: setup.challenge.id,
3810
+ delegateAddress: localKey.address,
3811
+ proofSignature,
3812
+ apiKeyHash: hashAgentApiKey(localApiKey),
3813
+ apiKeyPrefix: agentApiKeyPrefix(localApiKey),
3814
+ // #1878: report the pair we are ACTUALLY wiring, bare pair included, so
3815
+ // the dashboard can name it. Derived here rather than sent as the raw
3816
+ // slug — `serverNamesFor` is the one place the naming rule lives, and
3817
+ // the hosted name is what a user pastes into an MCP config.
3818
+ mcpServerName: serverNamesFor(serverName).hosted,
3819
+ // #2528: 'prose' is the default because it is what a caller who says
3820
+ // nothing is doing — the library entry point is not a --json run.
3821
+ runMode: options.runMode ?? "prose",
3822
+ connectorContext: {
3823
+ environment_label: options.environmentLabel ?? "Local workspace",
3824
+ config_target: installCapabilities.canWriteRuntimeConfig ? "agent runtime MCP config" : "local credential files"
3825
+ },
3826
+ installCapabilities
3827
+ });
3828
+ } catch (err) {
3829
+ const dead = deadSetupTokenError(err);
3830
+ if (dead) throw dead;
3831
+ if (isExpiredSetupChallenge(err)) {
3832
+ throw new Error(
3833
+ "The Haven setup challenge expired while connecting. Return to Haven, start a fresh connection, and run its new connector command. Do not reuse or paste credentials."
3834
+ );
3835
+ }
3836
+ throw err;
3837
+ }
3838
+ log(`Registered signing address with Haven: ${shortAddress(registration.delegate_address)}.`);
3839
+ log("Tucking your credentials away safely on disk\u2026");
3840
+ const credentialPaths = await writeCredentials({
3599
3841
  baseDir: options.credentialsDir,
3600
- agentId: stored.agentId,
3601
- serverName: options.serverName,
3602
- apiKey: options.newApiKey,
3603
- delegateKey: pending.new_delegate_key,
3604
- delegateAddress: pending.new_delegate_address,
3605
- // The stored value (already new-name-first, from the credential-FILE
3606
- // permanent fallback) wins; the hosted identity's `account_address` is a
3607
- // LIVE server read, which as of #2914 emits only that name.
3608
- accountAddress: stored.accountAddress ?? identity.account_address ?? void 0,
3609
- chainId: stored.chainId ?? identity.chain_id ?? void 0,
3610
- network: stored.network,
3611
- agentBudget: stored.agentBudget,
3612
- apiUrl: stored.apiUrl,
3613
- hostedMcpUrl: stored.hostedMcpUrl,
3614
- x402BindingSigner: stored.x402BindingSigner,
3615
- warn: deps.log
3842
+ agentId: registration.agent_id,
3843
+ serverName,
3844
+ apiKey: localApiKey,
3845
+ delegateKey: localKey.privateKey,
3846
+ delegateAddress: localKey.address,
3847
+ accountAddress: setup.haven_wallet.address,
3848
+ chainId: setup.haven_wallet.chain_id,
3849
+ network: setup.haven_wallet.network,
3850
+ agentBudget: setup.agent_budget.map((budget) => ({
3851
+ token_symbol: budget.token_symbol,
3852
+ allowance_amount: budget.allowance_amount,
3853
+ reset_period_min: budget.reset_period_min
3854
+ })),
3855
+ apiUrl: options.apiBaseUrl,
3856
+ hostedMcpUrl: registration.hosted_mcp_url,
3857
+ x402BindingSigner: setup.x402_binding_signer ?? void 0,
3858
+ warn: log
3616
3859
  });
3617
- await clearRekeyPending(stored.directory);
3618
- const names = serverNamesFor(options.serverName);
3619
- const messages = [
3620
- `Agent ${identity.name || stored.agentId} is now on its new signing key.`,
3621
- ` Signing address: ${pending.new_delegate_address}`,
3622
- ` Credentials: ${stored.directory} (rewritten in place)`,
3623
- ` MCP servers: ${names.hosted} / ${names.signer} (unchanged names)`
3624
- ];
3625
- let configRewritten = false;
3626
- if (options.runtime) {
3627
- const prepared = await (deps.prepareSigner ?? prepareSignerRuntime)(
3628
- {
3629
- credentialDirectory: stored.directory,
3630
- signerPath: `${stored.directory}/signer.json`,
3631
- homeDir: options.homeDir
3632
- },
3633
- { runCommand: deps.runCommand, env: deps.env }
3634
- );
3635
- const result = await (deps.writeConfig ?? writeHostedRuntimeConfig)(
3636
- { runCommand: deps.runCommand, homeDir: options.homeDir },
3637
- {
3638
- runtime: options.runtime,
3639
- hostedMcpUrl: stored.hostedMcpUrl,
3640
- apiKey: options.newApiKey,
3641
- identityPath: `${stored.directory}/identity.json`,
3642
- signerPath: `${stored.directory}/signer.json`,
3643
- credentialDirectory: stored.directory,
3644
- serverName: options.serverName
3645
- },
3646
- { command: prepared.command, args: prepared.args }
3860
+ trace.directory = credentialPaths.directory;
3861
+ try {
3862
+ const bindingRecord = {
3863
+ version: 1,
3864
+ server_name: hostedNameForRun,
3865
+ signer_name: serverNamesFor(serverName).signer,
3866
+ agent_id: registration.agent_id,
3867
+ api_url: withoutUserinfo(options.apiBaseUrl),
3868
+ // never persist `user:pass@` (#3154 doc review r2)
3869
+ ...registration.hosted_mcp_url ? { hosted_mcp_url: registration.hosted_mcp_url } : {},
3870
+ bound_at: (/* @__PURE__ */ new Date()).toISOString()
3871
+ };
3872
+ await writeMcpServerBinding(credentialPaths.directory, bindingRecord);
3873
+ } catch {
3874
+ log("Could not record the MCP server-name binding locally (non-fatal; the next setup cannot see this binding).");
3875
+ }
3876
+ log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
3877
+ log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
3878
+ log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
3879
+ if (setup.x402_binding_signer) {
3880
+ log("Configured x402 binding signer for the local signer.");
3881
+ } else {
3882
+ log(
3883
+ "Warning: Haven did not provide an x402 binding signer, so x402 payments will not sign until HAVEN_X402_BINDING_SIGNER is set for the signer. Non-x402 payments are unaffected."
3647
3884
  );
3648
- configRewritten = result.hostedConfigured;
3649
- if (prepared.runtimeSpecOverride) {
3650
- messages.push(
3651
- ` Signer runtime: RUNTIME SPEC OVERRIDE ACTIVE \u2014 ${describeRuntimeSpecOverride(prepared.runtimeSpecOverride.specs)} (NOT the pinned manifest; installed into ${prepared.runtimeDirectory})`
3652
- );
3885
+ }
3886
+ const runtimeInstall = await runRuntimeInstall({
3887
+ runtime,
3888
+ hostedMcpUrl: registration.hosted_mcp_url,
3889
+ apiKey: localApiKey,
3890
+ signerPath: credentialPaths.signerPath,
3891
+ identityPath: credentialPaths.identityPath,
3892
+ credentialDirectory: credentialPaths.directory,
3893
+ environmentLabel: options.environmentLabel ?? "Local workspace",
3894
+ ackSigner: options.ackSigner,
3895
+ ackLocalTools: options.ackLocalTools,
3896
+ localMcp: options.localMcp,
3897
+ serverName
3898
+ }, {
3899
+ onProgress: log,
3900
+ // #1543: report "runtime configured" the moment the config write settles,
3901
+ // so the dashboard can expose its budget-approval controls without
3902
+ // waiting on the probes and skill install — a tail that approval does not
3903
+ // depend on. Silent and best-effort: the complete report below remains
3904
+ // authoritative (it overwrites these keys, adding the probe verdicts and
3905
+ // skill state), so an early failure costs nothing but the head start.
3906
+ onRuntimeConfigured: async (early) => {
3907
+ try {
3908
+ await api.updateInstallStatus(registration.setup_id, localApiKey, {
3909
+ runtime: early.runtime,
3910
+ connectorVersion,
3911
+ runtimeMcpMode: early.runtimeMcpMode,
3912
+ hostedMcpConfigured: early.hostedMcpConfigured,
3913
+ localSignerConfigured: early.localSignerConfigured,
3914
+ localMcpConfigured: early.localMcpConfigured,
3915
+ credentialFilesWritten: true,
3916
+ signerAcknowledged: early.signerAcknowledged,
3917
+ localMcpAcknowledged: early.localMcpAcknowledged,
3918
+ restartRequired: early.restartRequired,
3919
+ nextUserAction: early.nextUserAction,
3920
+ errorCode: early.errorCode ?? null,
3921
+ environmentLabel: options.environmentLabel ?? "Local workspace"
3922
+ });
3923
+ if (!early.errorCode || early.errorCode === "manual_runtime_setup_required") {
3924
+ log(approveBudgetCta(setup, registration.approval_url));
3925
+ }
3926
+ } catch {
3927
+ }
3653
3928
  }
3654
- messages.push(` Config: ${result.target}`);
3655
- messages.push(...result.messages.map((line) => ` ${line}`));
3656
- if (!configRewritten) {
3657
- messages.push(
3658
- "",
3659
- "WARNING: the MCP config was NOT updated, so it still carries the OLD API key and every",
3660
- `wired host will fail with 401. Fix the cause above and re-run with --runtime ${options.runtime},`,
3661
- "or update the config by hand. The credential files on disk are already on the new key."
3929
+ });
3930
+ printRuntimeInstall(runtimeInstall, log);
3931
+ if (runtimeInstall.errorCode) {
3932
+ log("Haven setup needs a couple more steps on this machine \u2014 see the notes above.");
3933
+ } else {
3934
+ log("Haven setup on this machine is complete.");
3935
+ }
3936
+ let supersededAgentsRetiredLocally;
3937
+ const retiredAgentIds = [];
3938
+ const retirementMirrorErrors = {};
3939
+ if (replacing) {
3940
+ if (runtimeInstall.errorCode) {
3941
+ supersededAgentsRetiredLocally = false;
3942
+ log(
3943
+ `Previous agent(s) ${supersededIds(replacing)} were NOT retired: the runtime install did not complete, so their wiring may still be the only working one. Resolve the install, then retire them with ${RERUN_HINT} --unwire <dir>.`
3662
3944
  );
3945
+ } else {
3946
+ supersededAgentsRetiredLocally = true;
3947
+ for (const entry of replacing.superseded) {
3948
+ try {
3949
+ const { mirrorError } = await tombstoneDirectoryIfAbsent({
3950
+ directory: entry.directory,
3951
+ agentId: entry.agentId,
3952
+ reason: "replaced by a new setup (--replace)",
3953
+ replacedBy: registration.agent_id,
3954
+ // #3251: the ledger follows this run's credential root (default
3955
+ // root → ~/.haven/tombstones, as before; any other → <root>/.tombstones).
3956
+ tombstonesDir: tombstonesDirForCredentialRoot(options.credentialsDir)
3957
+ });
3958
+ if (mirrorError !== void 0) {
3959
+ retirementMirrorErrors[entry.agentId] = mirrorError;
3960
+ log(
3961
+ `Warning: the surviving tombstone record for ${entry.agentId} could not be written to ${tombstonesDirForCredentialRoot(options.credentialsDir)} (${mirrorError}). The in-place TOMBSTONE.json stands; --doctor will not list this retirement once the directory is deleted.`
3962
+ );
3963
+ }
3964
+ await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
3965
+ await clearMcpServerBinding(entry.directory);
3966
+ retiredAgentIds.push(entry.agentId);
3967
+ log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
3968
+ } catch (err) {
3969
+ supersededAgentsRetiredLocally = false;
3970
+ log(`Could not retire previous agent ${entry.agentId} locally: ${err instanceof Error ? err.message : String(err)}`);
3971
+ }
3972
+ }
3663
3973
  }
3664
- } else {
3665
- messages.push(
3666
- "",
3667
- "NOTE: no --runtime was given, so the MCP config still carries the OLD API key and every",
3668
- "wired host will fail with 401. Re-run with --runtime <name> to rewrite it."
3669
- );
3670
3974
  }
3671
- return {
3672
- finished: true,
3673
- agentId: stored.agentId,
3674
- directory: stored.directory,
3675
- newDelegateAddress: pending.new_delegate_address,
3676
- serverNames: { hosted: names.hosted, signer: names.signer },
3677
- configRewritten,
3678
- messages
3679
- };
3680
- }
3681
- async function probeIdentity(api, apiKey, which) {
3975
+ let supersededScan = null;
3976
+ let supersededAgentIds = [];
3682
3977
  try {
3683
- return await api.getAgentIdentity(apiKey);
3684
- } catch (err) {
3685
- const status = err?.status;
3686
- if (status === 401 || status === 403) {
3687
- throw new Error(
3688
- which === "current" ? "This machine's Haven API key is no longer accepted. If a re-key already finished elsewhere, run --rekey-finish with the key that re-key produced instead of starting a new one." : "Haven rejected that API key. Check you pasted the whole key from the agent page \u2014 nothing was changed."
3978
+ supersededScan = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
3979
+ supersededAgentIds = supersededScan ?? [];
3980
+ if (supersededAgentsRetiredLocally === true) {
3981
+ log("");
3982
+ log(
3983
+ `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.`
3984
+ );
3985
+ } else if (supersededAgentIds.length > 0) {
3986
+ log("");
3987
+ log(
3988
+ `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.`
3989
+ );
3990
+ log(
3991
+ `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.`
3689
3992
  );
3690
3993
  }
3691
- throw new Error(
3692
- `Could not reach Haven to check this agent (${redactSecrets(
3693
- err instanceof Error ? err.message : String(err)
3694
- )}). Nothing was changed.`
3695
- );
3994
+ } catch {
3696
3995
  }
3697
- }
3698
- function assertRekeyable(identity, stored) {
3699
- if (identity.id !== stored.agentId) {
3700
- throw new Error(
3701
- `The credentials at ${stored.directory} say agent ${stored.agentId}, but Haven says that key belongs to ${identity.id}. Refusing to re-key an agent this directory does not own.`
3702
- );
3996
+ try {
3997
+ await api.updateInstallStatus(registration.setup_id, localApiKey, {
3998
+ runtime: runtimeInstall.runtime,
3999
+ connectorVersion,
4000
+ runtimeMcpMode: runtimeInstall.runtimeMcpMode,
4001
+ hostedMcpConfigured: runtimeInstall.hostedMcpConfigured,
4002
+ localSignerConfigured: runtimeInstall.localSignerConfigured,
4003
+ localMcpConfigured: runtimeInstall.localMcpConfigured,
4004
+ credentialFilesWritten: true,
4005
+ signerAcknowledged: runtimeInstall.signerAcknowledged,
4006
+ localMcpAcknowledged: runtimeInstall.localMcpAcknowledged,
4007
+ activationCommandAvailable: Boolean(runtimeInstall.activationCommand),
4008
+ skillInstalled: runtimeInstall.skillInstalled,
4009
+ probeResult: runtimeInstall.probeResult,
4010
+ restartRequired: runtimeInstall.restartRequired,
4011
+ nextUserAction: runtimeInstall.nextUserAction,
4012
+ errorCode: runtimeInstall.errorCode,
4013
+ environmentLabel: options.environmentLabel ?? "Local workspace",
4014
+ // The raw scan result, `null` and all — NOT the flattened
4015
+ // `supersededAgentIds` the outcome carries. Flattening here would hand
4016
+ // the dashboard the same ambiguity this field exists to remove (#2561).
4017
+ supersededAgentIds: supersededScan
4018
+ });
4019
+ } catch (err) {
4020
+ log(`Could not report install status to Haven: ${err instanceof Error ? err.message : String(err)}`);
3703
4021
  }
3704
- if (identity.execution_rail === "legacy") {
3705
- throw new Error(
3706
- `Agent ${identity.name || identity.id} is on the legacy rail, which cannot be re-keyed \u2014 its authority is per-token Safe allowances, not a signed delegation there is anything to re-issue. Re-onboard the agent on the delegation rail instead.`
3707
- );
4022
+ let approval;
4023
+ const narrateApprovalWait = options.waitForApproval === true || options.waitForApproval === void 0 && (deps.isStdoutTty ?? Boolean(process.stdout.isTTY));
4024
+ if (narrateApprovalWait && !runtimeInstall.errorCode) {
4025
+ approval = await waitForBudgetApproval(api, registration.setup_id, localApiKey, log, options.approvalWait);
3708
4026
  }
3709
- if (identity.status === "revoked") {
3710
- throw new Error(
3711
- `Agent ${identity.name || identity.id} is revoked. Re-keying would hand a revoked agent fresh credentials \u2014 create a new agent instead.`
3712
- );
4027
+ printNextSteps(runtimeInstall, log, approval, registration.approval_url);
4028
+ const outcome = completionOutcome({
4029
+ runtimeInstall,
4030
+ delegateAddress: registration.delegate_address,
4031
+ hostedMcpUrl: registration.hosted_mcp_url,
4032
+ supersededAgentIds,
4033
+ supersededAgentsRetiredLocally,
4034
+ ...replacing ? { retiredAgentIds } : {},
4035
+ ...Object.keys(retirementMirrorErrors).length > 0 ? { retirementMirrorErrors } : {},
4036
+ existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
4037
+ ...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
4038
+ setupChallengeExpiresAt: setup.challenge.expires_at,
4039
+ approvalRequired: registration.agent_status === "pending_approval",
4040
+ approvalUrl: registration.approval_url
4041
+ });
4042
+ if (await recordConnectOutcome(deps, credentialPaths.directory, outcome)) {
4043
+ log(`Saved this run's outcome to ${exports.CONNECT_OUTCOME_FILENAME} in the agent's credential directory.`);
3713
4044
  }
4045
+ return {
4046
+ setupId: registration.setup_id,
4047
+ agentId: registration.agent_id,
4048
+ delegateAddress: registration.delegate_address,
4049
+ credentialPaths,
4050
+ outcome
4051
+ };
3714
4052
  }
3715
- var init_rekey = __esm({
3716
- "src/rekey.ts"() {
3717
- init_api();
3718
- init_runtime_install();
3719
- init_signer_runtime();
3720
- init_runtime_spec_override();
3721
- init_key();
3722
- init_redact();
3723
- init_rekey_messages();
3724
- init_server_names();
3725
- init_storage();
3726
- }
3727
- });
3728
-
3729
- // src/rekey-restart.ts
3730
- var rekey_restart_exports = {};
3731
- __export(rekey_restart_exports, {
3732
- restartGuidance: () => restartGuidance
3733
- });
3734
- function restartGuidance(runtime) {
3735
- const profile = runtime ? BY_RUNTIME[runtime] : void 0;
3736
- const lines = ["The new key is only live in a process that started after now.", ""];
3737
- if (profile) {
3738
- lines.push(profile.how);
3739
- for (const command of profile.commands) lines.push(` ${command}`);
3740
- if (profile.commands.length > 0) lines.push("");
3741
- } else {
3742
- lines.push(
3743
- runtime ? `No standard restart command is known for "${runtime}" \u2014 restart it the way you start it.` : "Pass --runtime <name> to get the restart command for a specific host."
3744
- );
3745
- lines.push("");
3746
- }
3747
- lines.push(SWEEP);
3748
- return { commands: profile?.commands ?? [], lines };
4053
+ function completionOutcome(input) {
4054
+ const { runtimeInstall } = input;
4055
+ const manualSetup = runtimeInstall.errorCode === "manual_runtime_setup_required";
4056
+ const nextAction2 = runtimeInstall.nextUserAction;
4057
+ const outcome = {
4058
+ schema_version: exports.CONNECT_OUTCOME_SCHEMA_VERSION,
4059
+ outcome: runtimeInstall.errorCode ? "action_required" : "complete",
4060
+ runtime: runtimeInstall.runtime,
4061
+ topology: runtimeInstall.runtimeMcpMode,
4062
+ configuration: {
4063
+ hosted_mcp: runtimeInstall.hostedMcpConfigured,
4064
+ local_signer: runtimeInstall.localSignerConfigured,
4065
+ local_mcp: runtimeInstall.localMcpConfigured
4066
+ },
4067
+ probe: { result: runtimeInstall.probeResult },
4068
+ activation: {
4069
+ restart_required: runtimeInstall.restartRequired,
4070
+ 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
4071
+ },
4072
+ next_action: nextAction2,
4073
+ approval: {
4074
+ required: input.approvalRequired,
4075
+ expires_at: null,
4076
+ // Only when there is something to approve AND the backend supplied a
4077
+ // link. Never synthesised here: the connector does not know the
4078
+ // dashboard's origin, and a guessed URL is worse than none — it is the
4079
+ // "do not invent one" rule the agent runbook states, applied to the
4080
+ // tool rather than the agent.
4081
+ ...input.approvalRequired && input.approvalUrl ? { url: input.approvalUrl } : {}
4082
+ },
4083
+ verification: {
4084
+ tools: ["haven_get_agent", "haven_get_allowances"],
4085
+ instruction: runtimeVerificationInstruction(runtimeInstall.runtime)
4086
+ },
4087
+ // The backend contract supplies a 20-byte address. If an unexpected
4088
+ // malformed value arrives, do not echo it into an automation-facing
4089
+ // record; the human log has already been redacted separately.
4090
+ delegate_address: /^0x[0-9a-fA-F]{40}$/.test(input.delegateAddress) ? shortAddress(input.delegateAddress) : "[delegate-address-redacted]",
4091
+ // The endpoint Connect wrote into the runtime's MCP config, verbatim — the
4092
+ // same string that already sits in the user's own config file. Passed
4093
+ // through the secret filter anyway: this is the automation contract, the
4094
+ // value is server-supplied, and belts are cheap (the #1589 stance).
4095
+ ...input.hostedMcpUrl ? { hosted_mcp_url: redactSecrets(input.hostedMcpUrl) } : {},
4096
+ // Always emitted on a completed run, empty list included: "no superseded
4097
+ // agents" is a fact a caller needs, and an omitted key would be
4098
+ // indistinguishable from an older connector that never reported it.
4099
+ superseded_agent_ids: input.supersededAgentIds ?? [],
4100
+ ...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
4101
+ ...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
4102
+ ...input.retirementMirrorErrors ? { retirement_mirror_errors: input.retirementMirrorErrors } : {},
4103
+ // #3122: always emitted on a completed run (empty list included), for the
4104
+ // same reason as superseded_agent_ids above.
4105
+ existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
4106
+ ...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
4107
+ ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
4108
+ ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
4109
+ };
4110
+ return outcome;
3749
4111
  }
3750
- var SWEEP, BY_RUNTIME;
3751
- var init_rekey_restart = __esm({
3752
- "src/rekey-restart.ts"() {
3753
- SWEEP = "Then restart EVERY other long-lived MCP host on this machine \u2014 gateways, TUI workers, editors. Each holds the wiring snapshot from its own start time, so after a re-key each one is still presenting the OLD API key and will fail with 401 until it restarts.";
3754
- BY_RUNTIME = {
3755
- "claude-code": {
3756
- commands: ["claude --continue"],
3757
- how: "Exit this Claude Code session and start a new one:"
3758
- },
3759
- "codex-cli": {
3760
- commands: ["codex resume --last"],
3761
- how: "Start a fresh Codex CLI session:"
3762
- },
3763
- "codex-desktop": {
3764
- commands: [],
3765
- how: "Quit Codex Desktop completely (not just the window) and reopen it."
3766
- },
3767
- "claude-desktop": {
3768
- commands: [],
3769
- how: "Quit Claude Desktop completely (not just the window) and reopen it."
3770
- },
3771
- hermes: {
3772
- commands: ["systemctl --user restart hermes-gateway", "/restart"],
3773
- how: "Restart the Hermes gateway, or run /restart inside a Hermes session. The first form is the one that matters if you run it as a user service:"
3774
- },
3775
- cursor: {
3776
- commands: [],
3777
- how: 'Cursor hot-reloads MCP config, so it will pick the new key up on its own. If a tool call still fails with 401, reload the window (Cmd/Ctrl+Shift+P \u2192 "Reload Window").'
3778
- },
3779
- vscode: {
3780
- commands: [],
3781
- how: 'VS Code hot-reloads MCP config. If a tool call still fails with 401, reload the window (Cmd/Ctrl+Shift+P \u2192 "Reload Window").'
3782
- },
3783
- "vscode-insiders": {
3784
- commands: [],
3785
- how: 'VS Code Insiders hot-reloads MCP config. If a tool call still fails with 401, reload the window (Cmd/Ctrl+Shift+P \u2192 "Reload Window").'
3786
- }
3787
- };
4112
+ async function installedClientHintFor(deps) {
4113
+ try {
4114
+ const candidates = await (deps.scanInstalledClients ?? scanInstalledClients)({ env: deps.env });
4115
+ const hint = installedClientHint(candidates);
4116
+ return hint.installedClients.length > 0 ? hint : {};
4117
+ } catch {
4118
+ return {};
3788
4119
  }
3789
- });
3790
-
3791
- // src/doctor.ts
3792
- var doctor_exports = {};
3793
- __export(doctor_exports, {
3794
- describeAccountAddressKey: () => describeAccountAddressKey,
3795
- rollUpLevel: () => rollUpLevel,
3796
- runDoctor: () => runDoctor,
3797
- runRepair: () => runRepair
3798
- });
3799
- function finalizeCheck(check) {
3800
- return { ...check, ok: check.level !== "failed" };
3801
4120
  }
3802
- function rollUpLevel(checks) {
3803
- if (checks.some((check) => check.level === "failed")) return "failed";
3804
- if (checks.some((check) => check.level === "advisory")) return "advisory";
3805
- return "ok";
4121
+ function installedClientProse(hint) {
4122
+ const found = hint.installedClients ?? [];
4123
+ if (found.length === 0) return "";
4124
+ const suggestion = hint.suggestedRuntime ? ` The likeliest is ${hint.suggestedRuntime}, but Haven will not choose for you \u2014 pass it yourself if it is right.` : " Haven will not choose between them for you.";
4125
+ return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
3806
4126
  }
3807
- function runtimeFlagFor(runtime) {
3808
- return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
4127
+ function runtimeSelectionPrompt(options, deps) {
4128
+ if (!interactivePromptAllowed(options, deps)) return void 0;
4129
+ return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
3809
4130
  }
3810
- async function resolveDoctorRuntime(input, directory) {
3811
- const explicit = input.runtime.trim();
3812
- if (explicit) return { runtime: explicit, origin: "flag" };
3813
- if (directory) {
3814
- const recorded = await readConnectOutcomeRuntime(directory);
3815
- if (recorded !== null && normalizeRuntimeName(recorded)) {
3816
- return { runtime: recorded, origin: "record" };
3817
- }
3818
- }
3819
- return { runtime: "", origin: "unknown" };
4131
+ function interactivePromptAllowed(options, deps) {
4132
+ if (options.interactive !== true) return false;
4133
+ return deps.isTty ?? Boolean(process.stdin.isTTY);
3820
4134
  }
3821
- async function discoverCredentialDirectory(homeDir, explicit) {
3822
- const root = explicit ? path.dirname(explicit) : path.join(homeDir, ".haven", "agents");
3823
- let entries = [];
3824
- try {
3825
- entries = await promises.readdir(root);
3826
- } catch {
3827
- return explicit ? { directory: explicit, others: [], parkedOnly: /* @__PURE__ */ new Set() } : { others: [], parkedOnly: /* @__PURE__ */ new Set() };
3828
- }
3829
- const candidates = [];
3830
- const tombstonedOnly = [];
3831
- const parkedOnly = [];
3832
- for (const entry of entries) {
3833
- const directory = path.join(root, entry);
3834
- try {
3835
- const s = await promises.stat(path.join(directory, "identity.json"));
3836
- candidates.push({ directory, mtimeMs: s.mtimeMs });
3837
- } catch {
3838
- try {
3839
- await promises.stat(path.join(directory, TOMBSTONE_FILENAME));
3840
- tombstonedOnly.push(directory);
3841
- } catch {
3842
- try {
3843
- await promises.stat(path.join(directory, REKEY_PENDING_FILENAME));
3844
- parkedOnly.push(directory);
3845
- } catch {
3846
- }
3847
- }
4135
+ function supersededIds(collision) {
4136
+ return collision.superseded.map((entry) => entry.agentId).join(", ");
4137
+ }
4138
+ async function resolveWiringCollision(collision, agentName, options, deps) {
4139
+ if (options.replaceExistingWiring) return { action: "replace" };
4140
+ if (interactivePromptAllowed(options, deps)) {
4141
+ const prompt = deps.promptWiringCollision ?? ((c, name) => promptWiringCollisionResolution(c, name, defaultPromptIo()));
4142
+ const resolution = await prompt(collision, agentName);
4143
+ if (resolution.action === "abort") {
4144
+ throw new exports.ConnectError(
4145
+ "wiring_collision_declined",
4146
+ `Setup stopped at your request: this machine is already wired to ${supersededIds(collision)} and you chose neither to replace that wiring nor to install alongside it. Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the connector command again with --replace to re-point haven / haven-signer at the new agent, or with --name <slug> (e.g. --name ${collision.suggestedServerName}) to install alongside.`,
4147
+ "rerun_connect_with_replace_or_name",
4148
+ { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4149
+ );
3848
4150
  }
4151
+ return resolution;
3849
4152
  }
3850
- const parkedOnlySet = new Set(parkedOnly);
3851
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
3852
- if (explicit) {
3853
- return {
3854
- directory: explicit,
3855
- others: [...candidates.map((c) => c.directory), ...tombstonedOnly, ...parkedOnly].filter((d) => d !== explicit),
3856
- parkedOnly: parkedOnlySet
3857
- };
3858
- }
3859
- if (candidates.length === 0 && tombstonedOnly.length === 0 && parkedOnly.length === 0) {
3860
- return { others: [], parkedOnly: parkedOnlySet };
3861
- }
3862
- return {
3863
- directory: candidates[0]?.directory,
3864
- others: [...candidates.slice(1).map((c) => c.directory), ...tombstonedOnly, ...parkedOnly],
3865
- parkedOnly: parkedOnlySet
4153
+ throw new exports.ConnectError(
4154
+ "wiring_collision",
4155
+ `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.`,
4156
+ "relay_wiring_collision_to_user",
4157
+ { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
4158
+ );
4159
+ }
4160
+ function failedConnectOutcome(runtimeHint, error) {
4161
+ const message = error instanceof Error ? error.message : "";
4162
+ const code = error instanceof exports.ConnectError ? error.code : /Node\.js >=/i.test(message) ? "unsupported_node_version" : /setup challenge.*expired|expired or invalid/i.test(message) ? "setup_challenge_expired_or_invalid" : /only available for Claude Code and Codex/i.test(message) ? "local_mcp_unsupported_runtime" : "connect_failed";
4163
+ const runtime = normalizeRuntime(runtimeHint);
4164
+ const nextAction2 = error instanceof exports.ConnectError ? error.nextAction : code === "setup_challenge_expired_or_invalid" ? "return_to_haven_for_fresh_setup" : code === "unsupported_node_version" ? "install_supported_node_and_rerun_connect" : code === "local_mcp_unsupported_runtime" ? "rerun_without_local_mcp" : "review_the_safe_error_output_and_start_a_fresh_haven_setup_if_needed";
4165
+ return {
4166
+ schema_version: exports.CONNECT_OUTCOME_SCHEMA_VERSION,
4167
+ outcome: "failed",
4168
+ runtime,
4169
+ topology: "unknown",
4170
+ configuration: { hosted_mcp: false, local_signer: false, local_mcp: false },
4171
+ probe: { result: "not_run" },
4172
+ activation: { restart_required: false, instruction: "Resolve the reported problem before activating Haven tools." },
4173
+ next_action: nextAction2,
4174
+ approval: { required: false, expires_at: null },
4175
+ verification: {
4176
+ tools: ["haven_get_agent", "haven_get_allowances"],
4177
+ instruction: "After a successful setup and activation, verify only with haven_get_agent and haven_get_allowances."
4178
+ },
4179
+ error: {
4180
+ code,
4181
+ next_action: nextAction2,
4182
+ // Only a ConnectError's message enters the JSON record: the vocabulary's
4183
+ // prose is connector-authored and safe to serialize, while a plain
4184
+ // Error can carry arbitrary server or filesystem detail (that stance is
4185
+ // pinned by test). Redaction stays on as belt-and-braces; plain-Error
4186
+ // runs still get their redacted message on stderr via the CLI mirror.
4187
+ ...error instanceof exports.ConnectError && message ? { message: redactForAutomation(message) } : {},
4188
+ ...error instanceof exports.ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
4189
+ ...error instanceof exports.ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
4190
+ ...error instanceof exports.ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {},
4191
+ ...error instanceof exports.ConnectError && error.details.supersededAgentIds ? { superseded_agent_ids: error.details.supersededAgentIds } : {},
4192
+ ...error instanceof exports.ConnectError && error.details.suggestedServerName ? { suggested_name: error.details.suggestedServerName } : {}
4193
+ }
3866
4194
  };
3867
4195
  }
3868
- function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bareOwnerExists) {
3869
- if (configText === null) return isPrimary;
3870
- if (slug) {
3871
- for (const name of [names.hosted, names.codexHosted, names.signer, names.codexSigner]) {
3872
- if (new RegExp(`(^|[."'\\s\\[])${name}(["'\\]:\\s]|$)`, "m").test(configText)) return true;
4196
+ function printSetupSummary(setup, log) {
4197
+ log(`Fetched Haven setup for ${setup.agent.name}.`);
4198
+ log(`Haven wallet: ${setup.haven_wallet.name} on ${setup.haven_wallet.network}.`);
4199
+ if (setup.agent_budget.length > 0) {
4200
+ for (const budget of setup.agent_budget) {
4201
+ log(
4202
+ `Agent budget: ${budget.allowance_amount} atomic ${budget.token_symbol} / ${budget.reset_period_min} minute reset.`
4203
+ );
3873
4204
  }
3874
- return false;
3875
4205
  }
3876
- if (sidecar?.wrapper_path && configText.includes(sidecar.wrapper_path)) return true;
3877
- if (bareOwnerExists) return false;
3878
- return isPrimary && Boolean(identity?.hosted_mcp_url && configText.includes(identity.hosted_mcp_url));
3879
- }
3880
- function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
3881
- return finalizeCheck(rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug));
4206
+ log(`Setup challenge expires at ${setup.challenge.expires_at}. If it expires, return to Haven for a fresh setup and rerun Connect \u2014 do not reuse or paste credentials.`);
3882
4207
  }
3883
- function rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug) {
3884
- const label = "Pending re-key";
3885
- const nameFlag = slug ? ` --name ${slug}` : "";
3886
- if (status.state === "unreadable") {
3887
- return {
3888
- id: "rekey_pending",
3889
- label,
3890
- level: "failed",
3891
- detail: `A re-key was started here but ${status.path} does not parse, so neither the address it generated nor when it started can be read. The file still holds what was a private key.`,
3892
- repair: `Delete ${status.path}, then start again: ${RERUN} --rekey${nameFlag}`
3893
- };
3894
- }
3895
- const started = status.startedAt ?? "an unknown time";
3896
- const address = status.newDelegateAddress ?? "unknown";
3897
- const completedOnHaven = hostedDelegateAddress !== void 0 && status.newDelegateAddress !== void 0 && hostedDelegateAddress.toLowerCase() === status.newDelegateAddress.toLowerCase();
3898
- if (completedOnHaven) {
3899
- return {
3900
- id: "rekey_pending",
3901
- label,
3902
- level: "failed",
3903
- detail: `A re-key started ${started} has COMPLETED on Haven \u2014 the agent's signing address is already ${address}, the one this machine generated \u2014 but the local half was never finished, so the credential files here still hold the old key. Parked at ${status.path}.` + (status.state === "expired" ? " The local file is also past its 24h TTL, which --rekey-finish refuses, so the finish command below will not accept it any more." : ""),
3904
- repair: status.state === "expired" ? `The parked key expired. Start again \u2014 ${RERUN} --rekey${nameFlag} \u2014 and re-run "Replace signing key" on the Haven agent page with the new address it prints.` : `Run: ${RERUN} --rekey-finish${nameFlag} --api-key <the key the agent page showed you>${runtimeFlagFor(runtime)}`
3905
- };
3906
- }
3907
- const wedgeNote = "Haven is NOT yet on this address, so the re-key did not complete. This machine cannot tell whether the on-chain revoke on the agent page already ran: if it did not, closing this costs nothing; if it did, the agent's old delegations are revoked, no new ones were issued, and only an owner re-grant restores its spend authority (#1868). Check the agent page before assuming the harmless case.";
3908
- if (status.state === "expired") {
3909
- return {
3910
- id: "rekey_pending",
3911
- label,
3912
- level: "failed",
3913
- detail: `A re-key started ${started} EXPIRED ${status.expiresAt ?? ""} without being finished. Its address was ${address}; the private half it generated is still on disk at ${status.path}. ` + wedgeNote,
3914
- repair: `Either delete ${status.path} to drop the parked key, or start over: ${RERUN} --rekey${nameFlag}. Connect never deletes it for you \u2014 an expired TTL is a refusal to USE the key, not a licence to destroy key material you may still be mid-flow on.`
3915
- };
4208
+ function approveBudgetCta(setup, approvalUrl) {
4209
+ const where = approvalUrl ? `at ${approvalUrl}` : "in the Haven dashboard";
4210
+ const budgetPhrase = setup.agent_budget.length > 0 ? setup.agent_budget.map((budget) => `up to ${describeSetupBudget(budget)}`).join(", ") : void 0;
4211
+ if (budgetPhrase) {
4212
+ return `\u2192 Action needed: approve this agent's budget \u2014 ${budgetPhrase} from ${setup.haven_wallet.name} on ${setup.haven_wallet.network} \u2014 ${where}. The approval button is live now; setup continues here in the meantime.`;
3916
4213
  }
3917
- return {
3918
- id: "rekey_pending",
3919
- label,
3920
- level: "ok",
3921
- detail: `A re-key started ${started} is still open (expires ${status.expiresAt ?? "unknown"}). Paste this address into "Replace signing key" on the Haven agent page: ${address}. Parked at ${status.path}. ` + wedgeNote
3922
- };
4214
+ return `\u2192 Action needed: approve this agent's budget ${where} \u2014 the approval button is live now. Setup continues here in the meantime.`;
3923
4215
  }
3924
- async function runtimeSpecOverrideCheck(directory, sidecar, env) {
3925
- const facts = [];
3926
- const installed = sidecar?.runtime_spec_override;
3927
- if (installed) {
3928
- facts.push(
3929
- `signer runtime installed under ${describeRuntimeSpecOverride(installed.specs)} (resolved: ${installed.resolved_specs.join(" ")}; directory key ${installed.directory_key})`
3930
- );
3931
- }
3932
- const mcpInstalled = await readMcpSidecarOverride(directory);
3933
- if (mcpInstalled) {
3934
- facts.push(
3935
- `local MCP runtime installed under ${describeRuntimeSpecOverride(mcpInstalled.specs)} (resolved: ${mcpInstalled.resolved_specs.join(" ")}; directory key ${mcpInstalled.directory_key})`
3936
- );
3937
- }
3938
- let shell;
3939
- try {
3940
- const active = resolveRuntimeSpecOverride(env);
3941
- if (active) shell = `set in this shell: ${describeRuntimeSpecOverride(active)} \u2014 a --repair from here installs these, not the pin`;
3942
- } catch (err) {
3943
- shell = err instanceof RuntimeSpecOverrideError ? `set in this shell but REFUSED: ${err.message}` : `set in this shell but unreadable: ${err instanceof Error ? err.message : String(err)}`;
3944
- }
3945
- if (shell) facts.push(shell);
3946
- if (facts.length === 0) return void 0;
3947
- const variables = Object.values(RUNTIME_SPEC_ENV).join(" / ");
3948
- return finalizeCheck({
3949
- id: "runtime_spec_override",
3950
- label: "Runtime spec override",
3951
- level: "failed",
3952
- detail: `runtime spec overridden \u2014 not the pinned manifest (${exports.MCP_RUNTIME_MANIFEST.signerPackage}@${exports.MCP_RUNTIME_MANIFEST.signerVersion}, ${exports.MCP_RUNTIME_MANIFEST.sdkPackage}@${exports.MCP_RUNTIME_MANIFEST.sdkVersion}). ${facts.join(". ")}.`,
3953
- repair: `Developer override (#2424). To return to the pinned manifest: unset ${variables}, then run ${RERUN} --doctor --repair --runtime <runtime>. If the override is intentional, this finding is the record of it.`
4216
+ function describeSetupBudget(budget) {
4217
+ return describeApprovedBudget({
4218
+ token_symbol: budget.token_symbol,
4219
+ token_address: budget.token_address,
4220
+ amount: budget.allowance_amount,
4221
+ reset_period_min: budget.reset_period_min
3954
4222
  });
3955
4223
  }
3956
- async function readMcpSidecarOverride(directory) {
3957
- try {
3958
- const parsed = JSON.parse(await promises.readFile(path.join(directory, "mcp-runtime.json"), "utf8"));
3959
- return parsed.runtime_spec_override;
3960
- } catch {
3961
- return void 0;
3962
- }
4224
+ function assertSetupChallengeIsUsable(expiresAt) {
4225
+ const expiresAtMs = Date.parse(expiresAt);
4226
+ if (!Number.isNaN(expiresAtMs) && expiresAtMs > Date.now()) return;
4227
+ throw new Error(
4228
+ "This Haven setup challenge is expired or invalid. Return to Haven, start a fresh connection, and rerun Connect. No local credentials were written."
4229
+ );
3963
4230
  }
3964
- async function readIdentity(directory) {
3965
- try {
3966
- return JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
3967
- } catch {
3968
- return void 0;
3969
- }
4231
+ function deadSetupTokenError(err) {
4232
+ if (!(err instanceof ConnectRequestError) || err.status !== 410 && err.status !== 401) return null;
4233
+ return new exports.ConnectError(
4234
+ "setup_challenge_expired_or_invalid",
4235
+ "This Haven setup token is expired or invalid \u2014 tokens are single-use and expire 30 minutes after the dashboard issues them, and a mistyped token reads the same way. Return to Haven, start a fresh connection, and run its new connector command. No local credentials were written.",
4236
+ "return_to_haven_for_fresh_setup"
4237
+ );
3970
4238
  }
3971
- function describeAccountAddressKey(identity, signerFile) {
3972
- const files = [identity, signerFile];
3973
- const has = (key) => files.some((f) => typeof f?.[key] === "string" && f[key].trim() !== "");
3974
- if (has("account_address")) return "account address stored as account_address";
3975
- if (has("safe_address")) {
3976
- return "account address stored under the pre-#2908 name safe_address \u2014 still read; the next --rekey or setup rewrites it as account_address";
3977
- }
3978
- return "no account address stored";
4239
+ function isExpiredSetupChallenge(err) {
4240
+ return err instanceof Error && /(?:setup )?challenge.*expir|expir.*(?:setup )?challenge/i.test(err.message);
3979
4241
  }
3980
- async function checksForAgent(entry, input, deps) {
3981
- const verdicts = await verdictsForAgent(entry, input, deps);
3982
- return {
3983
- checks: verdicts.checks.map(finalizeCheck),
3984
- ...verdicts.signerCapabilities ? { signerCapabilities: verdicts.signerCapabilities } : {}
4242
+ function secureLogger(log, redactPaths = false) {
4243
+ return (message) => {
4244
+ log(redactPaths ? redactForAutomation(message) : redactSecrets(message));
3985
4245
  };
3986
4246
  }
3987
- async function verdictsForAgent(entry, input, deps) {
3988
- const { directory, identity, sidecar } = entry;
3989
- const checks = [];
3990
- let signerCapabilities;
3991
- let signerFile;
3992
- try {
3993
- const parsed = JSON.parse(await promises.readFile(path.join(directory, "signer.json"), "utf8"));
3994
- signerFile = typeof parsed === "object" && parsed !== null ? parsed : void 0;
3995
- } catch {
3996
- signerFile = void 0;
4247
+ function printRuntimeInstall(result, log) {
4248
+ for (const message of result.messages) log(message);
4249
+ if (result.localMcpConfigured) {
4250
+ log("Configured local Haven MCP tools.");
4251
+ } else if (result.hostedMcpConfigured) {
4252
+ log("Configured hosted Haven MCP identity.");
4253
+ } else {
4254
+ log("Haven MCP tools still need runtime setup.");
3997
4255
  }
3998
- const credentialsOk = Boolean(identity?.api_key) && signerFile !== void 0;
3999
- checks.push({
4000
- id: "credentials",
4001
- label: "Agent credentials",
4002
- level: credentialsOk ? "ok" : "failed",
4003
- detail: credentialsOk ? `identity.json and signer.json parse (agent ${identity?.agent_id ?? "unknown"}; ${describeAccountAddressKey(identity, signerFile)})` : "identity.json or signer.json is missing or unparseable.",
4004
- ...credentialsOk ? {} : { repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` }
4005
- });
4006
- if (!sidecar) {
4007
- checks.push({
4008
- id: "signer_runtime",
4009
- label: "Signer runtime (preinstalled wrapper)",
4010
- level: "failed",
4011
- detail: "No signer-runtime.json sidecar \u2014 the pinned signer runtime was never prepared (or a pre-#1586 npx config).",
4012
- repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
4013
- });
4014
- } else if (sidecar.runtime_spec_override) {
4015
- const matches = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
4016
- signerVersion: sidecar.signer_version,
4017
- sdkVersion: sidecar.sdk_version
4018
- });
4019
- checks.push({
4020
- id: "signer_runtime",
4021
- label: "Signer runtime (preinstalled wrapper)",
4022
- level: matches ? "ok" : "failed",
4023
- detail: matches ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory} (override install \u2014 see runtime_spec_override)` : `Override runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
4024
- ...matches ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)} with the same HAVEN_*_SPEC variables set.` }
4025
- });
4256
+ if (result.localSignerConfigured) {
4257
+ log("Configured local Haven signer.");
4026
4258
  } else {
4027
- const intact = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
4028
- signerVersion: sidecar.signer_version,
4029
- sdkVersion: sidecar.sdk_version
4030
- });
4031
- const versionOk = sidecar.signer_version === exports.MCP_RUNTIME_MANIFEST.signerVersion;
4032
- const ok = intact && versionOk;
4033
- checks.push({
4034
- id: "signer_runtime",
4035
- label: "Signer runtime (preinstalled wrapper)",
4036
- level: ok ? "ok" : intact ? "advisory" : "failed",
4037
- detail: ok ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory}` : intact ? `Installed version ${sidecar.signer_version} does not match the connector's pinned ${exports.MCP_RUNTIME_MANIFEST.signerVersion} \u2014 intact, but outdated.` : `Runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
4038
- ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
4039
- });
4259
+ log("Local Haven signer still needs runtime setup.");
4040
4260
  }
4041
- const overrideCheck = await runtimeSpecOverrideCheck(directory, sidecar, deps.env ?? process.env);
4042
- if (overrideCheck) checks.push(overrideCheck);
4043
- const hostedUrl = identity?.hosted_mcp_url ?? (identity?.api_url ? `${identity.api_url}/mcp` : void 0);
4044
- if (identity?.api_key && hostedUrl) {
4045
- const probe = await (deps.probeHosted ?? probeHostedMcpTools)(identity.api_key, hostedUrl, deps.fetch);
4046
- checks.push({
4047
- id: "hosted_mcp",
4048
- label: "Hosted Haven MCP",
4049
- level: probe.status === "ok" ? "ok" : "failed",
4050
- detail: probe.status === "ok" ? `MCP tools endpoint is reachable (${hostedUrl}).` : `MCP tools endpoint probe failed: ${probe.status} (${hostedUrl}).`,
4051
- ...probe.status === "ok" ? {} : {
4052
- repair: "Check network access and runtime configuration for the hosted MCP URL, then re-run --doctor."
4053
- }
4054
- });
4055
- } else {
4056
- checks.push({
4057
- id: "hosted_mcp",
4058
- label: "Hosted Haven MCP",
4059
- level: "failed",
4060
- detail: "No stored API key / hosted MCP URL to probe with.",
4061
- repair: `Re-run the full setup: ${RERUN} --setup <token>.`
4062
- });
4261
+ }
4262
+ function formatAtomicAmount(atomic, decimals) {
4263
+ const s = atomic.toString().padStart(decimals + 1, "0");
4264
+ const intPart = s.slice(0, s.length - decimals) || "0";
4265
+ const fracPart = s.slice(s.length - decimals).replace(/0+$/, "");
4266
+ return fracPart ? `${intPart}.${fracPart}` : intPart;
4267
+ }
4268
+ function describeResetPeriod(resetPeriodMin) {
4269
+ if (resetPeriodMin === 1440) return "per day";
4270
+ if (resetPeriodMin === 10080) return "per week";
4271
+ if (resetPeriodMin === 43200) return "per month";
4272
+ if (resetPeriodMin === 60) return "per hour";
4273
+ if (resetPeriodMin === 0) return "in total";
4274
+ return `every ${resetPeriodMin} minutes`;
4275
+ }
4276
+ function describeApprovedBudget(budget) {
4277
+ const token = sdk.resolveTokenFromAddress(budget.token_address);
4278
+ const amount = token ? `${formatAtomicAmount(BigInt(budget.amount), token.decimals)} ${budget.token_symbol}` : `${budget.amount} ${budget.token_symbol} (atomic units)`;
4279
+ return `${amount} ${describeResetPeriod(budget.reset_period_min)}`;
4280
+ }
4281
+ function describeWaitBound(timeoutMs) {
4282
+ if (timeoutMs >= 12e4) {
4283
+ const minutes = Math.round(timeoutMs / 6e4);
4284
+ return `${minutes} minutes`;
4063
4285
  }
4064
- const localDelegate = typeof signerFile?.delegate_address === "string" ? signerFile.delegate_address : void 0;
4065
- let hostedDelegateAddress;
4066
- if (identity?.api_key && identity.api_url) {
4067
- const probe = await (deps.probeHostedIdentity ?? probeHostedAgentIdentity)(
4068
- identity.api_key,
4069
- identity.api_url,
4070
- deps.fetch
4071
- );
4072
- if (probe.status === "ok") hostedDelegateAddress = probe.delegateAddress;
4073
- if (probe.status !== "ok") {
4074
- checks.push({
4075
- id: "identity_match",
4076
- label: "Hosted identity matches the local signing key",
4077
- level: "failed",
4078
- detail: probe.status === "unauthorized" ? "The stored API key was rejected, so the agent it authenticates as cannot be compared with the local signing key." : `Could not read the hosted identity (${probe.status}) \u2014 the comparison did not happen, so it cannot be reported as a match.`,
4079
- repair: probe.status === "unauthorized" ? `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` : `Restore network access to the Haven API, then re-run: ${RERUN} --doctor${runtimeFlagFor(input.runtime)}`
4080
- });
4081
- } else if (!localDelegate) {
4082
- checks.push({
4083
- id: "identity_match",
4084
- label: "Hosted identity matches the local signing key",
4085
- level: "failed",
4086
- detail: "signer.json holds no delegate_address to compare against the hosted identity.",
4087
- repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.`
4088
- });
4089
- } else {
4090
- const same = probe.delegateAddress?.toLowerCase() === localDelegate.toLowerCase();
4091
- checks.push({
4092
- id: "identity_match",
4093
- label: "Hosted identity matches the local signing key",
4094
- level: same ? "ok" : "failed",
4095
- detail: same ? `The stored API key authenticates as the agent whose signing key is in this directory (${shortAddress(localDelegate)}).` : `MISMATCH: the stored API key authenticates as agent ${probe.agentId ?? "unknown"} with delegate ${shortAddress(probe.delegateAddress ?? "unknown")}, but signer.json here holds ${shortAddress(localDelegate)}. This runtime would quote as one agent and sign as another.`,
4096
- ...same ? {} : {
4097
- repair: `Re-run setup for this agent so its API key and signing key come from one run: ${RERUN} --setup <token>. Do not hand-edit either file.`
4098
- }
4099
- });
4286
+ return `${Math.round(timeoutMs / 1e3)}s`;
4287
+ }
4288
+ async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
4289
+ const intervalMs = options.intervalMs ?? 5e3;
4290
+ const timeoutMs = options.timeoutMs ?? 18e4;
4291
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve11) => setTimeout(resolve11, ms)));
4292
+ const maxPolls = Math.max(1, Math.floor(timeoutMs / intervalMs));
4293
+ const remindEvery = Math.max(1, Math.floor(3e4 / intervalMs));
4294
+ let waitingAnnounced = false;
4295
+ const announceWaiting = () => {
4296
+ if (waitingAnnounced) return;
4297
+ waitingAnnounced = true;
4298
+ log("Now waiting for you to approve the budget in the Haven dashboard \u2014 approval is what unlocks the agent's Haven tools\u2026");
4299
+ };
4300
+ for (let i = 0; i < maxPolls; i++) {
4301
+ if (i > 0) await sleep(intervalMs);
4302
+ let status;
4303
+ try {
4304
+ status = await api.getConnectorStatus(setupId, apiKey);
4305
+ } catch (err) {
4306
+ if (err instanceof ConnectRequestError && (err.status === 401 || err.status === 404)) {
4307
+ log("This setup ended in Haven \u2014 start a fresh connection from the dashboard when ready.");
4308
+ return "ended";
4309
+ }
4310
+ announceWaiting();
4311
+ continue;
4100
4312
  }
4101
- }
4102
- const pending = await inspectRekeyPending(directory, deps.now?.() ?? Date.now());
4103
- if (pending) {
4104
- checks.push(rekeyPendingCheck(pending, hostedDelegateAddress, input.runtime, sidecar?.server_name));
4105
- }
4106
- if (sidecar) {
4107
- const consent = await getLocalSignerConsentStatus(path.join(directory, "signer.json"));
4108
- if (!consent.acknowledged) {
4109
- checks.push({
4110
- id: "signer_process",
4111
- label: "Signer stdio handshake",
4112
- level: "failed",
4113
- detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
4114
- repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
4115
- });
4116
- } else {
4117
- const probe = await (deps.probeSignerTools ?? probeLocalMcpTools)(
4118
- sidecar.wrapper_path,
4119
- [],
4120
- exports.MCP_RUNTIME_MANIFEST.requiredSignerTools
4313
+ if (status.status === "active") {
4314
+ log(
4315
+ status.approved_budget ? `Budget approved \u{1F389} \u2014 I can now spend up to ${describeApprovedBudget(status.approved_budget)} from your Haven wallet.` : "Budget approved \u{1F389} \u2014 the agent can now spend within its Haven rules."
4316
+ );
4317
+ return "approved";
4318
+ }
4319
+ if (status.status === "cancelled" || status.status === "expired" || status.status === "failed") {
4320
+ log(`This setup ended in Haven (${status.status}) \u2014 start a fresh connection from the dashboard when ready.`);
4321
+ return "ended";
4322
+ }
4323
+ announceWaiting();
4324
+ if (i > 0 && i % remindEvery === 0) {
4325
+ const elapsedSeconds = Math.round(i * intervalMs / 1e3);
4326
+ log(
4327
+ `Still waiting for budget approval in Haven \u2014 ${elapsedSeconds}s elapsed, checking every ${Math.round(intervalMs / 1e3)}s. I'll stop waiting after ${describeWaitBound(timeoutMs)}; you can approve later and the tools unlock then.`
4121
4328
  );
4122
- const experimental = probe.capabilities?.experimental ?? probe.capabilities;
4123
- const compat = experimental?.["haven/signer-compatibility"];
4124
- signerCapabilities = compat ? { "haven/signer-compatibility": compat } : void 0;
4125
- const compatDetail = compat ? ` Compat: x402 expected-context v${JSON.stringify(compat.x402_expected_context_versions ?? "?")}.` : "";
4126
- checks.push({
4127
- id: "signer_process",
4128
- label: "Signer stdio handshake",
4129
- level: probe.status === "ok" ? "ok" : "failed",
4130
- detail: probe.status === "ok" ? `Signer started, listed ${probe.toolNames?.length ?? 0} tools${probe.serverInfo?.version ? ` (v${probe.serverInfo.version})` : ""}.${compatDetail}` : `Handshake failed: ${probe.status}.`,
4131
- ...probe.status === "ok" ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}` }
4132
- });
4133
4329
  }
4134
- } else {
4135
- checks.push({
4136
- id: "signer_process",
4137
- label: "Signer stdio handshake",
4138
- level: "failed",
4139
- detail: "Skipped \u2014 no prepared signer runtime to probe.",
4140
- repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input.runtime)}`
4141
- });
4142
4330
  }
4143
- return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
4331
+ log(
4332
+ "Budget approval is still pending in Haven. Approve it in the dashboard whenever you are ready \u2014 the agent tools unlock the moment you do. Verify later with the read-only haven_get_agent tool."
4333
+ );
4334
+ return "pending";
4144
4335
  }
4145
- async function runDoctor(input, deps = {}) {
4146
- const homeDir = deps.homeDir ?? os.homedir();
4147
- const checks = [];
4148
- let signerCapabilities;
4149
- const { directory, others, parkedOnly } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
4150
- const resolution = await resolveDoctorRuntime(input, directory);
4151
- const runtime = resolution.runtime;
4152
- const input2 = { ...input, runtime };
4153
- const normalizedRuntime = normalizeRuntimeName(input2.runtime);
4154
- const configPath = runtimeConfigPathFor(normalizedRuntime ?? input2.runtime, homeDir);
4155
- let configText = null;
4156
- if (configPath !== null) {
4336
+ function completionHandoffLines(result, approval, approvalUrl) {
4337
+ const approveStep = approvalUrl ? `Approve the budget at ${approvalUrl}. Approval \u2014 not restarting \u2014 unlocks Haven tools.` : "Return to Haven and approve the budget. Approval \u2014 not restarting \u2014 unlocks Haven tools.";
4338
+ if (result.errorCode === "manual_runtime_setup_required") {
4339
+ return [
4340
+ "Next steps:",
4341
+ `1. ${approveStep}`,
4342
+ "2. Finish the manual MCP setup using the secret-free file references printed above, then start a fresh session in your runtime.",
4343
+ `3. ${runtimeVerificationInstruction(result.runtime)}`
4344
+ ];
4345
+ }
4346
+ if (result.errorCode) {
4347
+ return [
4348
+ "Recovery: runtime setup is not complete. Resolve the reported problem, then return to Haven for a fresh connection and run its new connector command. Do not manually edit runtime config or paste credentials into prompts, logs, or config."
4349
+ ];
4350
+ }
4351
+ if (approval === "ended") {
4352
+ return [
4353
+ "Next step: this setup ended in Haven before the budget was approved, so there is nothing left to approve on it \u2014 start a fresh connection from the dashboard when ready."
4354
+ ];
4355
+ }
4356
+ const profile = runtimeProfile(result.runtime);
4357
+ const activation = activationInstructionWithWhy(profile);
4358
+ if (approval === "approved") {
4359
+ return [
4360
+ "Next steps \u2014 the budget is already approved, so there is nothing more to do in Haven:",
4361
+ `1. ${activation}`,
4362
+ `2. ${runtimeVerificationInstruction(result.runtime)}`
4363
+ ];
4364
+ }
4365
+ return [
4366
+ "Next steps:",
4367
+ `1. ${approveStep}`,
4368
+ `2. ${activation}`,
4369
+ `3. ${runtimeVerificationInstruction(result.runtime)}`
4370
+ ];
4371
+ }
4372
+ function activationInstructionWithWhy(profile) {
4373
+ if (profile.restartMode === "restart-session") {
4374
+ return `${profile.activationInstruction} (The entries are already written and verified \u2014 ${profile.label} only reads MCP config when a session starts, which is why this step is still needed.)`;
4375
+ }
4376
+ if (profile.restartMode === "restart-app") {
4377
+ return `${profile.activationInstruction} (The entries are already written and verified \u2014 ${profile.label} only reads MCP config at app launch, which is why this step is still needed.)`;
4378
+ }
4379
+ return profile.activationInstruction;
4380
+ }
4381
+ async function listExistingKeyedAgents(baseDir) {
4382
+ const root = defaultCredentialRoot(baseDir);
4383
+ let entries = [];
4384
+ try {
4385
+ entries = await promises.readdir(root);
4386
+ } catch {
4387
+ return [];
4388
+ }
4389
+ const out = [];
4390
+ for (const entry of entries) {
4391
+ const directory = path.join(root, entry);
4157
4392
  try {
4158
- configText = await promises.readFile(configPath, "utf8");
4393
+ const identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
4394
+ if (typeof identity.api_key !== "string" || identity.api_key.length === 0) continue;
4395
+ let agent = {};
4396
+ try {
4397
+ agent = JSON.parse(await promises.readFile(path.join(directory, "agent.json"), "utf8"));
4398
+ } catch {
4399
+ }
4400
+ const { accountAddress } = readStoredAccountAddress(identity, agent);
4401
+ out.push({ agentId: typeof identity.agent_id === "string" ? identity.agent_id : entry, directory, accountAddress: accountAddress ?? null });
4159
4402
  } catch {
4160
- configText = null;
4161
4403
  }
4162
4404
  }
4163
- const allDirectories = directory ? [directory, ...others] : others;
4164
- let bareOwnerExists = false;
4165
- for (const dir of allDirectories) {
4166
- const sidecar = await readRuntimeSidecar(dir);
4167
- if (!sidecar?.server_name && sidecar?.wrapper_path && configText?.includes(sidecar.wrapper_path)) {
4168
- bareOwnerExists = true;
4169
- break;
4170
- }
4405
+ return out;
4406
+ }
4407
+ async function listOtherAgentIds(baseDir, currentDirectory) {
4408
+ const root = defaultCredentialRoot(baseDir);
4409
+ let entries = [];
4410
+ try {
4411
+ entries = await promises.readdir(root);
4412
+ } catch {
4413
+ return null;
4171
4414
  }
4172
- const inventory = [];
4173
- const capabilitiesByDirectory = /* @__PURE__ */ new Map();
4174
- const primaryChecksById = /* @__PURE__ */ new Map();
4175
- for (const dir of allDirectories) {
4176
- const identity = await readIdentity(dir);
4177
- const sidecar = await readRuntimeSidecar(dir);
4178
- const tombstone = await readAgentTombstone(dir);
4179
- const slug = sidecar?.server_name;
4180
- const names = serverNamesFor(slug);
4181
- const rekeyPending = await inspectRekeyPending(dir, deps.now?.() ?? Date.now());
4182
- if (!identity?.api_key) {
4183
- const agentId = tombstone?.agent_id ?? rekeyPending?.agentId;
4184
- inventory.push({
4185
- ...slug ? { slug } : {},
4186
- ...agentId ? { agentId } : {},
4187
- directory: dir,
4188
- // A tombstone is a deliberate record and outranks the discovery tell:
4189
- // a retired directory that also holds a parked key stays `retired`.
4190
- classification: tombstone ? "retired" : parkedOnly.has(dir) ? "parked" : "orphaned",
4191
- checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)] : [],
4192
- ...rekeyPending ? { rekeyPending } : {}
4193
- });
4415
+ const ids = [];
4416
+ for (const entry of entries) {
4417
+ if (path.join(root, entry) === currentDirectory) continue;
4418
+ const identityPath = path.join(root, entry, "identity.json");
4419
+ try {
4420
+ await promises.stat(identityPath);
4421
+ } catch {
4194
4422
  continue;
4195
4423
  }
4196
- const wired = agentIsWired(configText, names, slug, identity, sidecar, dir === directory, bareOwnerExists);
4197
- const entry = {
4198
- ...slug ? { slug } : {},
4199
- ...identity.agent_id ? { agentId: identity.agent_id } : {},
4200
- directory: dir,
4201
- classification: wired ? "wired" : "superseded",
4202
- checks: [],
4203
- ...rekeyPending ? { rekeyPending } : {}
4204
- };
4205
- if (wired) {
4206
- const result = await checksForAgent({ directory: dir, identity, sidecar }, input2, deps);
4207
- entry.checks = result.checks;
4208
- capabilitiesByDirectory.set(dir, result.signerCapabilities);
4209
- } else if (rekeyPending) {
4210
- entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)];
4424
+ try {
4425
+ const identity = JSON.parse(await promises.readFile(identityPath, "utf8"));
4426
+ ids.push(identity.agent_id ?? entry);
4427
+ } catch {
4428
+ ids.push(entry);
4211
4429
  }
4212
- inventory.push(entry);
4213
4430
  }
4214
- const wiredDirectories = inventory.filter((entry) => entry.classification === "wired").map((entry) => entry.directory);
4215
- const primaryDirectory = input.credentialsDir ? directory : wiredDirectories.includes(directory ?? "") ? directory : wiredDirectories[0] ?? directory;
4216
- if (primaryDirectory) {
4217
- const primaryEntry = inventory.find((entry) => entry.directory === primaryDirectory);
4218
- signerCapabilities = capabilitiesByDirectory.get(primaryDirectory);
4219
- for (const check of primaryEntry?.checks ?? []) primaryChecksById.set(check.id, check);
4431
+ return ids;
4432
+ }
4433
+ function printNextSteps(result, log, approval, approvalUrl) {
4434
+ for (const line of completionHandoffLines(result, approval, approvalUrl)) log(line);
4435
+ }
4436
+ exports.CONNECTOR_VERSION = void 0; var CONNECTOR_CLIENT_IDENTITY; exports.CONNECT_OUTCOME_SCHEMA_VERSION = void 0; var failureOutcomesByError, RERUN_HINT;
4437
+ var init_runtime = __esm({
4438
+ "src/runtime.ts"() {
4439
+ init_api();
4440
+ init_key();
4441
+ init_redact();
4442
+ init_server_names();
4443
+ init_storage();
4444
+ init_runtime_install();
4445
+ init_runtime_registry();
4446
+ init_connect_error();
4447
+ init_installed_clients();
4448
+ init_wiring_collision();
4449
+ init_unwire();
4450
+ init_tombstone();
4451
+ init_local_mcp_runtime();
4452
+ init_runtime_manifest();
4453
+ exports.CONNECTOR_VERSION = "0.6.0-alpha.0";
4454
+ CONNECTOR_CLIENT_IDENTITY = `@haven_ai/connect/${exports.CONNECTOR_VERSION}`;
4455
+ exports.CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4456
+ failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4457
+ RERUN_HINT = sdk.connectorRerunCommand();
4220
4458
  }
4221
- if (!primaryDirectory) {
4222
- checks.push({
4223
- id: "credentials",
4224
- label: "Agent credentials",
4225
- level: "failed",
4226
- detail: "No agent credential directory with an identity.json under ~/.haven/agents.",
4227
- repair: `Run the full setup once: ${RERUN} --setup <token from the Haven dashboard>.`
4228
- });
4229
- } else {
4230
- const primaryIdentity = await readIdentity(primaryDirectory);
4231
- const primarySidecar = await readRuntimeSidecar(primaryDirectory);
4232
- if (!primaryChecksById.has("credentials")) {
4233
- const result = await checksForAgent(
4234
- { directory: primaryDirectory, identity: primaryIdentity, sidecar: primarySidecar },
4235
- input2,
4236
- deps
4237
- );
4238
- signerCapabilities = result.signerCapabilities;
4239
- for (const check of result.checks) primaryChecksById.set(check.id, check);
4459
+ });
4460
+
4461
+ // src/rekey-messages.ts
4462
+ var REKEY_FINISH_NEEDS_API_KEY;
4463
+ var init_rekey_messages = __esm({
4464
+ "src/rekey-messages.ts"() {
4465
+ REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
4466
+ }
4467
+ });
4468
+
4469
+ // src/prune-runtimes.ts
4470
+ var prune_runtimes_exports = {};
4471
+ __export(prune_runtimes_exports, {
4472
+ normalizeRuntimePath: () => normalizeRuntimePath,
4473
+ pruneSignerRuntimes: () => pruneSignerRuntimes,
4474
+ referencedRuntimeDirectories: () => referencedRuntimeDirectories,
4475
+ rollUpPruneLevel: () => rollUpPruneLevel,
4476
+ signerRuntimeRoot: () => signerRuntimeRoot
4477
+ });
4478
+ function signerRuntimeRoot(homeDir) {
4479
+ return path.join(homeDir, ".haven", "signer-runtime");
4480
+ }
4481
+ function classifyKey(key) {
4482
+ if (key.startsWith("override-")) return "override";
4483
+ if (key === exports.MCP_RUNTIME_MANIFEST.signerVersion || /^\d+\.\d+\.\d+/.test(key)) return "version";
4484
+ return "unknown";
4485
+ }
4486
+ async function directoryBytes(directory) {
4487
+ let total = 0;
4488
+ const walk = async (dir) => {
4489
+ let entries = [];
4490
+ try {
4491
+ entries = await promises.readdir(dir, { withFileTypes: true });
4492
+ } catch {
4493
+ return;
4240
4494
  }
4241
- for (const id of ["credentials", "signer_runtime", "runtime_spec_override"]) {
4242
- const check = primaryChecksById.get(id);
4243
- if (check) checks.push(check);
4495
+ for (const entry of entries) {
4496
+ const path$1 = path.join(dir, entry.name);
4497
+ if (entry.isDirectory()) await walk(path$1);
4498
+ else if (entry.isFile()) {
4499
+ try {
4500
+ total += (await promises.stat(path$1)).size;
4501
+ } catch {
4502
+ }
4503
+ }
4244
4504
  }
4505
+ };
4506
+ await walk(directory);
4507
+ return total;
4508
+ }
4509
+ async function normalizeRuntimePath(path$1) {
4510
+ const resolved = path.resolve(path$1).replace(/[\\/]+$/, "");
4511
+ try {
4512
+ return await promises.realpath(resolved);
4513
+ } catch {
4514
+ return resolved;
4245
4515
  }
4246
- const runtimeOwnsNoConfig = normalizedRuntime !== null && configPath === null;
4247
- if (configPath === null && input2.runtime !== "" && normalizedRuntime === null) {
4248
- checks.push({
4249
- id: "runtime_config",
4250
- label: "Runtime MCP config",
4251
- level: "failed",
4252
- detail: `Runtime '${input2.runtime}' is not one the connector recognises. The runtime config was NOT checked. Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`,
4253
- repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4254
- });
4255
- } else if (configPath === null && input2.runtime === "") {
4256
- checks.push({
4257
- id: "runtime_config",
4258
- label: "Runtime MCP config",
4259
- level: "failed",
4260
- detail: `Runtime is unknown \u2014 no runtime flag was given and the connector's record in ${primaryDirectory ?? input.credentialsDir ?? "~/.haven/agents"} carries no resolvable ${exports.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(", ")}.`,
4261
- repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4262
- });
4263
- } else if (configPath === null) {
4264
- checks.push({
4265
- id: "runtime_config",
4266
- label: "Runtime MCP config",
4267
- level: "ok",
4268
- 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.`
4269
- });
4270
- } else if (configText === null) {
4271
- checks.push({
4272
- id: "runtime_config",
4273
- label: "Runtime MCP config",
4274
- level: "failed",
4275
- detail: `No runtime config at ${configPath}.`,
4276
- repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}`
4277
- });
4278
- } else {
4279
- const primaryIdentity = await readIdentity(primaryDirectory ?? "");
4280
- const primarySidecar = primaryDirectory ? await readRuntimeSidecar(primaryDirectory) : null;
4281
- const hasHaven = primaryIdentity?.hosted_mcp_url ? configText.includes(primaryIdentity.hosted_mcp_url) : configText.includes("haven");
4282
- const signerViaNpx = configText.includes("@haven_ai/signer");
4283
- const wrapperReferenced = primarySidecar ? configText.includes(primarySidecar.wrapper_path) : false;
4284
- const ok = hasHaven && !signerViaNpx && (primarySidecar ? wrapperReferenced : true);
4285
- checks.push({
4286
- id: "runtime_config",
4287
- label: "Runtime MCP config",
4288
- level: ok ? "ok" : "failed",
4289
- detail: ok ? `Config at ${configPath} references the hosted server and the prepared signer wrapper.` : signerViaNpx ? `Config at ${configPath} still launches the signer via npx \u2014 the pre-#1586 shape that cannot start under a 120s startup timeout.` : `Config at ${configPath} is missing the Haven entries${primarySidecar && !wrapperReferenced ? " (or references a different signer wrapper)" : ""}.`,
4290
- ...ok ? {} : { repair: `Run: ${RERUN} --doctor --repair${runtimeFlagFor(input2.runtime)}` }
4291
- });
4292
- }
4293
- for (const id of ["hosted_mcp", "identity_match", "rekey_pending"]) {
4294
- const check = primaryChecksById.get(id);
4295
- if (check) checks.push(check);
4296
- }
4297
- const otherEntries = inventory.filter((entry) => entry.directory !== primaryDirectory);
4298
- if (otherEntries.length > 0) {
4299
- const live = [];
4300
- const revoked = [];
4301
- const unverifiable = [];
4302
- const retired = [];
4303
- for (const entry of otherEntries) {
4304
- const identity = await readIdentity(entry.directory);
4305
- const tombstone = await readAgentTombstone(entry.directory);
4306
- const otherAgent = entry.agentId ?? path.basename(entry.directory);
4307
- if (!identity?.api_key || !identity.api_url) {
4308
- if (tombstone) retired.push(`${otherAgent} (retired ${tombstone.retired_at})`);
4309
- else unverifiable.push(`${otherAgent} (no stored key/API URL to probe)`);
4310
- continue;
4516
+ }
4517
+ function escapeRegExp(text) {
4518
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4519
+ }
4520
+ async function referencedRuntimeDirectories(homeDir, credentialsDir) {
4521
+ const roots = /* @__PURE__ */ new Set([path.join(homeDir, ".haven", "agents")]);
4522
+ if (credentialsDir) roots.add(path.dirname(path.resolve(credentialsDir)));
4523
+ const referenced = /* @__PURE__ */ new Map();
4524
+ const runtimeRoot = await normalizeRuntimePath(signerRuntimeRoot(homeDir));
4525
+ const rootSpellings = [.../* @__PURE__ */ new Set([path.resolve(signerRuntimeRoot(homeDir)), runtimeRoot])];
4526
+ const wrapperRe = new RegExp(`(?:${rootSpellings.map(escapeRegExp).join("|")})[\\/]+([^\\/'"\\s]+)`, "g");
4527
+ const add = async (runtimeDirectory, by) => {
4528
+ const key = await normalizeRuntimePath(runtimeDirectory);
4529
+ const list = referenced.get(key) ?? [];
4530
+ if (!list.includes(by)) list.push(by);
4531
+ referenced.set(key, list);
4532
+ };
4533
+ for (const root of roots) {
4534
+ let entries = [];
4535
+ try {
4536
+ entries = await promises.readdir(root);
4537
+ } catch {
4538
+ continue;
4539
+ }
4540
+ for (const entry of entries) {
4541
+ const directory = path.join(root, entry);
4542
+ try {
4543
+ const sidecar = JSON.parse(await promises.readFile(path.join(directory, "signer-runtime.json"), "utf8"));
4544
+ if (typeof sidecar.runtime_directory === "string" && sidecar.runtime_directory.length > 0) {
4545
+ await add(sidecar.runtime_directory, directory);
4546
+ }
4547
+ } catch {
4548
+ }
4549
+ try {
4550
+ const wrapper = await promises.readFile(path.join(directory, "bin", "haven-signer.mjs"), "utf8");
4551
+ for (const match of wrapper.matchAll(wrapperRe)) await add(path.join(runtimeRoot, match[1]), directory);
4552
+ } catch {
4311
4553
  }
4312
- const suffix = tombstone ? " [tombstoned \u2014 key material still present]" : "";
4313
- const probe = await (deps.probeHostedIdentity ?? probeHostedAgentIdentity)(
4314
- identity.api_key,
4315
- identity.api_url,
4316
- deps.fetch
4317
- );
4318
- if (probe.status === "ok") live.push({ label: `${otherAgent}${suffix}`, entry });
4319
- else if (probe.status === "unauthorized") revoked.push(`${otherAgent}${suffix}`);
4320
- else unverifiable.push(`${otherAgent} (${probe.status})${suffix}`);
4321
4554
  }
4322
- const parts = [];
4323
- if (live.length > 0) parts.push(`STILL SPEND-CAPABLE: ${live.map((item) => item.label).join(", ")}`);
4324
- if (revoked.length > 0) parts.push(`already revoked: ${revoked.join(", ")}`);
4325
- if (retired.length > 0) parts.push(`tombstoned (keys removed): ${retired.join(", ")}`);
4326
- if (unverifiable.length > 0) parts.push(`could not verify: ${unverifiable.join(", ")}`);
4327
- const knownIds = new Set([...inventory].map((e) => e.agentId ?? path.basename(e.directory)));
4328
- const ghostRecords = (await readTombstoneRecords(path.join(homeDir, ".haven", "tombstones"))).filter(
4329
- (rec) => !knownIds.has(rec.agent_id)
4330
- );
4331
- if (ghostRecords.length > 0) {
4332
- parts.push(
4333
- `retired records (dir removed): ${ghostRecords.map((rec) => `${rec.agent_id} (${rec.reason})`).join(", ")}`
4334
- );
4335
- }
4336
- const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
4337
- const classificationUnreliable = configText === null && runtimeOwnsNoConfig;
4338
- const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
4339
- checks.push({
4340
- id: "superseded_agents",
4341
- label: "Superseded agent credentials",
4342
- level: supersededLevel,
4343
- 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("; ")}.`,
4344
- ...supersededLevel === "failed" ? {
4345
- 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.`
4346
- } : supersededLevel === "advisory" ? {
4347
- 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.`
4348
- } : {}
4349
- });
4350
- }
4351
- const bindings = (await Promise.all(
4352
- inventory.filter((entry) => entry.classification !== "retired").map(async (entry) => ({ entry, binding: await readMcpServerBinding(entry.directory) }))
4353
- )).filter((item) => item.binding !== null);
4354
- const byName = /* @__PURE__ */ new Map();
4355
- for (const item of bindings) {
4356
- const list = byName.get(item.binding.server_name) ?? [];
4357
- list.push(item);
4358
- byName.set(item.binding.server_name, list);
4359
- }
4360
- const rebound = [...byName.entries()].filter(([, items]) => items.length > 1);
4361
- if (rebound.length > 0) {
4362
- const parts = rebound.map(([name, items]) => {
4363
- const ordered = [...items].sort((a, b) => a.binding.bound_at < b.binding.bound_at ? -1 : a.binding.bound_at > b.binding.bound_at ? 1 : 0);
4364
- const backends = new Set(ordered.map((i) => withoutUserinfo(i.binding.api_url)));
4365
- return `'${name}': ` + ordered.map((i) => `${i.binding.agent_id} on ${withoutUserinfo(i.binding.api_url)} at ${i.binding.bound_at} [${i.entry.classification}]`).join(" \u2192 ") + (backends.size > 1 ? " (BACKEND CHANGED)" : "");
4366
- });
4367
- checks.push({
4368
- id: "mcp_server_name_rebound",
4369
- label: "MCP server names bound more than once",
4370
- level: "advisory",
4371
- 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.`,
4372
- repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
4373
- });
4374
- }
4375
- const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
4376
- if (parkedElsewhere.length > 0) {
4377
- const abandoned = parkedElsewhere.filter((item) => item.pending.state !== "pending");
4378
- const describe = (item) => `${item.entry.slug ?? item.entry.agentId ?? path.basename(item.entry.directory)} (${item.pending.state}, ${item.pending.path})`;
4379
- checks.push({
4380
- id: "rekey_pending_elsewhere",
4381
- label: "Parked re-keys in other credential directories",
4382
- level: abandoned.length === 0 ? "ok" : "failed",
4383
- detail: abandoned.length > 0 ? `ABANDONED re-key key material outside the agent this report describes: ${abandoned.map(describe).join(", ")}. Each holds a private key that was generated for a re-key nobody finished.` : `${parkedElsewhere.length} other director(y/ies) hold an open pending re-key: ${parkedElsewhere.map(describe).join(", ")}.`,
4384
- ...abandoned.length > 0 ? {
4385
- repair: "Check the Haven agent page for each before deleting: if its on-chain revoke already ran, the agent has no spend authority until you re-grant it (#1868), and that is not visible from this machine. Connect never deletes key material for you."
4386
- } : {}
4387
- });
4388
- }
4389
- const signerProcess = primaryChecksById.get("signer_process");
4390
- if (signerProcess) checks.push(signerProcess);
4391
- const prune = await (deps.pruneSignerRuntimes ?? pruneSignerRuntimes)({ dryRun: true, measure: false }, { homeDir, credentialsDir: input.credentialsDir });
4392
- const unused = prune.entries.filter((entry) => entry.action === "would_remove");
4393
- if (unused.length > 0) {
4394
- checks.push({
4395
- id: "signer_runtime_unused",
4396
- label: "Unused signer-runtime directories",
4397
- level: "advisory",
4398
- detail: `${unused.length} signer-runtime director${unused.length === 1 ? "y" : "ies"} under ${prune.root} that no credential directory names: ${unused.map((entry) => entry.key).join(", ")}. Nothing is broken; they are left over from earlier pins or overrides (sizes: --prune-signer-runtimes --dry-run).`,
4399
- repair: `Run: ${RERUN} --prune-signer-runtimes (add --dry-run to list only).`
4400
- });
4401
4555
  }
4402
- const restart = restartRequiredForRuntime(input2.runtime, deps.env);
4403
- checks.push({
4404
- id: "restart",
4405
- label: "Runtime restart",
4406
- level: "ok",
4407
- detail: restart ? "This runtime loads MCP config at startup \u2014 restart it after any repair before expecting the tools to appear." : input2.runtime === "" ? "Runtime is unknown \u2014 whether a restart is needed cannot be determined. Re-run the doctor naming the runtime for a definitive answer." : "No restart requirement known for this runtime."
4408
- });
4409
- const wiredChecks = inventory.filter((entry) => entry.classification === "wired").flatMap((entry) => entry.checks);
4410
- const finalChecks = checks.map(finalizeCheck);
4411
- const level = rollUpLevel([...finalChecks, ...wiredChecks]);
4412
- return {
4413
- version: 1,
4414
- ok: level !== "failed",
4415
- level,
4416
- runtime: input2.runtime,
4417
- credentialDirectory: primaryDirectory,
4418
- checks: finalChecks,
4419
- agents: inventory,
4420
- ...signerCapabilities ? { signerCapabilities } : {}
4421
- };
4556
+ return referenced;
4422
4557
  }
4423
- async function runRepair(input, deps = {}) {
4558
+ function rollUpPruneLevel(entries) {
4559
+ if (entries.some((e) => e.level === "failed")) return "failed";
4560
+ if (entries.some((e) => e.level === "advisory")) return "advisory";
4561
+ return "ok";
4562
+ }
4563
+ async function pruneSignerRuntimes(input, deps = {}) {
4424
4564
  const homeDir = deps.homeDir ?? os.homedir();
4425
- const messages = [];
4426
- const { directory, others } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
4427
- if (others.length > 0) {
4428
- messages.push(`Note: ${others.length} other agent credential dir(s) exist \u2014 run --doctor for their status.`);
4429
- }
4430
- if (!directory) {
4431
- return {
4432
- ok: false,
4433
- messages: [`No agent credentials found to repair \u2014 run the full setup: ${RERUN} --setup <token>.`]
4434
- };
4435
- }
4436
- const resolution = await resolveDoctorRuntime(input, directory);
4437
- if (resolution.origin === "record") {
4438
- messages.push(`Runtime not given \u2014 resolved '${resolution.runtime}' from ${path.join(directory, exports.CONNECT_OUTCOME_FILENAME)}.`);
4439
- }
4440
- const runtime = resolution.runtime;
4441
- const input2 = { ...input, runtime };
4442
- if (runtime === "") {
4443
- return {
4444
- ok: false,
4445
- messages: [
4446
- "Runtime is unknown \u2014 no runtime flag was given and the connector record carries no resolvable runtime.",
4447
- `Repair rewrites the runtime config, so it will not guess. Re-run repair naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
4448
- ]
4449
- };
4450
- }
4451
- let identity;
4565
+ const root = signerRuntimeRoot(homeDir);
4566
+ const measure = input.measure ?? true;
4567
+ const referenced = await referencedRuntimeDirectories(homeDir, deps.credentialsDir);
4568
+ const pin = await normalizeRuntimePath(path.join(root, exports.MCP_RUNTIME_MANIFEST.signerVersion));
4569
+ const remove = deps.rm ?? (async (directory) => promises.rm(directory, { recursive: true, force: false }));
4570
+ const entries = [];
4571
+ let keys = [];
4452
4572
  try {
4453
- identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
4573
+ keys = (await promises.readdir(root, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name).sort();
4454
4574
  } catch {
4455
- return { ok: false, messages: ["identity.json is unreadable \u2014 re-run the full setup with a fresh token."] };
4456
- }
4457
- if (!identity.api_key || !(identity.hosted_mcp_url || identity.api_url)) {
4458
- return { ok: false, messages: ["identity.json lacks the stored API key / hosted URL \u2014 re-run the full setup."] };
4575
+ return { version: 1, root, dryRun: input.dryRun, entries, removed: 0, reclaimedBytes: 0, level: "ok" };
4459
4576
  }
4460
- const configPath = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir);
4461
- if (configPath) {
4577
+ let removed = 0;
4578
+ let reclaimedBytes = 0;
4579
+ for (const key of keys) {
4580
+ const directory = path.join(root, key);
4581
+ const normalized = await normalizeRuntimePath(directory);
4582
+ const referencedBy = referenced.get(normalized) ?? [];
4583
+ const kind = classifyKey(key);
4584
+ if (referencedBy.length > 0 || normalized === pin) {
4585
+ entries.push({
4586
+ directory,
4587
+ key,
4588
+ kind,
4589
+ bytes: 0,
4590
+ referencedBy,
4591
+ action: "kept",
4592
+ level: "ok",
4593
+ detail: referencedBy.length > 0 ? `kept \u2014 named by ${referencedBy.length} credential director${referencedBy.length === 1 ? "y" : "ies"} (sidecar or wrapper)` : "kept \u2014 the connector's current pinned version (what --repair installs)"
4594
+ });
4595
+ continue;
4596
+ }
4597
+ const bytes = measure ? await directoryBytes(directory) : 0;
4598
+ if (input.dryRun) {
4599
+ entries.push({
4600
+ directory,
4601
+ key,
4602
+ kind,
4603
+ bytes,
4604
+ referencedBy,
4605
+ action: "would_remove",
4606
+ level: "advisory",
4607
+ detail: `would remove \u2014 no credential directory names it (${kind}-keyed)`
4608
+ });
4609
+ continue;
4610
+ }
4462
4611
  try {
4463
- const existing = await promises.readFile(configPath, "utf8");
4464
- if (existing.includes("bin/haven-mcp") || existing.includes(".haven/mcp-runtime")) {
4465
- return {
4466
- ok: false,
4467
- messages: [
4468
- `The config at ${configPath} is the LOCAL-stdio topology (--local). Repair currently rewrites only the hosted+signer shape and will not touch it.`,
4469
- "Re-run your original connector command (with --local) to repair a local-stdio install."
4470
- ]
4471
- };
4472
- }
4473
- } catch {
4612
+ await remove(directory);
4613
+ removed += 1;
4614
+ reclaimedBytes += bytes;
4615
+ entries.push({ directory, key, kind, bytes, referencedBy, action: "removed", level: "ok", detail: `removed \u2014 no credential directory named it (${kind}-keyed)` });
4616
+ } catch (err) {
4617
+ entries.push({
4618
+ directory,
4619
+ key,
4620
+ kind,
4621
+ bytes,
4622
+ referencedBy,
4623
+ action: "failed",
4624
+ level: "failed",
4625
+ detail: `removal failed: ${err instanceof Error ? err.message : String(err)} \u2014 a signer process may still hold it open; stop it and re-run`
4626
+ });
4474
4627
  }
4475
4628
  }
4476
- const existingSidecar = await readRuntimeSidecar(directory);
4477
- const serverName = existingSidecar?.server_name;
4478
- const signerPath = path.join(directory, "signer.json");
4479
- const prepared = await prepareSignerRuntime(
4480
- { credentialDirectory: directory, signerPath, homeDir, serverName },
4481
- { runCommand: deps.runCommand, env: deps.env }
4482
- );
4483
- messages.push(...prepared.messages);
4484
- const names = serverNamesFor(serverName);
4485
- messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
4486
- const configResult = await writeRuntimeConfig({
4487
- // Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
4488
- // switches on the id, and the raw alias fell to its "manual runtime" arm
4489
- // — a repair that reported success while writing nothing.
4490
- runtime: normalizeRuntimeName(input2.runtime) ?? input2.runtime,
4491
- hostedMcpUrl: identity.hosted_mcp_url ?? `${identity.api_url}/mcp`,
4492
- apiKey: identity.api_key,
4493
- identityPath: path.join(directory, "identity.json"),
4494
- signerPath,
4495
- credentialDirectory: directory,
4496
- signerCommand: { command: prepared.command, args: prepared.args },
4497
- homeDir,
4498
- mode: "hosted",
4499
- serverName
4500
- });
4501
- messages.push(...configResult.messages);
4502
- messages.push("Repair complete \u2014 restart the runtime, then verify with --doctor.");
4503
- return { ok: true, messages };
4629
+ return { version: 1, root, dryRun: input.dryRun, entries, removed, reclaimedBytes, level: rollUpPruneLevel(entries) };
4504
4630
  }
4505
- var RERUN;
4506
- var init_doctor = __esm({
4507
- "src/doctor.ts"() {
4508
- init_prune_runtimes();
4631
+ var init_prune_runtimes = __esm({
4632
+ "src/prune-runtimes.ts"() {
4509
4633
  init_runtime_manifest();
4510
- init_probes();
4511
- init_signer_runtime();
4512
- init_runtime_spec_override();
4513
- init_config_writers();
4514
- init_runtime_registry();
4515
- init_signer_consent();
4516
- init_tombstone();
4517
- init_server_names();
4518
- init_storage();
4519
- init_redact();
4520
- RERUN = sdk.connectorRerunCommand();
4521
4634
  }
4522
4635
  });
4523
4636
 
4524
- // src/runtime.ts
4525
- init_api();
4526
- init_key();
4527
- init_redact();
4528
- init_server_names();
4529
- init_storage();
4530
- init_runtime_install();
4531
- init_runtime_registry();
4532
- init_connect_error();
4533
-
4534
- // src/installed-clients.ts
4535
- init_connect_error();
4536
- init_config_writers();
4537
- init_runtime_registry();
4538
- var SCAN_ORDER = [
4539
- "claude-code",
4540
- "codex-cli",
4541
- "cursor",
4542
- "vscode",
4543
- "vscode-insiders",
4544
- "claude-desktop",
4545
- "hermes"
4546
- ];
4547
- function installedClientTargets(homeDir = os.homedir(), cwd = process.cwd(), env = process.env) {
4548
- const targets = [
4549
- {
4550
- runtime: "claude-code",
4551
- label: "Claude Code",
4552
- // Claude Code is configured through its own CLI (`claude mcp add-json`),
4553
- // not by writing a file this module owns — so its evidence is the
4554
- // client directory, never a config path.
4555
- configPath: null,
4556
- markers: [path.join(homeDir, ".claude"), path.join(homeDir, ".claude.json")]
4557
- },
4558
- {
4559
- runtime: "codex-cli",
4560
- // Both Codex surfaces write the same ~/.codex/config.toml, so they are
4561
- // ONE candidate. Splitting them would ask the user to answer a question
4562
- // whose answers are the same write.
4563
- label: "Codex (CLI or Desktop)",
4564
- configPath: runtimeConfigPathFor("codex-cli", homeDir),
4565
- markers: [path.join(homeDir, ".codex")]
4566
- },
4567
- {
4568
- runtime: "cursor",
4569
- label: "Cursor",
4570
- configPath: runtimeConfigPathFor("cursor", homeDir),
4571
- markers: [path.join(homeDir, ".cursor")]
4572
- },
4573
- {
4574
- runtime: "vscode",
4575
- label: "VS Code",
4576
- configPath: runtimeConfigPathFor("vscode", homeDir),
4577
- markers: [path.resolve(cwd, ".vscode")]
4578
- },
4579
- {
4580
- runtime: "vscode-insiders",
4581
- label: "VS Code Insiders",
4582
- configPath: runtimeConfigPathFor("vscode-insiders", homeDir),
4583
- markers: []
4584
- },
4585
- {
4586
- runtime: "claude-desktop",
4587
- label: "Claude Desktop",
4588
- configPath: runtimeConfigPathFor("claude-desktop", homeDir),
4589
- markers: []
4590
- },
4591
- {
4592
- runtime: "hermes",
4593
- label: "Hermes Agent",
4594
- configPath: runtimeConfigPathFor("hermes", homeDir),
4595
- markers: [env.HERMES_HOME ?? path.join(homeDir, ".hermes")]
4596
- }
4597
- ];
4598
- return targets.filter((target) => runtimeProfile(target.runtime, {}).canWriteRuntimeConfig);
4599
- }
4600
- async function scanInstalledClients(options = {}) {
4601
- const exists = options.exists ?? pathExists;
4602
- const targets = installedClientTargets(options.homeDir, options.cwd, options.env ?? process.env);
4603
- const found = [];
4604
- for (const target of targets) {
4605
- if (target.configPath && await exists(target.configPath)) {
4606
- found.push({
4607
- runtime: target.runtime,
4608
- label: target.label,
4609
- detail: `MCP config found at ${target.configPath}`,
4610
- configPath: target.configPath,
4611
- evidence: "config-file"
4612
- });
4613
- continue;
4614
- }
4615
- for (const marker of target.markers) {
4616
- if (!await exists(marker)) continue;
4617
- found.push({
4618
- runtime: target.runtime,
4619
- label: target.label,
4620
- detail: `installed (${marker})`,
4621
- configPath: target.configPath,
4622
- evidence: "client-directory"
4623
- });
4624
- break;
4625
- }
4626
- }
4627
- return found.sort((a, b) => {
4628
- if (a.evidence !== b.evidence) return a.evidence === "config-file" ? -1 : 1;
4629
- return SCAN_ORDER.indexOf(a.runtime) - SCAN_ORDER.indexOf(b.runtime);
4637
+ // src/rekey.ts
4638
+ var rekey_exports = {};
4639
+ __export(rekey_exports, {
4640
+ finishRekey: () => finishRekey,
4641
+ startRekey: () => startRekey
4642
+ });
4643
+ async function startRekey(options, deps = {}) {
4644
+ const now = deps.now ?? (() => Date.now());
4645
+ const stored = await readStoredCredentials(
4646
+ options.serverName,
4647
+ options.agentId,
4648
+ options.credentialsDir
4649
+ );
4650
+ const api = (deps.createApi ?? ((url) => createConnectApiClient(url, void 0, CONNECTOR_CLIENT_IDENTITY)))(stored.apiUrl);
4651
+ const identity = await probeIdentity(api, stored.apiKey, "current");
4652
+ assertRekeyable(identity, stored);
4653
+ const key = (deps.generateKey ?? generateDelegateKey)();
4654
+ const startedAt = new Date(now()).toISOString();
4655
+ const expiresAt = new Date(now() + REKEY_PENDING_TTL_MS).toISOString();
4656
+ await writeRekeyPending(stored.directory, {
4657
+ agent_id: stored.agentId,
4658
+ new_delegate_address: key.address,
4659
+ new_delegate_key: key.privateKey,
4660
+ started_at: startedAt,
4661
+ expires_at: expiresAt
4630
4662
  });
4663
+ const finishCommand = [
4664
+ sdk.connectorRerunCommand("--rekey-finish"),
4665
+ options.serverName ? `--name ${options.serverName}` : void 0,
4666
+ "--api-key <the key the dashboard showed you>",
4667
+ options.runtime ? `--runtime ${options.runtime}` : "--runtime <your runtime>"
4668
+ ].filter(Boolean).join(" ");
4669
+ return {
4670
+ started: true,
4671
+ agentId: stored.agentId,
4672
+ directory: stored.directory,
4673
+ newDelegateAddress: key.address,
4674
+ expiresAt,
4675
+ messages: [
4676
+ `New signing key generated on this machine for agent ${identity.name || stored.agentId}.`,
4677
+ "",
4678
+ ` New signing address: ${key.address}`,
4679
+ "",
4680
+ "The private half stays here \u2014 Haven never receives it, and there is no way to move it",
4681
+ "between machines. That is what keeps the account non-custodial.",
4682
+ "",
4683
+ 'Next, on the Haven agent page: choose "Replace signing key" and paste that address.',
4684
+ "When it finishes it shows a new API key ONCE. Come back here and run:",
4685
+ "",
4686
+ ` ${finishCommand}`,
4687
+ "",
4688
+ `Nothing has changed yet \u2014 the agent keeps working on its old key until you finish.`,
4689
+ `This pending re-key expires ${expiresAt}.`
4690
+ ]
4691
+ };
4631
4692
  }
4632
- function installedClientHint(candidates) {
4633
- const installedClients = candidates.map((candidate) => candidate.runtime);
4634
- if (candidates.length === 1) {
4635
- return { installedClients, suggestedRuntime: candidates[0].runtime };
4636
- }
4637
- const configured = candidates.filter((candidate) => candidate.evidence === "config-file");
4638
- return configured.length === 1 ? { installedClients, suggestedRuntime: configured[0].runtime } : { installedClients };
4639
- }
4640
- var MAX_PROMPT_ATTEMPTS = 3;
4641
- async function promptForInstalledClient(candidates, io = defaultPromptIo()) {
4642
- if (candidates.length === 0) throw noInstalledClientsError();
4643
- io.write("Haven could not detect which agent runtime this is.\n");
4644
- io.write("These agent clients are installed on this machine:\n");
4645
- candidates.forEach((candidate, index) => {
4646
- io.write(` ${index + 1}) ${candidate.label} \u2014 ${candidate.detail}
4647
- `);
4648
- });
4649
- 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");
4650
- for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS; attempt += 1) {
4651
- const answer = await io.question(`Which one? [1-${candidates.length}] (default 1 \u2014 ${candidates[0].label}): `);
4652
- if (answer === null) throw promptAbortedError("the prompt was cancelled");
4653
- const trimmed = answer.trim();
4654
- if (trimmed === "") return candidates[0].runtime;
4655
- const picked = Number.parseInt(trimmed, 10);
4656
- if (Number.isInteger(picked) && picked >= 1 && picked <= candidates.length) {
4657
- return candidates[picked - 1].runtime;
4658
- }
4659
- io.write(`"${trimmed}" is not one of 1-${candidates.length}.
4660
- `);
4693
+ async function finishRekey(options, deps = {}) {
4694
+ const now = deps.now ?? (() => Date.now());
4695
+ if (!options.newApiKey) {
4696
+ throw new Error(REKEY_FINISH_NEEDS_API_KEY);
4661
4697
  }
4662
- throw promptAbortedError(`no valid choice after ${MAX_PROMPT_ATTEMPTS} attempts`);
4663
- }
4664
- async function resolveRuntimeByInstalledClientPrompt(options = {}) {
4665
- const candidates = await scanInstalledClients(options);
4666
- if (candidates.length === 0) throw noInstalledClientsError();
4667
- return promptForInstalledClient(candidates, options.io ?? defaultPromptIo());
4668
- }
4669
- function noInstalledClientsError() {
4670
- return new exports.ConnectError(
4671
- "runtime_no_installed_clients",
4672
- "Could not determine the agent runtime: nothing was detected in this environment, and no agent client Haven can configure is installed on this machine. Re-run with --runtime <name> naming the client you want configured, or --runtime other to store credentials and finish the MCP setup by hand.",
4673
- "rerun_connect_with_explicit_runtime"
4674
- );
4675
- }
4676
- function promptAbortedError(reason) {
4677
- return new exports.ConnectError(
4678
- "runtime_prompt_aborted",
4679
- `Runtime not chosen (${reason}). Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the connector command again, or pass --runtime <name> to skip the prompt.`,
4680
- "rerun_connect_and_choose_a_runtime"
4698
+ const stored = await readStoredCredentials(
4699
+ options.serverName,
4700
+ options.agentId,
4701
+ options.credentialsDir
4681
4702
  );
4682
- }
4683
- async function pathExists(path) {
4684
- try {
4685
- await promises.access(path);
4686
- return true;
4687
- } catch {
4688
- return false;
4703
+ const pending = await readRekeyPending(stored.directory, now());
4704
+ if (pending.agent_id !== stored.agentId) {
4705
+ throw new Error(
4706
+ `The pending re-key at ${stored.directory} belongs to agent ${pending.agent_id}, but that directory now holds ${stored.agentId}. Refusing to write a key into the wrong agent.`
4707
+ );
4689
4708
  }
4690
- }
4691
- function defaultPromptIo() {
4692
- return {
4693
- write: (text) => process.stdout.write(text),
4694
- question: (query) => new Promise((resolvePromise) => {
4695
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
4696
- let settled = false;
4697
- const settle = (value) => {
4698
- if (settled) return;
4699
- settled = true;
4700
- rl.close();
4701
- resolvePromise(value);
4702
- };
4703
- rl.once("SIGINT", () => settle(null));
4704
- rl.once("close", () => settle(null));
4705
- rl.question(query, (answer) => settle(answer));
4706
- })
4707
- };
4708
- }
4709
-
4710
- // src/wiring-collision.ts
4711
- init_server_names();
4712
- init_signer_runtime();
4713
- init_storage();
4714
- init_tombstone();
4715
- async function detectWiringCollision(input) {
4716
- const root = defaultCredentialRoot(input.credentialsDir);
4717
- let entries = [];
4718
- try {
4719
- entries = await promises.readdir(root);
4720
- } catch {
4721
- return null;
4709
+ const api = (deps.createApi ?? ((url) => createConnectApiClient(url, void 0, CONNECTOR_CLIENT_IDENTITY)))(stored.apiUrl);
4710
+ const identity = await probeIdentity(api, options.newApiKey, "new");
4711
+ if (identity.id !== stored.agentId) {
4712
+ throw new Error(
4713
+ `That API key belongs to agent ${identity.id}, not ${stored.agentId}. Nothing was changed.`
4714
+ );
4722
4715
  }
4723
- const superseded = [];
4724
- const taken = /* @__PURE__ */ new Set();
4725
- for (const entry of entries) {
4726
- const directory = path.join(root, entry);
4727
- let identityRaw;
4728
- try {
4729
- identityRaw = await promises.readFile(path.join(directory, "identity.json"), "utf8");
4730
- } catch {
4731
- continue;
4716
+ const onChain = (identity.delegate_address ?? "").toLowerCase();
4717
+ const expected = pending.new_delegate_address.toLowerCase();
4718
+ if (onChain !== expected) {
4719
+ throw new Error(
4720
+ `Haven says this agent's signing address is ${identity.delegate_address ?? "unset"}, but this machine generated ${pending.new_delegate_address}. Nothing was changed. Either the re-key on the agent page used a different address, or it has not finished yet.`
4721
+ );
4722
+ }
4723
+ await rewriteCredentialFiles({
4724
+ baseDir: options.credentialsDir,
4725
+ agentId: stored.agentId,
4726
+ serverName: options.serverName,
4727
+ apiKey: options.newApiKey,
4728
+ delegateKey: pending.new_delegate_key,
4729
+ delegateAddress: pending.new_delegate_address,
4730
+ // The stored value (already new-name-first, from the credential-FILE
4731
+ // permanent fallback) wins; the hosted identity's `account_address` is a
4732
+ // LIVE server read, which as of #2914 emits only that name.
4733
+ accountAddress: stored.accountAddress ?? identity.account_address ?? void 0,
4734
+ chainId: stored.chainId ?? identity.chain_id ?? void 0,
4735
+ network: stored.network,
4736
+ agentBudget: stored.agentBudget,
4737
+ apiUrl: stored.apiUrl,
4738
+ hostedMcpUrl: stored.hostedMcpUrl,
4739
+ x402BindingSigner: stored.x402BindingSigner,
4740
+ warn: deps.log
4741
+ });
4742
+ await clearRekeyPending(stored.directory);
4743
+ const names = serverNamesFor(options.serverName);
4744
+ const messages = [
4745
+ `Agent ${identity.name || stored.agentId} is now on its new signing key.`,
4746
+ ` Signing address: ${pending.new_delegate_address}`,
4747
+ ` Credentials: ${stored.directory} (rewritten in place)`,
4748
+ ` MCP servers: ${names.hosted} / ${names.signer} (unchanged names)`
4749
+ ];
4750
+ let configRewritten = false;
4751
+ if (options.runtime) {
4752
+ const prepared = await (deps.prepareSigner ?? prepareSignerRuntime)(
4753
+ {
4754
+ credentialDirectory: stored.directory,
4755
+ signerPath: `${stored.directory}/signer.json`,
4756
+ homeDir: options.homeDir
4757
+ },
4758
+ { runCommand: deps.runCommand, env: deps.env }
4759
+ );
4760
+ const result = await (deps.writeConfig ?? writeHostedRuntimeConfig)(
4761
+ { runCommand: deps.runCommand, homeDir: options.homeDir },
4762
+ {
4763
+ runtime: options.runtime,
4764
+ hostedMcpUrl: stored.hostedMcpUrl,
4765
+ apiKey: options.newApiKey,
4766
+ identityPath: `${stored.directory}/identity.json`,
4767
+ signerPath: `${stored.directory}/signer.json`,
4768
+ credentialDirectory: stored.directory,
4769
+ serverName: options.serverName
4770
+ },
4771
+ { command: prepared.command, args: prepared.args }
4772
+ );
4773
+ configRewritten = result.hostedConfigured;
4774
+ if (prepared.runtimeSpecOverride) {
4775
+ messages.push(
4776
+ ` Signer runtime: RUNTIME SPEC OVERRIDE ACTIVE \u2014 ${describeRuntimeSpecOverride(prepared.runtimeSpecOverride.specs)} (NOT the pinned manifest; installed into ${prepared.runtimeDirectory})`
4777
+ );
4732
4778
  }
4733
- taken.add(entry);
4734
- let identity;
4735
- try {
4736
- identity = JSON.parse(identityRaw);
4737
- } catch {
4738
- identity = void 0;
4779
+ messages.push(` Config: ${result.target}`);
4780
+ messages.push(...result.messages.map((line) => ` ${line}`));
4781
+ if (!configRewritten) {
4782
+ messages.push(
4783
+ "",
4784
+ "WARNING: the MCP config was NOT updated, so it still carries the OLD API key and every",
4785
+ `wired host will fail with 401. Fix the cause above and re-run with --runtime ${options.runtime},`,
4786
+ "or update the config by hand. The credential files on disk are already on the new key."
4787
+ );
4739
4788
  }
4740
- if (!identity?.api_key) continue;
4741
- if (await pathExists2(path.join(directory, TOMBSTONE_FILENAME))) continue;
4742
- const sidecar = await readRuntimeSidecar(directory);
4743
- if (sidecar?.server_name) continue;
4744
- superseded.push({ directory, agentId: identity.agent_id ?? entry });
4789
+ } else {
4790
+ messages.push(
4791
+ "",
4792
+ "NOTE: no --runtime was given, so the MCP config still carries the OLD API key and every",
4793
+ "wired host will fail with 401. Re-run with --runtime <name> to rewrite it."
4794
+ );
4745
4795
  }
4746
- if (superseded.length === 0) return null;
4747
4796
  return {
4748
- superseded,
4749
- suggestedServerName: proposeServerSlug(input.agentName, taken)
4797
+ finished: true,
4798
+ agentId: stored.agentId,
4799
+ directory: stored.directory,
4800
+ newDelegateAddress: pending.new_delegate_address,
4801
+ serverNames: { hosted: names.hosted, signer: names.signer },
4802
+ configRewritten,
4803
+ messages
4750
4804
  };
4751
4805
  }
4752
- function proposeServerSlug(agentName, taken) {
4753
- let base = agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-").slice(0, 32).replace(/-+$/g, "");
4754
- if (!slugIsValid(base)) base = "agent";
4755
- if (!taken.has(base)) return base;
4756
- for (let n = 2; n < 1e3; n += 1) {
4757
- const suffix = `-${n}`;
4758
- const candidate = `${base.slice(0, 32 - suffix.length).replace(/-+$/g, "")}${suffix}`;
4759
- if (slugIsValid(candidate) && !taken.has(candidate)) return candidate;
4760
- }
4761
- throw new Error(`Could not propose an unused server name for ${JSON.stringify(agentName)}.`);
4762
- }
4763
- function slugIsValid(slug) {
4806
+ async function probeIdentity(api, apiKey, which) {
4764
4807
  try {
4765
- assertValidServerSlug(slug);
4766
- return true;
4767
- } catch {
4768
- return false;
4769
- }
4770
- }
4771
- var MAX_PROMPT_ATTEMPTS2 = 3;
4772
- async function promptWiringCollisionResolution(collision, agentName, io) {
4773
- const ids = collision.superseded.map((entry) => entry.agentId).join(", ");
4774
- io.write(`This machine is already wired to a Haven agent with a live key: ${ids}.
4775
- `);
4776
- io.write(`Setting up "${agentName}" on the bare haven / haven-signer pair would replace that wiring.
4777
- `);
4778
- io.write(" r) replace \u2014 re-point haven / haven-signer at the new agent and retire the previous directory locally\n");
4779
- io.write(" (tombstoned, local key files removed; you still revoke it on the Haven agent page)\n");
4780
- io.write(` a) alongside \u2014 install as a named agent (suggested: ${collision.suggestedServerName}) with its own
4781
- `);
4782
- io.write(` haven-<name> / haven-signer-<name> pair, leaving the current wiring untouched
4783
- `);
4784
- io.write(" q) quit \u2014 nothing is written and the setup token stays unused\n");
4785
- for (let attempt = 0; attempt < MAX_PROMPT_ATTEMPTS2; attempt += 1) {
4786
- const answer = await io.question("Replace, install alongside, or quit? [r/a/q]: ");
4787
- if (answer === null) return { action: "abort" };
4788
- const trimmed = answer.trim().toLowerCase();
4789
- if (trimmed === "r" || trimmed === "replace") return { action: "replace" };
4790
- if (trimmed === "q" || trimmed === "quit") return { action: "abort" };
4791
- if (trimmed === "a" || trimmed === "alongside") {
4792
- const typed = await io.question(`Server name [${collision.suggestedServerName}]: `);
4793
- if (typed === null) return { action: "abort" };
4794
- const serverName = typed.trim() === "" ? collision.suggestedServerName : typed.trim();
4795
- try {
4796
- assertValidServerSlug(serverName);
4797
- } catch (err) {
4798
- io.write(`${err instanceof Error ? err.message : String(err)}
4799
- `);
4800
- continue;
4801
- }
4802
- return { action: "alongside", serverName };
4808
+ return await api.getAgentIdentity(apiKey);
4809
+ } catch (err) {
4810
+ const status = err?.status;
4811
+ if (status === 401 || status === 403) {
4812
+ throw new Error(
4813
+ which === "current" ? "This machine's Haven API key is no longer accepted. If a re-key already finished elsewhere, run --rekey-finish with the key that re-key produced instead of starting a new one." : "Haven rejected that API key. Check you pasted the whole key from the agent page \u2014 nothing was changed."
4814
+ );
4803
4815
  }
4804
- io.write(`"${trimmed}" is not one of r, a, q.
4805
- `);
4816
+ throw new Error(
4817
+ `Could not reach Haven to check this agent (${redactSecrets(
4818
+ err instanceof Error ? err.message : String(err)
4819
+ )}). Nothing was changed.`
4820
+ );
4806
4821
  }
4807
- return { action: "abort" };
4808
4822
  }
4809
- async function pathExists2(path) {
4810
- try {
4811
- await promises.stat(path);
4812
- return true;
4813
- } catch {
4814
- return false;
4823
+ function assertRekeyable(identity, stored) {
4824
+ if (identity.id !== stored.agentId) {
4825
+ throw new Error(
4826
+ `The credentials at ${stored.directory} say agent ${stored.agentId}, but Haven says that key belongs to ${identity.id}. Refusing to re-key an agent this directory does not own.`
4827
+ );
4828
+ }
4829
+ if (identity.execution_rail === "legacy") {
4830
+ throw new Error(
4831
+ `Agent ${identity.name || identity.id} is on the legacy rail, which cannot be re-keyed \u2014 its authority is per-token Safe allowances, not a signed delegation there is anything to re-issue. Re-onboard the agent on the delegation rail instead.`
4832
+ );
4833
+ }
4834
+ if (identity.status === "revoked") {
4835
+ throw new Error(
4836
+ `Agent ${identity.name || identity.id} is revoked. Re-keying would hand a revoked agent fresh credentials \u2014 create a new agent instead.`
4837
+ );
4815
4838
  }
4816
4839
  }
4840
+ var init_rekey = __esm({
4841
+ "src/rekey.ts"() {
4842
+ init_api();
4843
+ init_runtime_install();
4844
+ init_signer_runtime();
4845
+ init_runtime_spec_override();
4846
+ init_key();
4847
+ init_redact();
4848
+ init_rekey_messages();
4849
+ init_runtime();
4850
+ init_server_names();
4851
+ init_storage();
4852
+ }
4853
+ });
4817
4854
 
4818
- // src/runtime.ts
4819
- init_unwire();
4820
- init_local_mcp_runtime();
4821
- init_runtime_manifest();
4822
- var CONNECTOR_VERSION = "0.4.0-alpha.0";
4823
- var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
4824
- var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
4825
- function failureOutcomeFor(runtimeHint, error) {
4826
- if (error !== null && typeof error === "object") {
4827
- const recorded = failureOutcomesByError.get(error);
4828
- if (recorded) return recorded;
4855
+ // src/rekey-restart.ts
4856
+ var rekey_restart_exports = {};
4857
+ __export(rekey_restart_exports, {
4858
+ restartGuidance: () => restartGuidance
4859
+ });
4860
+ function restartGuidance(runtime) {
4861
+ const profile = runtime ? BY_RUNTIME[runtime] : void 0;
4862
+ const lines = ["The new key is only live in a process that started after now.", ""];
4863
+ if (profile) {
4864
+ lines.push(profile.how);
4865
+ for (const command of profile.commands) lines.push(` ${command}`);
4866
+ if (profile.commands.length > 0) lines.push("");
4867
+ } else {
4868
+ lines.push(
4869
+ runtime ? `No standard restart command is known for "${runtime}" \u2014 restart it the way you start it.` : "Pass --runtime <name> to get the restart command for a specific host."
4870
+ );
4871
+ lines.push("");
4829
4872
  }
4830
- return failedConnectOutcome(runtimeHint, error);
4873
+ lines.push(SWEEP);
4874
+ return { commands: profile?.commands ?? [], lines };
4831
4875
  }
4832
- async function runConnect(options, deps = {}) {
4833
- const trace = {};
4834
- try {
4835
- return await executeConnect(options, deps, trace);
4836
- } catch (err) {
4837
- const outcome = failedConnectOutcome(trace.runtime ?? options.runtime, err);
4838
- if (err !== null && typeof err === "object") failureOutcomesByError.set(err, outcome);
4839
- if (trace.directory) await recordConnectOutcome(deps, trace.directory, outcome);
4840
- throw err;
4876
+ var SWEEP, BY_RUNTIME;
4877
+ var init_rekey_restart = __esm({
4878
+ "src/rekey-restart.ts"() {
4879
+ SWEEP = "Then restart EVERY other long-lived MCP host on this machine \u2014 gateways, TUI workers, editors. Each holds the wiring snapshot from its own start time, so after a re-key each one is still presenting the OLD API key and will fail with 401 until it restarts.";
4880
+ BY_RUNTIME = {
4881
+ "claude-code": {
4882
+ commands: ["claude --continue"],
4883
+ how: "Exit this Claude Code session and start a new one:"
4884
+ },
4885
+ "codex-cli": {
4886
+ commands: ["codex resume --last"],
4887
+ how: "Start a fresh Codex CLI session:"
4888
+ },
4889
+ "codex-desktop": {
4890
+ commands: [],
4891
+ how: "Quit Codex Desktop completely (not just the window) and reopen it."
4892
+ },
4893
+ "claude-desktop": {
4894
+ commands: [],
4895
+ how: "Quit Claude Desktop completely (not just the window) and reopen it."
4896
+ },
4897
+ hermes: {
4898
+ commands: ["systemctl --user restart hermes-gateway", "/restart"],
4899
+ how: "Restart the Hermes gateway, or run /restart inside a Hermes session. The first form is the one that matters if you run it as a user service:"
4900
+ },
4901
+ cursor: {
4902
+ commands: [],
4903
+ how: 'Cursor hot-reloads MCP config, so it will pick the new key up on its own. If a tool call still fails with 401, reload the window (Cmd/Ctrl+Shift+P \u2192 "Reload Window").'
4904
+ },
4905
+ vscode: {
4906
+ commands: [],
4907
+ how: 'VS Code hot-reloads MCP config. If a tool call still fails with 401, reload the window (Cmd/Ctrl+Shift+P \u2192 "Reload Window").'
4908
+ },
4909
+ "vscode-insiders": {
4910
+ commands: [],
4911
+ how: 'VS Code Insiders hot-reloads MCP config. If a tool call still fails with 401, reload the window (Cmd/Ctrl+Shift+P \u2192 "Reload Window").'
4912
+ }
4913
+ };
4841
4914
  }
4915
+ });
4916
+
4917
+ // src/doctor.ts
4918
+ var doctor_exports = {};
4919
+ __export(doctor_exports, {
4920
+ describeAccountAddressKey: () => describeAccountAddressKey,
4921
+ rollUpLevel: () => rollUpLevel,
4922
+ runDoctor: () => runDoctor,
4923
+ runRepair: () => runRepair
4924
+ });
4925
+ function finalizeCheck(check) {
4926
+ return { ...check, ok: check.level !== "failed" };
4842
4927
  }
4843
- async function recordConnectOutcome(deps, directory, outcome) {
4844
- try {
4845
- return await (deps.writeOutcomeRecord ?? writeConnectOutcomeRecord)(directory, outcome);
4846
- } catch {
4847
- return void 0;
4848
- }
4928
+ function rollUpLevel(checks) {
4929
+ if (checks.some((check) => check.level === "failed")) return "failed";
4930
+ if (checks.some((check) => check.level === "advisory")) return "advisory";
4931
+ return "ok";
4849
4932
  }
4850
- async function executeConnect(options, deps, trace) {
4851
- assertSupportedNodeVersion(deps.nodeVersion, exports.MCP_RUNTIME_MANIFEST.minimumNodeVersion);
4852
- const connectorVersion = options.connectorVersion ?? CONNECTOR_VERSION;
4853
- const api = deps.api ?? createConnectApiClient(options.apiBaseUrl);
4854
- const log = secureLogger(
4855
- deps.log ?? ((message) => process.stdout.write(`${message}
4856
- `)),
4857
- deps.redactPaths === true
4858
- );
4859
- const writeCredentials = deps.writeCredentials ?? writeCredentialFiles;
4860
- const preflightStorage = deps.preflightStorage ?? preflightCredentialStorage;
4861
- const runRuntimeInstall = deps.installRuntime ?? installRuntime;
4862
- const generateKey = deps.generateKey ?? generateDelegateKey;
4863
- const generateLocalApiKey = deps.generateApiKey ?? generateAgentApiKey;
4864
- const selection = await resolveRuntimeSelection(options.runtime, options.runtimeForce, {
4865
- env: deps.env ?? process.env,
4866
- selfReported: options.runtimeSelfReport,
4867
- promptForRuntime: runtimeSelectionPrompt(options, deps)
4868
- });
4869
- if (!selection.runtime) {
4870
- const hint = await installedClientHintFor(deps);
4871
- throw new exports.ConnectError(
4872
- "runtime_undetermined",
4873
- `Could not determine the agent runtime: nothing was detected in this environment and no --runtime was given. If you are an AI agent running this command: re-run it once, unchanged except for adding --runtime <name>, naming the harness you are running in \u2014 one of: ${exports.RUNTIME_FLAG_VALUES} (the aliases cowork, codex and openclaw are accepted too). Do not guess: if your harness is not one of those, use --runtime other, which stores the credentials and prints the manual MCP steps. ` + installedClientProse(hint) + "Nothing was written and the Haven setup token is still unused.",
4874
- "rerun_connect_with_explicit_runtime",
4875
- // #2091: the values must ride structurally too. The backend's setup
4876
- // prompt permits a retry only with "one of the values that refusal
4877
- // lists" — and --json discards prose, so a prose-only list deadlocked
4878
- // every automation run in an undetected runtime (Codex in the field:
4879
- // npx needs network, Codex runs network commands unsandboxed, and the
4880
- // unsandboxed path carries none of the CODEX_* detection vars).
4881
- //
4882
- // #2174: the list alone still leaves the retry a nine-value guess. The
4883
- // scan below narrows it to what is actually on this machine — as a
4884
- // HINT. It does not select, and this stays a refusal: see
4885
- // `installedClientHint`.
4886
- { allowedRuntimes: RUNTIME_FLAG_VALUE_LIST, ...hint }
4887
- );
4933
+ function runtimeFlagFor(runtime) {
4934
+ return normalizeRuntimeName(runtime) ? ` --runtime ${runtime}` : "";
4935
+ }
4936
+ function repairCommandFor(runtime, suffix = "") {
4937
+ if (normalizeRuntimeName(runtime)) return `Run: ${RERUN} --doctor --repair --runtime ${runtime}${suffix}`;
4938
+ return `Run: ${RERUN} --doctor --repair --runtime <runtime>${suffix.replace(/\.$/, "")} \u2014 --repair needs the runtime named; one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`;
4939
+ }
4940
+ async function resolveDoctorRuntime(input, directory) {
4941
+ const explicit = input.runtime.trim();
4942
+ if (explicit) return { runtime: explicit, origin: "flag" };
4943
+ if (directory) {
4944
+ const recorded = await readConnectOutcomeRuntime(directory);
4945
+ if (recorded !== null && normalizeRuntimeName(recorded)) {
4946
+ return { runtime: recorded, origin: "record" };
4947
+ }
4888
4948
  }
4889
- const runtime = selection.runtime;
4890
- trace.runtime = runtime;
4891
- const installCapabilities = runtimeInstallCapabilities(runtime);
4892
- if (options.localMcp) {
4893
- if (!supportsLocalMcp(runtime)) {
4894
- throw new Error(
4895
- `--local (fully-local Haven MCP) is only available for Claude Code and Codex. The detected runtime is ${runtimeProfile(runtime).label}. Re-run without --local to use the default hosted MCP + local signer setup.`
4896
- );
4949
+ return { runtime: "", origin: "unknown" };
4950
+ }
4951
+ async function discoverCredentialDirectory(homeDir, explicit) {
4952
+ const root = explicit ? path.dirname(explicit) : path.join(homeDir, ".haven", "agents");
4953
+ let entries = [];
4954
+ try {
4955
+ entries = await promises.readdir(root);
4956
+ } catch {
4957
+ return explicit ? { directory: explicit, others: [], parkedOnly: /* @__PURE__ */ new Set() } : { others: [], parkedOnly: /* @__PURE__ */ new Set() };
4958
+ }
4959
+ const candidates = [];
4960
+ const tombstonedOnly = [];
4961
+ const parkedOnly = [];
4962
+ for (const entry of entries) {
4963
+ const directory = path.join(root, entry);
4964
+ try {
4965
+ const s = await promises.stat(path.join(directory, "identity.json"));
4966
+ candidates.push({ directory, mtimeMs: s.mtimeMs });
4967
+ } catch {
4968
+ try {
4969
+ await promises.stat(path.join(directory, TOMBSTONE_FILENAME));
4970
+ tombstonedOnly.push(directory);
4971
+ } catch {
4972
+ try {
4973
+ await promises.stat(path.join(directory, REKEY_PENDING_FILENAME));
4974
+ parkedOnly.push(directory);
4975
+ } catch {
4976
+ }
4977
+ }
4897
4978
  }
4898
4979
  }
4899
- if (selection.overrodeHint) {
4900
- log(`runtime: ${runtime} (detected; ignoring the ${selection.overrodeHint} hint \u2014 pass --runtime-force ${selection.overrodeHint} to override)`);
4980
+ const parkedOnlySet = new Set(parkedOnly);
4981
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
4982
+ if (explicit) {
4983
+ return {
4984
+ directory: explicit,
4985
+ others: [...candidates.map((c) => c.directory), ...tombstonedOnly, ...parkedOnly].filter((d) => d !== explicit),
4986
+ parkedOnly: parkedOnlySet
4987
+ };
4901
4988
  }
4902
- if (selection.discardedHint) {
4903
- log(`runtime: ${runtime} (detected; "${selection.discardedHint}" is not a runtime Haven knows \u2014 valid values: ${exports.RUNTIME_FLAG_VALUES})`);
4989
+ if (candidates.length === 0 && tombstonedOnly.length === 0 && parkedOnly.length === 0) {
4990
+ return { others: [], parkedOnly: parkedOnlySet };
4904
4991
  }
4905
- if (selection.source === "prompted") {
4906
- log(`runtime: ${runtime} (chosen at the prompt \u2014 nothing was detected in this environment)`);
4992
+ return {
4993
+ directory: candidates[0]?.directory,
4994
+ others: [...candidates.slice(1).map((c) => c.directory), ...tombstonedOnly, ...parkedOnly],
4995
+ parkedOnly: parkedOnlySet
4996
+ };
4997
+ }
4998
+ function agentIsWired(configText, names, slug, identity, sidecar, isPrimary, bareOwnerExists) {
4999
+ if (configText === null) return isPrimary;
5000
+ if (slug) {
5001
+ for (const name of [names.hosted, names.codexHosted, names.signer, names.codexSigner]) {
5002
+ if (new RegExp(`(^|[."'\\s\\[])${name}(["'\\]:\\s]|$)`, "m").test(configText)) return true;
5003
+ }
5004
+ return false;
4907
5005
  }
4908
- log("Warming up your connection to Haven\u2026");
4909
- let setup;
4910
- try {
4911
- setup = await api.resolveSetup({
4912
- setupToken: options.setupToken,
4913
- connectorVersion,
4914
- runtime
4915
- });
4916
- } catch (err) {
4917
- throw deadSetupTokenError(err) ?? err;
5006
+ if (sidecar?.wrapper_path && configText.includes(sidecar.wrapper_path)) return true;
5007
+ if (bareOwnerExists) return false;
5008
+ return isPrimary && Boolean(identity?.hosted_mcp_url && configText.includes(identity.hosted_mcp_url));
5009
+ }
5010
+ function rekeyPendingCheck(status, hostedDelegateAddress, runtime, slug) {
5011
+ return finalizeCheck(rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug));
5012
+ }
5013
+ function rekeyPendingVerdict(status, hostedDelegateAddress, runtime, slug) {
5014
+ const label = "Pending re-key";
5015
+ const nameFlag = slug ? ` --name ${slug}` : "";
5016
+ if (status.state === "unreadable") {
5017
+ return {
5018
+ id: "rekey_pending",
5019
+ label,
5020
+ level: "failed",
5021
+ detail: `A re-key was started here but ${status.path} does not parse, so neither the address it generated nor when it started can be read. The file still holds what was a private key.`,
5022
+ repair: `Delete ${status.path}, then start again: ${RERUN} --rekey${nameFlag}`
5023
+ };
4918
5024
  }
4919
- assertSetupChallengeIsUsable(setup.challenge.expires_at);
4920
- printSetupSummary(setup, log);
4921
- await preflightStorage({ baseDir: options.credentialsDir, warn: log });
4922
- if (options.serverName) {
4923
- assertValidServerSlug(options.serverName);
4924
- await assertServerSlugAvailable(options.serverName, options.credentialsDir);
5025
+ const started = status.startedAt ?? "an unknown time";
5026
+ const address = status.newDelegateAddress ?? "unknown";
5027
+ const completedOnHaven = hostedDelegateAddress !== void 0 && status.newDelegateAddress !== void 0 && hostedDelegateAddress.toLowerCase() === status.newDelegateAddress.toLowerCase();
5028
+ if (completedOnHaven) {
5029
+ return {
5030
+ id: "rekey_pending",
5031
+ label,
5032
+ level: "failed",
5033
+ detail: `A re-key started ${started} has COMPLETED on Haven \u2014 the agent's signing address is already ${address}, the one this machine generated \u2014 but the local half was never finished, so the credential files here still hold the old key. Parked at ${status.path}.` + (status.state === "expired" ? " The local file is also past its 24h TTL, which --rekey-finish refuses, so the finish command below will not accept it any more." : ""),
5034
+ repair: status.state === "expired" ? `The parked key expired. Start again \u2014 ${RERUN} --rekey${nameFlag} \u2014 and re-run "Replace signing key" on the Haven agent page with the new address it prints.` : `Run: ${RERUN} --rekey-finish${nameFlag} --api-key <the key the agent page showed you>${runtimeFlagFor(runtime)}`
5035
+ };
4925
5036
  }
4926
- log("Checked local credential storage \u2014 all clear.");
4927
- let serverName = options.serverName;
4928
- let replacing;
4929
- if (!serverName) {
4930
- const collision = await detectWiringCollision({
4931
- credentialsDir: options.credentialsDir,
4932
- agentName: setup.agent.name
4933
- });
4934
- if (collision) {
4935
- const resolution = await resolveWiringCollision(collision, setup.agent.name, options, deps);
4936
- if (resolution.action === "replace") {
4937
- replacing = collision;
4938
- log(
4939
- `Replacing the existing haven / haven-signer wiring (previous agent(s): ${supersededIds(collision)}). The previous directory is retired locally once the new wiring is written; nothing is revoked.`
4940
- );
4941
- } else {
4942
- serverName = resolution.serverName;
4943
- assertValidServerSlug(serverName);
4944
- await assertServerSlugAvailable(serverName, options.credentialsDir);
4945
- log(`Installing alongside the existing wiring as a named agent: ${serverNamesFor(serverName).hosted} / ${serverNamesFor(serverName).signer}.`);
4946
- }
4947
- }
5037
+ const wedgeNote = "Haven is NOT yet on this address, so the re-key did not complete. This machine cannot tell whether the on-chain revoke on the agent page already ran: if it did not, closing this costs nothing; if it did, the agent's old delegations are revoked, no new ones were issued, and only an owner re-grant restores its spend authority (#1868). Check the agent page before assuming the harmless case.";
5038
+ if (status.state === "expired") {
5039
+ return {
5040
+ id: "rekey_pending",
5041
+ label,
5042
+ level: "failed",
5043
+ detail: `A re-key started ${started} EXPIRED ${status.expiresAt ?? ""} without being finished. Its address was ${address}; the private half it generated is still on disk at ${status.path}. ` + wedgeNote,
5044
+ repair: `Either delete ${status.path} to drop the parked key, or start over: ${RERUN} --rekey${nameFlag}. Connect never deletes it for you \u2014 an expired TTL is a refusal to USE the key, not a licence to destroy key material you may still be mid-flow on.`
5045
+ };
4948
5046
  }
4949
- const existingAgents = await listExistingKeyedAgents(options.credentialsDir);
4950
- if (existingAgents.length > 0) {
4951
- log("");
4952
- log(
4953
- `Heads-up (before anything is written to this machine): it already carries ${existingAgents.length} agent director${existingAgents.length === 1 ? "y" : "ies"} with stored keys \u2014 ` + existingAgents.map((a) => `${a.agentId}${a.accountAddress ? ` (spends from ${shortAddress(a.accountAddress)})` : ""}`).join(", ") + ". This setup creates a NEW agent alongside them and revokes nothing."
5047
+ return {
5048
+ id: "rekey_pending",
5049
+ label,
5050
+ level: "ok",
5051
+ detail: `A re-key started ${started} is still open (expires ${status.expiresAt ?? "unknown"}). Paste this address into "Replace signing key" on the Haven agent page: ${address}. Parked at ${status.path}. ` + wedgeNote
5052
+ };
5053
+ }
5054
+ async function runtimeSpecOverrideCheck(directory, sidecar, env) {
5055
+ const facts = [];
5056
+ const installed = sidecar?.runtime_spec_override;
5057
+ if (installed) {
5058
+ facts.push(
5059
+ `signer runtime installed under ${describeRuntimeSpecOverride(installed.specs)} (resolved: ${installed.resolved_specs.join(" ")}; directory key ${installed.directory_key})`
4954
5060
  );
4955
5061
  }
4956
- const hostedNameForRun = serverNamesFor(serverName).hosted;
4957
- let reboundFrom;
4958
- const holders = (await listMcpServerBindings(options.credentialsDir)).filter(({ binding }) => binding.server_name === hostedNameForRun).sort((a, b) => a.binding.bound_at < b.binding.bound_at ? 1 : a.binding.bound_at > b.binding.bound_at ? -1 : 0);
4959
- const newest = holders[0];
4960
- if (newest) {
4961
- const { binding, directory } = newest;
4962
- const backendChanged = withoutUserinfo(binding.api_url) !== withoutUserinfo(options.apiBaseUrl);
4963
- const previousApiUrl = withoutUserinfo(binding.api_url);
4964
- reboundFrom = { server_name: binding.server_name, agent_id: binding.agent_id, api_url: previousApiUrl, bound_at: binding.bound_at, backend_changed: backendChanged };
4965
- const beingReplaced = replacing?.superseded.some((entry) => entry.directory === directory) ?? false;
4966
- log(
4967
- `Heads-up: the MCP server name '${binding.server_name}' was bound to agent ${binding.agent_id} on ${previousApiUrl} at ${binding.bound_at} (locally recorded${holders.length > 1 ? `; ${holders.length - 1} older record${holders.length > 2 ? "s" : ""} also claim${holders.length > 2 ? "" : "s"} it` : ""}). ` + (beingReplaced ? `This run replaces that wiring, as you chose, with a new agent on ${withoutUserinfo(options.apiBaseUrl)}` : `This run rebinds it to a new agent on ${withoutUserinfo(options.apiBaseUrl)}`) + (backendChanged ? " \u2014 a DIFFERENT backend: any saved session, script or document naming this MCP server now resolves to a different backend and agent." : ".")
5062
+ const mcpInstalled = await readMcpSidecarOverride(directory);
5063
+ if (mcpInstalled) {
5064
+ facts.push(
5065
+ `local MCP runtime installed under ${describeRuntimeSpecOverride(mcpInstalled.specs)} (resolved: ${mcpInstalled.resolved_specs.join(" ")}; directory key ${mcpInstalled.directory_key})`
4968
5066
  );
4969
5067
  }
4970
- const localKey = generateKey();
4971
- const localApiKey = generateLocalApiKey();
4972
- log("Minting a fresh signing key and API key \u2014 both stay on this machine.");
4973
- const proofSignature = await localKey.signChallenge(setup.challenge.message);
4974
- log("Introducing your agent to Haven\u2026");
4975
- let registration;
5068
+ let shell;
4976
5069
  try {
4977
- registration = await api.registerSetup({
4978
- setupToken: options.setupToken,
4979
- connectorVersion,
4980
- runtime,
4981
- challengeId: setup.challenge.id,
4982
- delegateAddress: localKey.address,
4983
- proofSignature,
4984
- apiKeyHash: hashAgentApiKey(localApiKey),
4985
- apiKeyPrefix: agentApiKeyPrefix(localApiKey),
4986
- // #1878: report the pair we are ACTUALLY wiring, bare pair included, so
4987
- // the dashboard can name it. Derived here rather than sent as the raw
4988
- // slug — `serverNamesFor` is the one place the naming rule lives, and
4989
- // the hosted name is what a user pastes into an MCP config.
4990
- mcpServerName: serverNamesFor(serverName).hosted,
4991
- // #2528: 'prose' is the default because it is what a caller who says
4992
- // nothing is doing — the library entry point is not a --json run.
4993
- runMode: options.runMode ?? "prose",
4994
- connectorContext: {
4995
- environment_label: options.environmentLabel ?? "Local workspace",
4996
- config_target: installCapabilities.canWriteRuntimeConfig ? "agent runtime MCP config" : "local credential files"
4997
- },
4998
- installCapabilities
4999
- });
5070
+ const active = resolveRuntimeSpecOverride(env);
5071
+ if (active) shell = `set in this shell: ${describeRuntimeSpecOverride(active)} \u2014 a --repair from here installs these, not the pin`;
5000
5072
  } catch (err) {
5001
- const dead = deadSetupTokenError(err);
5002
- if (dead) throw dead;
5003
- if (isExpiredSetupChallenge(err)) {
5004
- throw new Error(
5005
- "The Haven setup challenge expired while connecting. Return to Haven, start a fresh connection, and run its new connector command. Do not reuse or paste credentials."
5006
- );
5007
- }
5008
- throw err;
5073
+ shell = err instanceof RuntimeSpecOverrideError ? `set in this shell but REFUSED: ${err.message}` : `set in this shell but unreadable: ${err instanceof Error ? err.message : String(err)}`;
5009
5074
  }
5010
- log(`Registered signing address with Haven: ${shortAddress(registration.delegate_address)}.`);
5011
- log("Tucking your credentials away safely on disk\u2026");
5012
- const credentialPaths = await writeCredentials({
5013
- baseDir: options.credentialsDir,
5014
- agentId: registration.agent_id,
5015
- serverName,
5016
- apiKey: localApiKey,
5017
- delegateKey: localKey.privateKey,
5018
- delegateAddress: localKey.address,
5019
- accountAddress: setup.haven_wallet.address,
5020
- chainId: setup.haven_wallet.chain_id,
5021
- network: setup.haven_wallet.network,
5022
- agentBudget: setup.agent_budget.map((budget) => ({
5023
- token_symbol: budget.token_symbol,
5024
- allowance_amount: budget.allowance_amount,
5025
- reset_period_min: budget.reset_period_min
5026
- })),
5027
- apiUrl: options.apiBaseUrl,
5028
- hostedMcpUrl: registration.hosted_mcp_url,
5029
- x402BindingSigner: setup.x402_binding_signer ?? void 0,
5030
- warn: log
5075
+ if (shell) facts.push(shell);
5076
+ if (facts.length === 0) return void 0;
5077
+ const variables = Object.values(RUNTIME_SPEC_ENV).join(" / ");
5078
+ return finalizeCheck({
5079
+ id: "runtime_spec_override",
5080
+ label: "Runtime spec override",
5081
+ level: "failed",
5082
+ detail: `runtime spec overridden \u2014 not the pinned manifest (${exports.MCP_RUNTIME_MANIFEST.signerPackage}@${exports.MCP_RUNTIME_MANIFEST.signerVersion}, ${exports.MCP_RUNTIME_MANIFEST.sdkPackage}@${exports.MCP_RUNTIME_MANIFEST.sdkVersion}). ${facts.join(". ")}.`,
5083
+ repair: `Developer override (#2424). To return to the pinned manifest: unset ${variables}, then run ${RERUN} --doctor --repair --runtime <runtime>. If the override is intentional, this finding is the record of it.`
5031
5084
  });
5032
- trace.directory = credentialPaths.directory;
5085
+ }
5086
+ async function readMcpSidecarOverride(directory) {
5033
5087
  try {
5034
- const bindingRecord = {
5035
- version: 1,
5036
- server_name: hostedNameForRun,
5037
- signer_name: serverNamesFor(serverName).signer,
5038
- agent_id: registration.agent_id,
5039
- api_url: withoutUserinfo(options.apiBaseUrl),
5040
- // never persist `user:pass@` (#3154 doc review r2)
5041
- ...registration.hosted_mcp_url ? { hosted_mcp_url: registration.hosted_mcp_url } : {},
5042
- bound_at: (/* @__PURE__ */ new Date()).toISOString()
5043
- };
5044
- await writeMcpServerBinding(credentialPaths.directory, bindingRecord);
5088
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, "mcp-runtime.json"), "utf8"));
5089
+ return parsed.runtime_spec_override;
5045
5090
  } catch {
5046
- log("Could not record the MCP server-name binding locally (non-fatal; the next setup cannot see this binding).");
5091
+ return void 0;
5047
5092
  }
5048
- log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
5049
- log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
5050
- log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
5051
- if (setup.x402_binding_signer) {
5052
- log("Configured x402 binding signer for the local signer.");
5093
+ }
5094
+ async function readIdentity(directory) {
5095
+ try {
5096
+ return JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
5097
+ } catch {
5098
+ return void 0;
5099
+ }
5100
+ }
5101
+ function describeAccountAddressKey(identity, signerFile) {
5102
+ const files = [identity, signerFile];
5103
+ const has = (key) => files.some((f) => typeof f?.[key] === "string" && f[key].trim() !== "");
5104
+ if (has("account_address")) return "account address stored as account_address";
5105
+ if (has("safe_address")) {
5106
+ return "account address stored under the pre-#2908 name safe_address \u2014 still read; the next --rekey or setup rewrites it as account_address";
5107
+ }
5108
+ return "no account address stored";
5109
+ }
5110
+ async function checksForAgent(entry, input, deps) {
5111
+ const verdicts = await verdictsForAgent(entry, input, deps);
5112
+ return {
5113
+ checks: verdicts.checks.map(finalizeCheck),
5114
+ ...verdicts.signerCapabilities ? { signerCapabilities: verdicts.signerCapabilities } : {}
5115
+ };
5116
+ }
5117
+ async function verdictsForAgent(entry, input, deps) {
5118
+ const { directory, identity, sidecar } = entry;
5119
+ const checks = [];
5120
+ let signerCapabilities;
5121
+ let signerFile;
5122
+ try {
5123
+ const parsed = JSON.parse(await promises.readFile(path.join(directory, "signer.json"), "utf8"));
5124
+ signerFile = typeof parsed === "object" && parsed !== null ? parsed : void 0;
5125
+ } catch {
5126
+ signerFile = void 0;
5127
+ }
5128
+ const credentialsOk = Boolean(identity?.api_key) && signerFile !== void 0;
5129
+ checks.push({
5130
+ id: "credentials",
5131
+ label: "Agent credentials",
5132
+ level: credentialsOk ? "ok" : "failed",
5133
+ detail: credentialsOk ? `identity.json and signer.json parse (agent ${identity?.agent_id ?? "unknown"}; ${describeAccountAddressKey(identity, signerFile)})` : "identity.json or signer.json is missing or unparseable.",
5134
+ ...credentialsOk ? {} : { repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` }
5135
+ });
5136
+ if (!sidecar) {
5137
+ checks.push({
5138
+ id: "signer_runtime",
5139
+ label: "Signer runtime (preinstalled wrapper)",
5140
+ level: "failed",
5141
+ detail: "No signer-runtime.json sidecar \u2014 the pinned signer runtime was never prepared (or a pre-#1586 npx config).",
5142
+ repair: repairCommandFor(input.runtime)
5143
+ });
5144
+ } else if (sidecar.runtime_spec_override) {
5145
+ const matches = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
5146
+ signerVersion: sidecar.signer_version,
5147
+ sdkVersion: sidecar.sdk_version
5148
+ });
5149
+ checks.push({
5150
+ id: "signer_runtime",
5151
+ label: "Signer runtime (preinstalled wrapper)",
5152
+ level: matches ? "ok" : "failed",
5153
+ detail: matches ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory} (override install \u2014 see runtime_spec_override)` : `Override runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
5154
+ ...matches ? {} : { repair: repairCommandFor(input.runtime, " with the same HAVEN_*_SPEC variables set.") }
5155
+ });
5053
5156
  } else {
5054
- log(
5055
- "Warning: Haven did not provide an x402 binding signer, so x402 payments will not sign until HAVEN_X402_BINDING_SIGNER is set for the signer. Non-x402 payments are unaffected."
5056
- );
5157
+ const intact = await installedRuntimeMatchesVersions(sidecar.runtime_directory, sidecar.cli_path, {
5158
+ signerVersion: sidecar.signer_version,
5159
+ sdkVersion: sidecar.sdk_version
5160
+ });
5161
+ const versionOk = sidecar.signer_version === exports.MCP_RUNTIME_MANIFEST.signerVersion;
5162
+ const ok = intact && versionOk;
5163
+ checks.push({
5164
+ id: "signer_runtime",
5165
+ label: "Signer runtime (preinstalled wrapper)",
5166
+ level: ok ? "ok" : intact ? "advisory" : "failed",
5167
+ detail: ok ? `Installed ${sidecar.signer_package}@${sidecar.signer_version} at ${sidecar.runtime_directory}` : intact ? `Installed version ${sidecar.signer_version} does not match the connector's pinned ${exports.MCP_RUNTIME_MANIFEST.signerVersion} \u2014 intact, but outdated.` : `Runtime directory is stale or empty (${sidecar.runtime_directory}) \u2014 the CLI or package versions are missing.`,
5168
+ ...ok ? {} : { repair: repairCommandFor(input.runtime) }
5169
+ });
5057
5170
  }
5058
- const runtimeInstall = await runRuntimeInstall({
5059
- runtime,
5060
- hostedMcpUrl: registration.hosted_mcp_url,
5061
- apiKey: localApiKey,
5062
- signerPath: credentialPaths.signerPath,
5063
- identityPath: credentialPaths.identityPath,
5064
- credentialDirectory: credentialPaths.directory,
5065
- environmentLabel: options.environmentLabel ?? "Local workspace",
5066
- ackSigner: options.ackSigner,
5067
- ackLocalTools: options.ackLocalTools,
5068
- localMcp: options.localMcp,
5069
- serverName
5070
- }, {
5071
- onProgress: log,
5072
- // #1543: report "runtime configured" the moment the config write settles,
5073
- // so the dashboard can expose its budget-approval controls without
5074
- // waiting on the probes and skill install — a tail that approval does not
5075
- // depend on. Silent and best-effort: the complete report below remains
5076
- // authoritative (it overwrites these keys, adding the probe verdicts and
5077
- // skill state), so an early failure costs nothing but the head start.
5078
- onRuntimeConfigured: async (early) => {
5079
- try {
5080
- await api.updateInstallStatus(registration.setup_id, localApiKey, {
5081
- runtime: early.runtime,
5082
- connectorVersion,
5083
- runtimeMcpMode: early.runtimeMcpMode,
5084
- hostedMcpConfigured: early.hostedMcpConfigured,
5085
- localSignerConfigured: early.localSignerConfigured,
5086
- localMcpConfigured: early.localMcpConfigured,
5087
- credentialFilesWritten: true,
5088
- signerAcknowledged: early.signerAcknowledged,
5089
- localMcpAcknowledged: early.localMcpAcknowledged,
5090
- restartRequired: early.restartRequired,
5091
- nextUserAction: early.nextUserAction,
5092
- errorCode: early.errorCode ?? null,
5093
- environmentLabel: options.environmentLabel ?? "Local workspace"
5094
- });
5095
- if (!early.errorCode || early.errorCode === "manual_runtime_setup_required") {
5096
- log(approveBudgetCta(setup, registration.approval_url));
5097
- }
5098
- } catch {
5171
+ const overrideCheck = await runtimeSpecOverrideCheck(directory, sidecar, deps.env ?? process.env);
5172
+ if (overrideCheck) checks.push(overrideCheck);
5173
+ const hostedUrl = identity?.hosted_mcp_url ?? (identity?.api_url ? `${identity.api_url}/mcp` : void 0);
5174
+ if (identity?.api_key && hostedUrl) {
5175
+ const probe = await (deps.probeHosted ?? probeHostedMcpTools)(identity.api_key, hostedUrl, deps.fetch);
5176
+ checks.push({
5177
+ id: "hosted_mcp",
5178
+ label: "Hosted Haven MCP",
5179
+ level: probe.status === "ok" ? "ok" : "failed",
5180
+ detail: probe.status === "ok" ? `MCP tools endpoint is reachable (${hostedUrl}).` : `MCP tools endpoint probe failed: ${probe.status} (${hostedUrl}).`,
5181
+ ...probe.status === "ok" ? {} : {
5182
+ repair: "Check network access and runtime configuration for the hosted MCP URL, then re-run --doctor."
5099
5183
  }
5100
- }
5101
- });
5102
- printRuntimeInstall(runtimeInstall, log);
5103
- if (runtimeInstall.errorCode) {
5104
- log("Haven setup needs a couple more steps on this machine \u2014 see the notes above.");
5184
+ });
5105
5185
  } else {
5106
- log("Haven setup on this machine is complete.");
5186
+ checks.push({
5187
+ id: "hosted_mcp",
5188
+ label: "Hosted Haven MCP",
5189
+ level: "failed",
5190
+ detail: "No stored API key / hosted MCP URL to probe with.",
5191
+ repair: `Re-run the full setup: ${RERUN} --setup <token>.`
5192
+ });
5107
5193
  }
5108
- let supersededAgentsRetiredLocally;
5109
- const retiredAgentIds = [];
5110
- if (replacing) {
5111
- if (runtimeInstall.errorCode) {
5112
- supersededAgentsRetiredLocally = false;
5113
- log(
5114
- `Previous agent(s) ${supersededIds(replacing)} were NOT retired: the runtime install did not complete, so their wiring may still be the only working one. Resolve the install, then retire them with ${RERUN_HINT} --unwire <dir>.`
5115
- );
5194
+ const localDelegate = typeof signerFile?.delegate_address === "string" ? signerFile.delegate_address : void 0;
5195
+ let hostedDelegateAddress;
5196
+ if (identity?.api_key && identity.api_url) {
5197
+ const probe = await (deps.probeHostedIdentity ?? probeHostedAgentIdentity)(
5198
+ identity.api_key,
5199
+ identity.api_url,
5200
+ deps.fetch
5201
+ );
5202
+ if (probe.status === "ok") hostedDelegateAddress = probe.delegateAddress;
5203
+ if (probe.status !== "ok") {
5204
+ checks.push({
5205
+ id: "identity_match",
5206
+ label: "Hosted identity matches the local signing key",
5207
+ level: "failed",
5208
+ detail: probe.status === "unauthorized" ? "The stored API key was rejected, so the agent it authenticates as cannot be compared with the local signing key." : `Could not read the hosted identity (${probe.status}) \u2014 the comparison did not happen, so it cannot be reported as a match.`,
5209
+ repair: probe.status === "unauthorized" ? `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.` : `Restore network access to the Haven API, then re-run: ${RERUN} --doctor${runtimeFlagFor(input.runtime)}`
5210
+ });
5211
+ } else if (!localDelegate) {
5212
+ checks.push({
5213
+ id: "identity_match",
5214
+ label: "Hosted identity matches the local signing key",
5215
+ level: "failed",
5216
+ detail: "signer.json holds no delegate_address to compare against the hosted identity.",
5217
+ repair: `Re-run the full setup with a fresh token: ${RERUN} --setup <token>.`
5218
+ });
5116
5219
  } else {
5117
- supersededAgentsRetiredLocally = true;
5118
- for (const entry of replacing.superseded) {
5119
- try {
5120
- await tombstoneDirectoryIfAbsent({
5121
- directory: entry.directory,
5122
- agentId: entry.agentId,
5123
- reason: "replaced by a new setup (--replace)",
5124
- replacedBy: registration.agent_id
5125
- });
5126
- await teardownLocalKeyMaterial(entry.directory, await readIdentityFile(entry.directory));
5127
- await clearMcpServerBinding(entry.directory);
5128
- retiredAgentIds.push(entry.agentId);
5129
- log(`Retired previous agent ${entry.agentId} locally: tombstoned, local key files removed.`);
5130
- } catch (err) {
5131
- supersededAgentsRetiredLocally = false;
5132
- log(`Could not retire previous agent ${entry.agentId} locally: ${err instanceof Error ? err.message : String(err)}`);
5220
+ const same = probe.delegateAddress?.toLowerCase() === localDelegate.toLowerCase();
5221
+ checks.push({
5222
+ id: "identity_match",
5223
+ label: "Hosted identity matches the local signing key",
5224
+ level: same ? "ok" : "failed",
5225
+ detail: same ? `The stored API key authenticates as the agent whose signing key is in this directory (${shortAddress(localDelegate)}).` : `MISMATCH: the stored API key authenticates as agent ${probe.agentId ?? "unknown"} with delegate ${shortAddress(probe.delegateAddress ?? "unknown")}, but signer.json here holds ${shortAddress(localDelegate)}. This runtime would quote as one agent and sign as another.`,
5226
+ ...same ? {} : {
5227
+ repair: `Re-run setup for this agent so its API key and signing key come from one run: ${RERUN} --setup <token>. Do not hand-edit either file.`
5133
5228
  }
5134
- }
5229
+ });
5135
5230
  }
5136
5231
  }
5137
- let supersededScan = null;
5138
- let supersededAgentIds = [];
5139
- try {
5140
- supersededScan = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
5141
- supersededAgentIds = supersededScan ?? [];
5142
- if (supersededAgentsRetiredLocally === true) {
5143
- log("");
5144
- log(
5145
- `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.`
5146
- );
5147
- } else if (supersededAgentIds.length > 0) {
5148
- log("");
5149
- log(
5150
- `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.`
5151
- );
5152
- log(
5153
- `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.`
5232
+ const pending = await inspectRekeyPending(directory, deps.now?.() ?? Date.now());
5233
+ if (pending) {
5234
+ checks.push(rekeyPendingCheck(pending, hostedDelegateAddress, input.runtime, sidecar?.server_name));
5235
+ }
5236
+ if (sidecar) {
5237
+ const consent = await getLocalSignerConsentStatus(path.join(directory, "signer.json"));
5238
+ if (!consent.acknowledged) {
5239
+ checks.push({
5240
+ id: "signer_process",
5241
+ label: "Signer stdio handshake",
5242
+ level: "failed",
5243
+ detail: "The local-tools consent is not acknowledged, so the signer refuses to start (by design).",
5244
+ repair: `Run: ${RERUN} --ack-local-tools --setup <token> (or re-run your original connector command with --ack-local-tools).`
5245
+ });
5246
+ } else {
5247
+ const probe = await (deps.probeSignerTools ?? probeLocalMcpTools)(
5248
+ sidecar.wrapper_path,
5249
+ [],
5250
+ exports.MCP_RUNTIME_MANIFEST.requiredSignerTools
5154
5251
  );
5252
+ const experimental = probe.capabilities?.experimental ?? probe.capabilities;
5253
+ const compat = experimental?.["haven/signer-compatibility"];
5254
+ signerCapabilities = compat ? { "haven/signer-compatibility": compat } : void 0;
5255
+ const compatDetail = compat ? ` Compat: x402 expected-context v${JSON.stringify(compat.x402_expected_context_versions ?? "?")}.` : "";
5256
+ checks.push({
5257
+ id: "signer_process",
5258
+ label: "Signer stdio handshake",
5259
+ level: probe.status === "ok" ? "ok" : "failed",
5260
+ detail: probe.status === "ok" ? `Signer started, listed ${probe.toolNames?.length ?? 0} tools${probe.serverInfo?.version ? ` (v${probe.serverInfo.version})` : ""}.${compatDetail}` : `Handshake failed: ${probe.status}.`,
5261
+ ...probe.status === "ok" ? {} : { repair: repairCommandFor(input.runtime) }
5262
+ });
5155
5263
  }
5156
- } catch {
5157
- }
5158
- try {
5159
- await api.updateInstallStatus(registration.setup_id, localApiKey, {
5160
- runtime: runtimeInstall.runtime,
5161
- connectorVersion,
5162
- runtimeMcpMode: runtimeInstall.runtimeMcpMode,
5163
- hostedMcpConfigured: runtimeInstall.hostedMcpConfigured,
5164
- localSignerConfigured: runtimeInstall.localSignerConfigured,
5165
- localMcpConfigured: runtimeInstall.localMcpConfigured,
5166
- credentialFilesWritten: true,
5167
- signerAcknowledged: runtimeInstall.signerAcknowledged,
5168
- localMcpAcknowledged: runtimeInstall.localMcpAcknowledged,
5169
- activationCommandAvailable: Boolean(runtimeInstall.activationCommand),
5170
- skillInstalled: runtimeInstall.skillInstalled,
5171
- probeResult: runtimeInstall.probeResult,
5172
- restartRequired: runtimeInstall.restartRequired,
5173
- nextUserAction: runtimeInstall.nextUserAction,
5174
- errorCode: runtimeInstall.errorCode,
5175
- environmentLabel: options.environmentLabel ?? "Local workspace",
5176
- // The raw scan result, `null` and all — NOT the flattened
5177
- // `supersededAgentIds` the outcome carries. Flattening here would hand
5178
- // the dashboard the same ambiguity this field exists to remove (#2561).
5179
- supersededAgentIds: supersededScan
5264
+ } else {
5265
+ checks.push({
5266
+ id: "signer_process",
5267
+ label: "Signer stdio handshake",
5268
+ level: "failed",
5269
+ detail: "Skipped \u2014 no prepared signer runtime to probe.",
5270
+ repair: repairCommandFor(input.runtime)
5180
5271
  });
5181
- } catch (err) {
5182
- log(`Could not report install status to Haven: ${err instanceof Error ? err.message : String(err)}`);
5183
- }
5184
- let approval;
5185
- const narrateApprovalWait = options.waitForApproval === true || options.waitForApproval === void 0 && (deps.isStdoutTty ?? Boolean(process.stdout.isTTY));
5186
- if (narrateApprovalWait && !runtimeInstall.errorCode) {
5187
- approval = await waitForBudgetApproval(api, registration.setup_id, localApiKey, log, options.approvalWait);
5188
- }
5189
- printNextSteps(runtimeInstall, log, approval, registration.approval_url);
5190
- const outcome = completionOutcome({
5191
- runtimeInstall,
5192
- delegateAddress: registration.delegate_address,
5193
- hostedMcpUrl: registration.hosted_mcp_url,
5194
- supersededAgentIds,
5195
- supersededAgentsRetiredLocally,
5196
- ...replacing ? { retiredAgentIds } : {},
5197
- existingAgentsBeforeWrite: existingAgents.map((a) => ({ agent_id: a.agentId, account_address: a.accountAddress })),
5198
- ...reboundFrom ? { serverNameReboundFrom: reboundFrom } : {},
5199
- setupChallengeExpiresAt: setup.challenge.expires_at,
5200
- approvalRequired: registration.agent_status === "pending_approval",
5201
- approvalUrl: registration.approval_url
5202
- });
5203
- if (await recordConnectOutcome(deps, credentialPaths.directory, outcome)) {
5204
- log(`Saved this run's outcome to ${exports.CONNECT_OUTCOME_FILENAME} in the agent's credential directory.`);
5205
5272
  }
5206
- return {
5207
- setupId: registration.setup_id,
5208
- agentId: registration.agent_id,
5209
- delegateAddress: registration.delegate_address,
5210
- credentialPaths,
5211
- outcome
5212
- };
5213
- }
5214
- function completionOutcome(input) {
5215
- const { runtimeInstall } = input;
5216
- const manualSetup = runtimeInstall.errorCode === "manual_runtime_setup_required";
5217
- const nextAction2 = runtimeInstall.nextUserAction;
5218
- const outcome = {
5219
- schema_version: CONNECT_OUTCOME_SCHEMA_VERSION,
5220
- outcome: runtimeInstall.errorCode ? "action_required" : "complete",
5221
- runtime: runtimeInstall.runtime,
5222
- topology: runtimeInstall.runtimeMcpMode,
5223
- configuration: {
5224
- hosted_mcp: runtimeInstall.hostedMcpConfigured,
5225
- local_signer: runtimeInstall.localSignerConfigured,
5226
- local_mcp: runtimeInstall.localMcpConfigured
5227
- },
5228
- probe: { result: runtimeInstall.probeResult },
5229
- activation: {
5230
- restart_required: runtimeInstall.restartRequired,
5231
- 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
5232
- },
5233
- next_action: nextAction2,
5234
- approval: {
5235
- required: input.approvalRequired,
5236
- expires_at: null,
5237
- // Only when there is something to approve AND the backend supplied a
5238
- // link. Never synthesised here: the connector does not know the
5239
- // dashboard's origin, and a guessed URL is worse than none — it is the
5240
- // "do not invent one" rule the agent runbook states, applied to the
5241
- // tool rather than the agent.
5242
- ...input.approvalRequired && input.approvalUrl ? { url: input.approvalUrl } : {}
5243
- },
5244
- verification: {
5245
- tools: ["haven_get_agent", "haven_get_allowances"],
5246
- instruction: runtimeVerificationInstruction(runtimeInstall.runtime)
5247
- },
5248
- // The backend contract supplies a 20-byte address. If an unexpected
5249
- // malformed value arrives, do not echo it into an automation-facing
5250
- // record; the human log has already been redacted separately.
5251
- delegate_address: /^0x[0-9a-fA-F]{40}$/.test(input.delegateAddress) ? shortAddress(input.delegateAddress) : "[delegate-address-redacted]",
5252
- // The endpoint Connect wrote into the runtime's MCP config, verbatim — the
5253
- // same string that already sits in the user's own config file. Passed
5254
- // through the secret filter anyway: this is the automation contract, the
5255
- // value is server-supplied, and belts are cheap (the #1589 stance).
5256
- ...input.hostedMcpUrl ? { hosted_mcp_url: redactSecrets(input.hostedMcpUrl) } : {},
5257
- // Always emitted on a completed run, empty list included: "no superseded
5258
- // agents" is a fact a caller needs, and an omitted key would be
5259
- // indistinguishable from an older connector that never reported it.
5260
- superseded_agent_ids: input.supersededAgentIds ?? [],
5261
- ...input.supersededAgentsRetiredLocally !== void 0 ? { superseded_agents_retired_locally: input.supersededAgentsRetiredLocally } : {},
5262
- ...input.retiredAgentIds ? { retired_agent_ids: input.retiredAgentIds } : {},
5263
- // #3122: always emitted on a completed run (empty list included), for the
5264
- // same reason as superseded_agent_ids above.
5265
- existing_agents_before_write: input.existingAgentsBeforeWrite ?? [],
5266
- ...input.serverNameReboundFrom ? { server_name_rebound_from: input.serverNameReboundFrom } : {},
5267
- ...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
5268
- ...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
5269
- };
5270
- return outcome;
5273
+ return { checks, ...signerCapabilities ? { signerCapabilities } : {} };
5271
5274
  }
5272
- async function installedClientHintFor(deps) {
5273
- try {
5274
- const candidates = await (deps.scanInstalledClients ?? scanInstalledClients)({ env: deps.env });
5275
- const hint = installedClientHint(candidates);
5276
- return hint.installedClients.length > 0 ? hint : {};
5277
- } catch {
5278
- return {};
5275
+ async function runDoctor(input, deps = {}) {
5276
+ const homeDir = deps.homeDir ?? os.homedir();
5277
+ const checks = [];
5278
+ let signerCapabilities;
5279
+ const { directory, others, parkedOnly } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
5280
+ const resolution = await resolveDoctorRuntime(input, directory);
5281
+ const runtime = resolution.runtime;
5282
+ const input2 = { ...input, runtime };
5283
+ const normalizedRuntime = normalizeRuntimeName(input2.runtime);
5284
+ const configPath = runtimeConfigPathFor(normalizedRuntime ?? input2.runtime, homeDir);
5285
+ let configText = null;
5286
+ if (configPath !== null) {
5287
+ try {
5288
+ configText = await promises.readFile(configPath, "utf8");
5289
+ } catch {
5290
+ configText = null;
5291
+ }
5279
5292
  }
5280
- }
5281
- function installedClientProse(hint) {
5282
- const found = hint.installedClients ?? [];
5283
- if (found.length === 0) return "";
5284
- const suggestion = hint.suggestedRuntime ? ` The likeliest is ${hint.suggestedRuntime}, but Haven will not choose for you \u2014 pass it yourself if it is right.` : " Haven will not choose between them for you.";
5285
- return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
5286
- }
5287
- function runtimeSelectionPrompt(options, deps) {
5288
- if (!interactivePromptAllowed(options, deps)) return void 0;
5289
- return deps.promptRuntime ?? (() => resolveRuntimeByInstalledClientPrompt());
5290
- }
5291
- function interactivePromptAllowed(options, deps) {
5292
- if (options.interactive !== true) return false;
5293
- return deps.isTty ?? Boolean(process.stdin.isTTY);
5294
- }
5295
- function supersededIds(collision) {
5296
- return collision.superseded.map((entry) => entry.agentId).join(", ");
5297
- }
5298
- async function resolveWiringCollision(collision, agentName, options, deps) {
5299
- if (options.replaceExistingWiring) return { action: "replace" };
5300
- if (interactivePromptAllowed(options, deps)) {
5301
- const prompt = deps.promptWiringCollision ?? ((c, name) => promptWiringCollisionResolution(c, name, defaultPromptIo()));
5302
- const resolution = await prompt(collision, agentName);
5303
- if (resolution.action === "abort") {
5304
- throw new exports.ConnectError(
5305
- "wiring_collision_declined",
5306
- `Setup stopped at your request: this machine is already wired to ${supersededIds(collision)} and you chose neither to replace that wiring nor to install alongside it. Nothing was written: no agent was created, no credentials were stored, and the Haven setup token is still unused. Run the connector command again with --replace to re-point haven / haven-signer at the new agent, or with --name <slug> (e.g. --name ${collision.suggestedServerName}) to install alongside.`,
5307
- "rerun_connect_with_replace_or_name",
5308
- { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
5309
- );
5293
+ const allDirectories = directory ? [directory, ...others] : others;
5294
+ let bareOwnerExists = false;
5295
+ for (const dir of allDirectories) {
5296
+ const sidecar = await readRuntimeSidecar(dir);
5297
+ if (!sidecar?.server_name && sidecar?.wrapper_path && configText?.includes(sidecar.wrapper_path)) {
5298
+ bareOwnerExists = true;
5299
+ break;
5310
5300
  }
5311
- return resolution;
5312
5301
  }
5313
- throw new exports.ConnectError(
5314
- "wiring_collision",
5315
- `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.`,
5316
- "relay_wiring_collision_to_user",
5317
- { supersededAgentIds: collision.superseded.map((e) => e.agentId), suggestedServerName: collision.suggestedServerName }
5318
- );
5319
- }
5320
- function failedConnectOutcome(runtimeHint, error) {
5321
- const message = error instanceof Error ? error.message : "";
5322
- const code = error instanceof exports.ConnectError ? error.code : /Node\.js >=/i.test(message) ? "unsupported_node_version" : /setup challenge.*expired|expired or invalid/i.test(message) ? "setup_challenge_expired_or_invalid" : /only available for Claude Code and Codex/i.test(message) ? "local_mcp_unsupported_runtime" : "connect_failed";
5323
- const runtime = normalizeRuntime(runtimeHint);
5324
- const nextAction2 = error instanceof exports.ConnectError ? error.nextAction : code === "setup_challenge_expired_or_invalid" ? "return_to_haven_for_fresh_setup" : code === "unsupported_node_version" ? "install_supported_node_and_rerun_connect" : code === "local_mcp_unsupported_runtime" ? "rerun_without_local_mcp" : "review_the_safe_error_output_and_start_a_fresh_haven_setup_if_needed";
5325
- return {
5326
- schema_version: CONNECT_OUTCOME_SCHEMA_VERSION,
5327
- outcome: "failed",
5328
- runtime,
5329
- topology: "unknown",
5330
- configuration: { hosted_mcp: false, local_signer: false, local_mcp: false },
5331
- probe: { result: "not_run" },
5332
- activation: { restart_required: false, instruction: "Resolve the reported problem before activating Haven tools." },
5333
- next_action: nextAction2,
5334
- approval: { required: false, expires_at: null },
5335
- verification: {
5336
- tools: ["haven_get_agent", "haven_get_allowances"],
5337
- instruction: "After a successful setup and activation, verify only with haven_get_agent and haven_get_allowances."
5338
- },
5339
- error: {
5340
- code,
5341
- next_action: nextAction2,
5342
- // Only a ConnectError's message enters the JSON record: the vocabulary's
5343
- // prose is connector-authored and safe to serialize, while a plain
5344
- // Error can carry arbitrary server or filesystem detail (that stance is
5345
- // pinned by test). Redaction stays on as belt-and-braces; plain-Error
5346
- // runs still get their redacted message on stderr via the CLI mirror.
5347
- ...error instanceof exports.ConnectError && message ? { message: redactForAutomation(message) } : {},
5348
- ...error instanceof exports.ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
5349
- ...error instanceof exports.ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
5350
- ...error instanceof exports.ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {},
5351
- ...error instanceof exports.ConnectError && error.details.supersededAgentIds ? { superseded_agent_ids: error.details.supersededAgentIds } : {},
5352
- ...error instanceof exports.ConnectError && error.details.suggestedServerName ? { suggested_name: error.details.suggestedServerName } : {}
5302
+ const inventory = [];
5303
+ const capabilitiesByDirectory = /* @__PURE__ */ new Map();
5304
+ const primaryChecksById = /* @__PURE__ */ new Map();
5305
+ for (const dir of allDirectories) {
5306
+ const identity = await readIdentity(dir);
5307
+ const sidecar = await readRuntimeSidecar(dir);
5308
+ const tombstone = await readAgentTombstone(dir);
5309
+ const slug = sidecar?.server_name;
5310
+ const names = serverNamesFor(slug);
5311
+ const rekeyPending = await inspectRekeyPending(dir, deps.now?.() ?? Date.now());
5312
+ if (!identity?.api_key) {
5313
+ const agentId = tombstone?.agent_id ?? rekeyPending?.agentId;
5314
+ inventory.push({
5315
+ ...slug ? { slug } : {},
5316
+ ...agentId ? { agentId } : {},
5317
+ directory: dir,
5318
+ // A tombstone is a deliberate record and outranks the discovery tell:
5319
+ // a retired directory that also holds a parked key stays `retired`.
5320
+ classification: tombstone ? "retired" : parkedOnly.has(dir) ? "parked" : "orphaned",
5321
+ checks: rekeyPending ? [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)] : [],
5322
+ ...rekeyPending ? { rekeyPending } : {}
5323
+ });
5324
+ continue;
5353
5325
  }
5354
- };
5355
- }
5356
- function printSetupSummary(setup, log) {
5357
- log(`Fetched Haven setup for ${setup.agent.name}.`);
5358
- log(`Haven wallet: ${setup.haven_wallet.name} on ${setup.haven_wallet.network}.`);
5359
- if (setup.agent_budget.length > 0) {
5360
- for (const budget of setup.agent_budget) {
5361
- log(
5362
- `Agent budget: ${budget.allowance_amount} atomic ${budget.token_symbol} / ${budget.reset_period_min} minute reset.`
5363
- );
5326
+ const wired = agentIsWired(configText, names, slug, identity, sidecar, dir === directory, bareOwnerExists);
5327
+ const entry = {
5328
+ ...slug ? { slug } : {},
5329
+ ...identity.agent_id ? { agentId: identity.agent_id } : {},
5330
+ directory: dir,
5331
+ classification: wired ? "wired" : "superseded",
5332
+ checks: [],
5333
+ ...rekeyPending ? { rekeyPending } : {}
5334
+ };
5335
+ if (wired) {
5336
+ const result = await checksForAgent({ directory: dir, identity, sidecar }, input2, deps);
5337
+ entry.checks = result.checks;
5338
+ capabilitiesByDirectory.set(dir, result.signerCapabilities);
5339
+ } else if (rekeyPending) {
5340
+ entry.checks = [rekeyPendingCheck(rekeyPending, void 0, input2.runtime, slug)];
5364
5341
  }
5342
+ inventory.push(entry);
5365
5343
  }
5366
- log(`Setup challenge expires at ${setup.challenge.expires_at}. If it expires, return to Haven for a fresh setup and rerun Connect \u2014 do not reuse or paste credentials.`);
5367
- }
5368
- function approveBudgetCta(setup, approvalUrl) {
5369
- const where = approvalUrl ? `at ${approvalUrl}` : "in the Haven dashboard";
5370
- const budgetPhrase = setup.agent_budget.length > 0 ? setup.agent_budget.map((budget) => `up to ${describeSetupBudget(budget)}`).join(", ") : void 0;
5371
- if (budgetPhrase) {
5372
- return `\u2192 Action needed: approve this agent's budget \u2014 ${budgetPhrase} from ${setup.haven_wallet.name} on ${setup.haven_wallet.network} \u2014 ${where}. The approval button is live now; setup continues here in the meantime.`;
5344
+ const wiredDirectories = inventory.filter((entry) => entry.classification === "wired").map((entry) => entry.directory);
5345
+ const primaryDirectory = input.credentialsDir ? directory : wiredDirectories.includes(directory ?? "") ? directory : wiredDirectories[0] ?? directory;
5346
+ if (primaryDirectory) {
5347
+ const primaryEntry = inventory.find((entry) => entry.directory === primaryDirectory);
5348
+ signerCapabilities = capabilitiesByDirectory.get(primaryDirectory);
5349
+ for (const check of primaryEntry?.checks ?? []) primaryChecksById.set(check.id, check);
5373
5350
  }
5374
- return `\u2192 Action needed: approve this agent's budget ${where} \u2014 the approval button is live now. Setup continues here in the meantime.`;
5375
- }
5376
- function describeSetupBudget(budget) {
5377
- return describeApprovedBudget({
5378
- token_symbol: budget.token_symbol,
5379
- token_address: budget.token_address,
5380
- amount: budget.allowance_amount,
5381
- reset_period_min: budget.reset_period_min
5382
- });
5383
- }
5384
- function assertSetupChallengeIsUsable(expiresAt) {
5385
- const expiresAtMs = Date.parse(expiresAt);
5386
- if (!Number.isNaN(expiresAtMs) && expiresAtMs > Date.now()) return;
5387
- throw new Error(
5388
- "This Haven setup challenge is expired or invalid. Return to Haven, start a fresh connection, and rerun Connect. No local credentials were written."
5389
- );
5390
- }
5391
- function deadSetupTokenError(err) {
5392
- if (!(err instanceof ConnectRequestError) || err.status !== 410 && err.status !== 401) return null;
5393
- return new exports.ConnectError(
5394
- "setup_challenge_expired_or_invalid",
5395
- "This Haven setup token is expired or invalid \u2014 tokens are single-use and expire 30 minutes after the dashboard issues them, and a mistyped token reads the same way. Return to Haven, start a fresh connection, and run its new connector command. No local credentials were written.",
5396
- "return_to_haven_for_fresh_setup"
5397
- );
5398
- }
5399
- function isExpiredSetupChallenge(err) {
5400
- return err instanceof Error && /(?:setup )?challenge.*expir|expir.*(?:setup )?challenge/i.test(err.message);
5401
- }
5402
- function secureLogger(log, redactPaths = false) {
5403
- return (message) => {
5404
- log(redactPaths ? redactForAutomation(message) : redactSecrets(message));
5405
- };
5406
- }
5407
- function printRuntimeInstall(result, log) {
5408
- for (const message of result.messages) log(message);
5409
- if (result.localMcpConfigured) {
5410
- log("Configured local Haven MCP tools.");
5411
- } else if (result.hostedMcpConfigured) {
5412
- log("Configured hosted Haven MCP identity.");
5351
+ if (!primaryDirectory) {
5352
+ checks.push({
5353
+ id: "credentials",
5354
+ label: "Agent credentials",
5355
+ level: "failed",
5356
+ detail: "No agent credential directory with an identity.json under ~/.haven/agents.",
5357
+ repair: `Run the full setup once: ${RERUN} --setup <token from the Haven dashboard>.`
5358
+ });
5413
5359
  } else {
5414
- log("Haven MCP tools still need runtime setup.");
5360
+ const primaryIdentity = await readIdentity(primaryDirectory);
5361
+ const primarySidecar = await readRuntimeSidecar(primaryDirectory);
5362
+ if (!primaryChecksById.has("credentials")) {
5363
+ const result = await checksForAgent(
5364
+ { directory: primaryDirectory, identity: primaryIdentity, sidecar: primarySidecar },
5365
+ input2,
5366
+ deps
5367
+ );
5368
+ signerCapabilities = result.signerCapabilities;
5369
+ for (const check of result.checks) primaryChecksById.set(check.id, check);
5370
+ }
5371
+ for (const id of ["credentials", "signer_runtime", "runtime_spec_override"]) {
5372
+ const check = primaryChecksById.get(id);
5373
+ if (check) checks.push(check);
5374
+ }
5415
5375
  }
5416
- if (result.localSignerConfigured) {
5417
- log("Configured local Haven signer.");
5376
+ const runtimeOwnsNoConfig = normalizedRuntime !== null && configPath === null;
5377
+ if (configPath === null && input2.runtime !== "" && normalizedRuntime === null) {
5378
+ checks.push({
5379
+ id: "runtime_config",
5380
+ label: "Runtime MCP config",
5381
+ level: "failed",
5382
+ detail: `Runtime '${input2.runtime}' is not one the connector recognises. The runtime config was NOT checked. Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`,
5383
+ repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
5384
+ });
5385
+ } else if (configPath === null && input2.runtime === "") {
5386
+ checks.push({
5387
+ id: "runtime_config",
5388
+ label: "Runtime MCP config",
5389
+ level: "failed",
5390
+ detail: `Runtime is unknown \u2014 no runtime flag was given and the connector's record in ${primaryDirectory ?? input.credentialsDir ?? "~/.haven/agents"} carries no resolvable ${exports.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(", ")}.`,
5391
+ repair: `Re-run the doctor naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
5392
+ });
5393
+ } else if (configPath === null) {
5394
+ checks.push({
5395
+ id: "runtime_config",
5396
+ label: "Runtime MCP config",
5397
+ level: "ok",
5398
+ 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.`
5399
+ });
5400
+ } else if (configText === null) {
5401
+ checks.push({
5402
+ id: "runtime_config",
5403
+ label: "Runtime MCP config",
5404
+ level: "failed",
5405
+ detail: `No runtime config at ${configPath}.`,
5406
+ repair: repairCommandFor(input2.runtime)
5407
+ });
5418
5408
  } else {
5419
- log("Local Haven signer still needs runtime setup.");
5409
+ const primaryIdentity = await readIdentity(primaryDirectory ?? "");
5410
+ const primarySidecar = primaryDirectory ? await readRuntimeSidecar(primaryDirectory) : null;
5411
+ const hasHaven = primaryIdentity?.hosted_mcp_url ? configText.includes(primaryIdentity.hosted_mcp_url) : configText.includes("haven");
5412
+ const signerViaNpx = configText.includes("@haven_ai/signer");
5413
+ const wrapperReferenced = primarySidecar ? configText.includes(primarySidecar.wrapper_path) : false;
5414
+ const ok = hasHaven && !signerViaNpx && (primarySidecar ? wrapperReferenced : true);
5415
+ checks.push({
5416
+ id: "runtime_config",
5417
+ label: "Runtime MCP config",
5418
+ level: ok ? "ok" : "failed",
5419
+ detail: ok ? `Config at ${configPath} references the hosted server and the prepared signer wrapper.` : signerViaNpx ? `Config at ${configPath} still launches the signer via npx \u2014 the pre-#1586 shape that cannot start under a 120s startup timeout.` : `Config at ${configPath} is missing the Haven entries${primarySidecar && !wrapperReferenced ? " (or references a different signer wrapper)" : ""}.`,
5420
+ ...ok ? {} : { repair: repairCommandFor(input2.runtime) }
5421
+ });
5420
5422
  }
5421
- }
5422
- function formatAtomicAmount(atomic, decimals) {
5423
- const s = atomic.toString().padStart(decimals + 1, "0");
5424
- const intPart = s.slice(0, s.length - decimals) || "0";
5425
- const fracPart = s.slice(s.length - decimals).replace(/0+$/, "");
5426
- return fracPart ? `${intPart}.${fracPart}` : intPart;
5427
- }
5428
- function describeResetPeriod(resetPeriodMin) {
5429
- if (resetPeriodMin === 1440) return "per day";
5430
- if (resetPeriodMin === 10080) return "per week";
5431
- if (resetPeriodMin === 43200) return "per month";
5432
- if (resetPeriodMin === 60) return "per hour";
5433
- if (resetPeriodMin === 0) return "in total";
5434
- return `every ${resetPeriodMin} minutes`;
5435
- }
5436
- function describeApprovedBudget(budget) {
5437
- const token = sdk.resolveTokenFromAddress(budget.token_address);
5438
- const amount = token ? `${formatAtomicAmount(BigInt(budget.amount), token.decimals)} ${budget.token_symbol}` : `${budget.amount} ${budget.token_symbol} (atomic units)`;
5439
- return `${amount} ${describeResetPeriod(budget.reset_period_min)}`;
5440
- }
5441
- function describeWaitBound(timeoutMs) {
5442
- if (timeoutMs >= 12e4) {
5443
- const minutes = Math.round(timeoutMs / 6e4);
5444
- return `${minutes} minutes`;
5423
+ for (const id of ["hosted_mcp", "identity_match", "rekey_pending"]) {
5424
+ const check = primaryChecksById.get(id);
5425
+ if (check) checks.push(check);
5445
5426
  }
5446
- return `${Math.round(timeoutMs / 1e3)}s`;
5447
- }
5448
- async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
5449
- const intervalMs = options.intervalMs ?? 5e3;
5450
- const timeoutMs = options.timeoutMs ?? 18e4;
5451
- const sleep = options.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)));
5452
- const maxPolls = Math.max(1, Math.floor(timeoutMs / intervalMs));
5453
- const remindEvery = Math.max(1, Math.floor(3e4 / intervalMs));
5454
- let waitingAnnounced = false;
5455
- const announceWaiting = () => {
5456
- if (waitingAnnounced) return;
5457
- waitingAnnounced = true;
5458
- log("Now waiting for you to approve the budget in the Haven dashboard \u2014 approval is what unlocks the agent's Haven tools\u2026");
5459
- };
5460
- for (let i = 0; i < maxPolls; i++) {
5461
- if (i > 0) await sleep(intervalMs);
5462
- let status;
5463
- try {
5464
- status = await api.getConnectorStatus(setupId, apiKey);
5465
- } catch (err) {
5466
- if (err instanceof ConnectRequestError && (err.status === 401 || err.status === 404)) {
5467
- log("This setup ended in Haven \u2014 start a fresh connection from the dashboard when ready.");
5468
- return "ended";
5427
+ const otherEntries = inventory.filter((entry) => entry.directory !== primaryDirectory);
5428
+ if (otherEntries.length > 0) {
5429
+ const live = [];
5430
+ const revoked = [];
5431
+ const unverifiable = [];
5432
+ const retired = [];
5433
+ for (const entry of otherEntries) {
5434
+ const identity = await readIdentity(entry.directory);
5435
+ const tombstone = await readAgentTombstone(entry.directory);
5436
+ const otherAgent = entry.agentId ?? path.basename(entry.directory);
5437
+ if (!identity?.api_key || !identity.api_url) {
5438
+ if (tombstone) retired.push(`${otherAgent} (retired ${tombstone.retired_at})`);
5439
+ else unverifiable.push(`${otherAgent} (no stored key/API URL to probe)`);
5440
+ continue;
5469
5441
  }
5470
- announceWaiting();
5471
- continue;
5472
- }
5473
- if (status.status === "active") {
5474
- log(
5475
- status.approved_budget ? `Budget approved \u{1F389} \u2014 I can now spend up to ${describeApprovedBudget(status.approved_budget)} from your Haven wallet.` : "Budget approved \u{1F389} \u2014 the agent can now spend within its Haven rules."
5442
+ const suffix = tombstone ? " [tombstoned \u2014 key material still present]" : "";
5443
+ const probe = await (deps.probeHostedIdentity ?? probeHostedAgentIdentity)(
5444
+ identity.api_key,
5445
+ identity.api_url,
5446
+ deps.fetch
5476
5447
  );
5477
- return "approved";
5478
- }
5479
- if (status.status === "cancelled" || status.status === "expired" || status.status === "failed") {
5480
- log(`This setup ended in Haven (${status.status}) \u2014 start a fresh connection from the dashboard when ready.`);
5481
- return "ended";
5448
+ if (probe.status === "ok") live.push({ label: `${otherAgent}${suffix}`, entry });
5449
+ else if (probe.status === "unauthorized") revoked.push(`${otherAgent}${suffix}`);
5450
+ else unverifiable.push(`${otherAgent} (${probe.status})${suffix}`);
5482
5451
  }
5483
- announceWaiting();
5484
- if (i > 0 && i % remindEvery === 0) {
5485
- const elapsedSeconds = Math.round(i * intervalMs / 1e3);
5486
- log(
5487
- `Still waiting for budget approval in Haven \u2014 ${elapsedSeconds}s elapsed, checking every ${Math.round(intervalMs / 1e3)}s. I'll stop waiting after ${describeWaitBound(timeoutMs)}; you can approve later and the tools unlock then.`
5452
+ const parts = [];
5453
+ if (live.length > 0) parts.push(`STILL SPEND-CAPABLE: ${live.map((item) => item.label).join(", ")}`);
5454
+ if (revoked.length > 0) parts.push(`already revoked: ${revoked.join(", ")}`);
5455
+ if (retired.length > 0) parts.push(`tombstoned (keys removed): ${retired.join(", ")}`);
5456
+ if (unverifiable.length > 0) parts.push(`could not verify: ${unverifiable.join(", ")}`);
5457
+ const knownIds = new Set([...inventory].map((e) => e.agentId ?? path.basename(e.directory)));
5458
+ const ledgerDir = input.credentialsDir ? tombstonesDirForAgentDirectory(input.credentialsDir, homeDir) : path.join(homeDir, ".haven", "tombstones");
5459
+ const ghostRecords = (await readTombstoneRecords(ledgerDir)).filter(
5460
+ (rec) => !knownIds.has(rec.agent_id)
5461
+ );
5462
+ if (ghostRecords.length > 0) {
5463
+ parts.push(
5464
+ `retired records (dir removed): ${ghostRecords.map((rec) => `${rec.agent_id} (${rec.reason})`).join(", ")}`
5488
5465
  );
5489
5466
  }
5467
+ const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
5468
+ const classificationUnreliable = configText === null && runtimeOwnsNoConfig;
5469
+ const supersededLevel = supersededLive.length === 0 ? "ok" : classificationUnreliable ? "advisory" : "failed";
5470
+ checks.push({
5471
+ id: "superseded_agents",
5472
+ label: "Superseded agent credentials",
5473
+ level: supersededLevel,
5474
+ 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("; ")}.`,
5475
+ ...supersededLevel === "failed" ? {
5476
+ 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.`
5477
+ } : supersededLevel === "advisory" ? {
5478
+ 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.`
5479
+ } : {}
5480
+ });
5490
5481
  }
5491
- log(
5492
- "Budget approval is still pending in Haven. Approve it in the dashboard whenever you are ready \u2014 the agent tools unlock the moment you do. Verify later with the read-only haven_get_agent tool."
5493
- );
5494
- return "pending";
5495
- }
5496
- function completionHandoffLines(result, approval, approvalUrl) {
5497
- const approveStep = approvalUrl ? `Approve the budget at ${approvalUrl}. Approval \u2014 not restarting \u2014 unlocks Haven tools.` : "Return to Haven and approve the budget. Approval \u2014 not restarting \u2014 unlocks Haven tools.";
5498
- if (result.errorCode === "manual_runtime_setup_required") {
5499
- return [
5500
- "Next steps:",
5501
- `1. ${approveStep}`,
5502
- "2. Finish the manual MCP setup using the secret-free file references printed above, then start a fresh session in your runtime.",
5503
- `3. ${runtimeVerificationInstruction(result.runtime)}`
5504
- ];
5482
+ const bindings = (await Promise.all(
5483
+ inventory.filter((entry) => entry.classification !== "retired").map(async (entry) => ({ entry, binding: await readMcpServerBinding(entry.directory) }))
5484
+ )).filter((item) => item.binding !== null);
5485
+ const byName = /* @__PURE__ */ new Map();
5486
+ for (const item of bindings) {
5487
+ const list = byName.get(item.binding.server_name) ?? [];
5488
+ list.push(item);
5489
+ byName.set(item.binding.server_name, list);
5505
5490
  }
5506
- if (result.errorCode) {
5507
- return [
5508
- "Recovery: runtime setup is not complete. Resolve the reported problem, then return to Haven for a fresh connection and run its new connector command. Do not manually edit runtime config or paste credentials into prompts, logs, or config."
5509
- ];
5491
+ const rebound = [...byName.entries()].filter(([, items]) => items.length > 1);
5492
+ if (rebound.length > 0) {
5493
+ const parts = rebound.map(([name, items]) => {
5494
+ const ordered = [...items].sort((a, b) => a.binding.bound_at < b.binding.bound_at ? -1 : a.binding.bound_at > b.binding.bound_at ? 1 : 0);
5495
+ const backends = new Set(ordered.map((i) => withoutUserinfo(i.binding.api_url)));
5496
+ return `'${name}': ` + ordered.map((i) => `${i.binding.agent_id} on ${withoutUserinfo(i.binding.api_url)} at ${i.binding.bound_at} [${i.entry.classification}]`).join(" \u2192 ") + (backends.size > 1 ? " (BACKEND CHANGED)" : "");
5497
+ });
5498
+ checks.push({
5499
+ id: "mcp_server_name_rebound",
5500
+ label: "MCP server names bound more than once",
5501
+ level: "advisory",
5502
+ 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.`,
5503
+ repair: `Retire the earlier director(y/ies) with ${RERUN} --unwire <dir> to release the name, or keep both and address them by --name.`
5504
+ });
5510
5505
  }
5511
- if (approval === "ended") {
5512
- return [
5513
- "Next step: this setup ended in Haven before the budget was approved, so there is nothing left to approve on it \u2014 start a fresh connection from the dashboard when ready."
5514
- ];
5506
+ const parkedElsewhere = inventory.filter((entry) => entry.directory !== primaryDirectory && entry.rekeyPending).map((entry) => ({ entry, pending: entry.rekeyPending }));
5507
+ if (parkedElsewhere.length > 0) {
5508
+ const abandoned = parkedElsewhere.filter((item) => item.pending.state !== "pending");
5509
+ const describe = (item) => `${item.entry.slug ?? item.entry.agentId ?? path.basename(item.entry.directory)} (${item.pending.state}, ${item.pending.path})`;
5510
+ checks.push({
5511
+ id: "rekey_pending_elsewhere",
5512
+ label: "Parked re-keys in other credential directories",
5513
+ level: abandoned.length === 0 ? "ok" : "failed",
5514
+ detail: abandoned.length > 0 ? `ABANDONED re-key key material outside the agent this report describes: ${abandoned.map(describe).join(", ")}. Each holds a private key that was generated for a re-key nobody finished.` : `${parkedElsewhere.length} other director(y/ies) hold an open pending re-key: ${parkedElsewhere.map(describe).join(", ")}.`,
5515
+ ...abandoned.length > 0 ? {
5516
+ repair: "Check the Haven agent page for each before deleting: if its on-chain revoke already ran, the agent has no spend authority until you re-grant it (#1868), and that is not visible from this machine. Connect never deletes key material for you."
5517
+ } : {}
5518
+ });
5515
5519
  }
5516
- const profile = runtimeProfile(result.runtime);
5517
- const activation = activationInstructionWithWhy(profile);
5518
- if (approval === "approved") {
5519
- return [
5520
- "Next steps \u2014 the budget is already approved, so there is nothing more to do in Haven:",
5521
- `1. ${activation}`,
5522
- `2. ${runtimeVerificationInstruction(result.runtime)}`
5523
- ];
5520
+ const signerProcess = primaryChecksById.get("signer_process");
5521
+ if (signerProcess) checks.push(signerProcess);
5522
+ const prune = await (deps.pruneSignerRuntimes ?? pruneSignerRuntimes)({ dryRun: true, measure: false }, { homeDir, credentialsDir: input.credentialsDir });
5523
+ const unused = prune.entries.filter((entry) => entry.action === "would_remove");
5524
+ if (unused.length > 0) {
5525
+ checks.push({
5526
+ id: "signer_runtime_unused",
5527
+ label: "Unused signer-runtime directories",
5528
+ level: "advisory",
5529
+ detail: `${unused.length} signer-runtime director${unused.length === 1 ? "y" : "ies"} under ${prune.root} that no credential directory names: ${unused.map((entry) => entry.key).join(", ")}. Nothing is broken; they are left over from earlier pins or overrides (sizes: --prune-signer-runtimes --dry-run).`,
5530
+ repair: `Run: ${RERUN} --prune-signer-runtimes (add --dry-run to list only).`
5531
+ });
5524
5532
  }
5525
- return [
5526
- "Next steps:",
5527
- `1. ${approveStep}`,
5528
- `2. ${activation}`,
5529
- `3. ${runtimeVerificationInstruction(result.runtime)}`
5530
- ];
5533
+ if (resolution.origin === "record") {
5534
+ const rc = checks.find((check) => check.id === "runtime_config");
5535
+ if (rc) {
5536
+ rc.detail += ` (Resolved '${resolution.runtime}' from ${path.join(directory ?? "", exports.CONNECT_OUTCOME_FILENAME)}; pass --runtime to check a different one.)`;
5537
+ }
5538
+ }
5539
+ const restart = normalizedRuntime === null ? false : restartRequiredForRuntime(input2.runtime, deps.env);
5540
+ checks.push({
5541
+ id: "restart",
5542
+ label: "Runtime restart",
5543
+ level: "ok",
5544
+ detail: restart ? "This runtime loads MCP config at startup \u2014 restart it after any repair before expecting the tools to appear." : input2.runtime === "" ? "Runtime is unknown \u2014 whether a restart is needed cannot be determined. Re-run the doctor naming the runtime for a definitive answer." : "No restart requirement known for this runtime."
5545
+ });
5546
+ const wiredChecks = inventory.filter((entry) => entry.classification === "wired").flatMap((entry) => entry.checks);
5547
+ const finalChecks = checks.map(finalizeCheck);
5548
+ const level = rollUpLevel([...finalChecks, ...wiredChecks]);
5549
+ return {
5550
+ version: 1,
5551
+ ok: level !== "failed",
5552
+ level,
5553
+ runtime: input2.runtime,
5554
+ credentialDirectory: primaryDirectory,
5555
+ checks: finalChecks,
5556
+ agents: inventory,
5557
+ ...signerCapabilities ? { signerCapabilities } : {}
5558
+ };
5531
5559
  }
5532
- function activationInstructionWithWhy(profile) {
5533
- if (profile.restartMode === "restart-session") {
5534
- return `${profile.activationInstruction} (The entries are already written and verified \u2014 ${profile.label} only reads MCP config when a session starts, which is why this step is still needed.)`;
5560
+ async function runRepair(input, deps = {}) {
5561
+ const homeDir = deps.homeDir ?? os.homedir();
5562
+ const messages = [];
5563
+ const { directory, others } = await discoverCredentialDirectory(homeDir, input.credentialsDir);
5564
+ if (others.length > 0) {
5565
+ messages.push(`Note: ${others.length} other agent credential dir(s) exist \u2014 run --doctor for their status.`);
5535
5566
  }
5536
- if (profile.restartMode === "restart-app") {
5537
- return `${profile.activationInstruction} (The entries are already written and verified \u2014 ${profile.label} only reads MCP config at app launch, which is why this step is still needed.)`;
5567
+ if (!directory) {
5568
+ return {
5569
+ ok: false,
5570
+ messages: [`No agent credentials found to repair \u2014 run the full setup: ${RERUN} --setup <token>.`]
5571
+ };
5538
5572
  }
5539
- return profile.activationInstruction;
5540
- }
5541
- var RERUN_HINT = sdk.connectorRerunCommand();
5542
- async function listExistingKeyedAgents(baseDir) {
5543
- const root = defaultCredentialRoot(baseDir);
5544
- let entries = [];
5545
- try {
5546
- entries = await promises.readdir(root);
5547
- } catch {
5548
- return [];
5573
+ const resolution = await resolveDoctorRuntime(input, directory);
5574
+ if (resolution.origin === "record") {
5575
+ messages.push(`Runtime not given \u2014 resolved '${resolution.runtime}' from ${path.join(directory, exports.CONNECT_OUTCOME_FILENAME)}.`);
5549
5576
  }
5550
- const out = [];
5551
- for (const entry of entries) {
5552
- const directory = path.join(root, entry);
5553
- try {
5554
- const identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
5555
- if (typeof identity.api_key !== "string" || identity.api_key.length === 0) continue;
5556
- let agent = {};
5557
- try {
5558
- agent = JSON.parse(await promises.readFile(path.join(directory, "agent.json"), "utf8"));
5559
- } catch {
5560
- }
5561
- const { accountAddress } = readStoredAccountAddress(identity, agent);
5562
- out.push({ agentId: typeof identity.agent_id === "string" ? identity.agent_id : entry, directory, accountAddress: accountAddress ?? null });
5563
- } catch {
5564
- }
5577
+ const runtime = resolution.runtime;
5578
+ const input2 = { ...input, runtime };
5579
+ if (runtime === "") {
5580
+ return {
5581
+ ok: false,
5582
+ messages: [
5583
+ "Runtime is unknown \u2014 no runtime flag was given and the connector record carries no resolvable runtime.",
5584
+ `Repair rewrites the runtime config, so it will not guess. Re-run repair naming the runtime \u2014 one of: ${RUNTIME_FLAG_VALUE_LIST.join(", ")}.`
5585
+ ]
5586
+ };
5565
5587
  }
5566
- return out;
5567
- }
5568
- async function listOtherAgentIds(baseDir, currentDirectory) {
5569
- const root = defaultCredentialRoot(baseDir);
5570
- let entries = [];
5588
+ let identity;
5571
5589
  try {
5572
- entries = await promises.readdir(root);
5590
+ identity = JSON.parse(await promises.readFile(path.join(directory, "identity.json"), "utf8"));
5573
5591
  } catch {
5574
- return null;
5592
+ return { ok: false, messages: ["identity.json is unreadable \u2014 re-run the full setup with a fresh token."] };
5575
5593
  }
5576
- const ids = [];
5577
- for (const entry of entries) {
5578
- if (path.join(root, entry) === currentDirectory) continue;
5579
- const identityPath = path.join(root, entry, "identity.json");
5580
- try {
5581
- await promises.stat(identityPath);
5582
- } catch {
5583
- continue;
5584
- }
5594
+ if (!identity.api_key || !(identity.hosted_mcp_url || identity.api_url)) {
5595
+ return { ok: false, messages: ["identity.json lacks the stored API key / hosted URL \u2014 re-run the full setup."] };
5596
+ }
5597
+ const configPath = runtimeConfigPathFor(normalizeRuntimeName(input2.runtime) ?? input2.runtime, homeDir);
5598
+ if (configPath) {
5585
5599
  try {
5586
- const identity = JSON.parse(await promises.readFile(identityPath, "utf8"));
5587
- ids.push(identity.agent_id ?? entry);
5600
+ const existing = await promises.readFile(configPath, "utf8");
5601
+ if (existing.includes("bin/haven-mcp") || existing.includes(".haven/mcp-runtime")) {
5602
+ return {
5603
+ ok: false,
5604
+ messages: [
5605
+ `The config at ${configPath} is the LOCAL-stdio topology (--local). Repair currently rewrites only the hosted+signer shape and will not touch it.`,
5606
+ "Re-run your original connector command (with --local) to repair a local-stdio install."
5607
+ ]
5608
+ };
5609
+ }
5588
5610
  } catch {
5589
- ids.push(entry);
5590
5611
  }
5591
5612
  }
5592
- return ids;
5593
- }
5594
- function printNextSteps(result, log, approval, approvalUrl) {
5595
- for (const line of completionHandoffLines(result, approval, approvalUrl)) log(line);
5613
+ const existingSidecar = await readRuntimeSidecar(directory);
5614
+ const serverName = existingSidecar?.server_name;
5615
+ const signerPath = path.join(directory, "signer.json");
5616
+ const prepared = await prepareSignerRuntime(
5617
+ { credentialDirectory: directory, signerPath, homeDir, serverName },
5618
+ { runCommand: deps.runCommand, env: deps.env }
5619
+ );
5620
+ messages.push(...prepared.messages);
5621
+ const names = serverNamesFor(serverName);
5622
+ messages.push(`Rewriting MCP entries ${names.hosted} / ${names.signer}${serverName ? ` (agent "${serverName}")` : " (unnamed pair)"} \u2014 no other pair is touched.`);
5623
+ const configResult = await writeRuntimeConfig({
5624
+ // Normalized for the WRITE too (#3145 review round 3): `writeRuntimeConfig`
5625
+ // switches on the id, and the raw alias fell to its "manual runtime" arm
5626
+ // — a repair that reported success while writing nothing.
5627
+ runtime: normalizeRuntimeName(input2.runtime) ?? input2.runtime,
5628
+ hostedMcpUrl: identity.hosted_mcp_url ?? `${identity.api_url}/mcp`,
5629
+ apiKey: identity.api_key,
5630
+ identityPath: path.join(directory, "identity.json"),
5631
+ signerPath,
5632
+ credentialDirectory: directory,
5633
+ signerCommand: { command: prepared.command, args: prepared.args },
5634
+ homeDir,
5635
+ mode: "hosted",
5636
+ serverName
5637
+ });
5638
+ messages.push(...configResult.messages);
5639
+ messages.push("Repair complete \u2014 restart the runtime, then verify with --doctor.");
5640
+ return { ok: true, messages };
5596
5641
  }
5642
+ var RERUN;
5643
+ var init_doctor = __esm({
5644
+ "src/doctor.ts"() {
5645
+ init_prune_runtimes();
5646
+ init_runtime_manifest();
5647
+ init_probes();
5648
+ init_signer_runtime();
5649
+ init_runtime_spec_override();
5650
+ init_config_writers();
5651
+ init_runtime_registry();
5652
+ init_signer_consent();
5653
+ init_tombstone();
5654
+ init_server_names();
5655
+ init_storage();
5656
+ init_redact();
5657
+ RERUN = sdk.connectorRerunCommand();
5658
+ }
5659
+ });
5597
5660
 
5598
5661
  // src/args.ts
5662
+ init_runtime();
5599
5663
  init_server_names();
5600
5664
  init_rekey_messages();
5601
5665
  function parseArgs(argv, env = process.env) {
5602
5666
  const options = {
5603
5667
  apiBaseUrl: env.HAVEN_API_URL ?? "http://localhost:3001",
5604
- connectorVersion: CONNECTOR_VERSION
5668
+ connectorVersion: exports.CONNECTOR_VERSION
5605
5669
  };
5606
5670
  let help = false;
5607
5671
  let json = false;
@@ -5678,7 +5742,7 @@ function parseArgs(argv, env = process.env) {
5678
5742
  } else if (arg === "--local" || arg === "--local-mcp") {
5679
5743
  options.localMcp = true;
5680
5744
  } else if (arg === "--version") {
5681
- process.stdout.write(`${CONNECTOR_VERSION}
5745
+ process.stdout.write(`${exports.CONNECTOR_VERSION}
5682
5746
  `);
5683
5747
  process.exit(0);
5684
5748
  } else {
@@ -5760,8 +5824,10 @@ function parseArgs(argv, env = process.env) {
5760
5824
  return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir };
5761
5825
  }
5762
5826
  if (doctor || repair) {
5763
- if (!options.runtime) {
5764
- throw new Error("--doctor/--repair need --runtime <runtime> (which config to examine).");
5827
+ if (repair && !options.runtime?.trim()) {
5828
+ throw new Error(
5829
+ "--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."
5830
+ );
5765
5831
  }
5766
5832
  return { options, help, json, doctor, repair, tombstone, rekey };
5767
5833
  }
@@ -5812,9 +5878,12 @@ function helpText() {
5812
5878
  " --json Emit one versioned, secret-free result object on stdout; progress stays on stderr.",
5813
5879
  " --doctor Diagnose an existing setup (read-only, no token): config, credentials,",
5814
5880
  " signer runtime, hosted MCP, and a live signer handshake. Exits non-zero only on a failed check; an advisory (!) exits 0.",
5881
+ " --runtime is optional: without it the doctor checks the runtime the setup recorded,",
5882
+ " and reports a failure (not a pass) when no record names one.",
5815
5883
  " --repair Repair, then re-diagnose (implies --doctor): reinstall the pinned signer",
5816
5884
  " runtime, rewrite the wrapper and runtime config from stored credentials.",
5817
5885
  " Hosted topology only (refuses to touch a --local config). No keys, no token.",
5886
+ " Requires --runtime <runtime>: it rewrites that config, so the runtime is never inherited.",
5818
5887
  " --rekey Replace this agent's signing key (no token). Generates a fresh keypair HERE and",
5819
5888
  " prints its public address to paste into the Haven agent page. Nothing changes",
5820
5889
  " until you finish; the agent keeps working on its old key throughout.",
@@ -5863,6 +5932,8 @@ function requireValue(argv, index, option) {
5863
5932
  init_api();
5864
5933
  init_key();
5865
5934
  init_redact();
5935
+ init_runtime();
5936
+ init_runtime();
5866
5937
  init_redact();
5867
5938
  init_connect_error();
5868
5939
  function failSubcommand(io, json, err, envelope, fallback) {
@@ -5919,7 +5990,7 @@ async function runCli(argv, io = {
5919
5990
  return 0;
5920
5991
  }
5921
5992
  if (parsed.tombstone) {
5922
- const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5993
+ const { writeAgentTombstone: writeAgentTombstone2, tombstonesDirForAgentDirectory: tombstonesDirForAgentDirectory2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5923
5994
  const { readFile: readFile15 } = await import('fs/promises');
5924
5995
  const { join: join13 } = await import('path');
5925
5996
  try {
@@ -5931,11 +6002,16 @@ async function runCli(argv, io = {
5931
6002
  agentId = identity.agent_id ?? "unknown";
5932
6003
  } catch {
5933
6004
  }
6005
+ const ledgerDir = tombstonesDirForAgentDirectory2(parsed.tombstone.directory);
5934
6006
  const info = await writeAgentTombstone2({
5935
6007
  directory: parsed.tombstone.directory,
5936
6008
  agentId,
5937
6009
  reason: parsed.tombstone.reason ?? "retired by operator via --tombstone",
5938
- replacedBy: parsed.tombstone.replacedBy
6010
+ replacedBy: parsed.tombstone.replacedBy,
6011
+ // #3251: the ledger of the root that holds the retired directory —
6012
+ // ~/.haven/agents/<x> → ~/.haven/tombstones, exactly as before; a
6013
+ // directory under any other root → <root>/.tombstones.
6014
+ tombstonesDir: ledgerDir
5939
6015
  });
5940
6016
  if (parsed.json) {
5941
6017
  io.stdout(`${redactSecrets(JSON.stringify({ tombstoned: true, ...info }))}
@@ -5947,11 +6023,18 @@ async function runCli(argv, io = {
5947
6023
  "Key files were NOT touched and nothing was revoked \u2014 revoke the agent on the Haven agent page if you have not already.\n"
5948
6024
  );
5949
6025
  io.stdout(
5950
- `A surviving tombstone record was mirrored to ${redactSecrets(info.recordPath)}
6026
+ info.recordPath !== null ? `A surviving tombstone record was mirrored to ${redactSecrets(info.recordPath)}
6027
+ ` : (
6028
+ // #3259: the directory IS tombstoned; only the surviving copy failed.
6029
+ redactSecrets(
6030
+ `! The surviving tombstone record could NOT be written to ${ledgerDir} (${info.mirrorError}). The in-place TOMBSTONE.json stands, but --doctor will not list this retirement once the directory is deleted.
5951
6031
  `
6032
+ )
6033
+ )
5952
6034
  );
5953
6035
  io.stdout(
5954
- "Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot from its own start time, and the tombstone only speaks when a stale host next probes the old path. The mirrored record keeps this retirement observable even after the agent directory itself is deleted.\n"
6036
+ "Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot from its own start time, and the tombstone only speaks when a stale host next probes the old path." + // #3259 review: never claim a copy that the line above says was not written.
6037
+ (info.recordPath !== null ? " The mirrored record keeps this retirement observable even after the agent directory itself is deleted." : "") + "\n"
5955
6038
  );
5956
6039
  }
5957
6040
  return 0;
@@ -5964,11 +6047,13 @@ async function runCli(argv, io = {
5964
6047
  }
5965
6048
  if (parsed.unwire) {
5966
6049
  const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
6050
+ const { tombstonesDirForAgentDirectory: tombstonesDirForAgentDirectory2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
5967
6051
  const { homedir: homedir11 } = await import('os');
5968
6052
  const { join: join13 } = await import('path');
5969
6053
  const homeDir = homedir11();
5970
6054
  const root = parsed.options.credentialsDir ?? join13(homeDir, ".haven", "agents");
5971
6055
  const directory = parsed.unwireDir ?? (parsed.options.serverName ? join13(root, parsed.options.serverName) : root);
6056
+ const ledgerDir = tombstonesDirForAgentDirectory2(directory, homeDir);
5972
6057
  try {
5973
6058
  const result = await unwireAgent2({
5974
6059
  directory,
@@ -5976,7 +6061,14 @@ async function runCli(argv, io = {
5976
6061
  reason: parsed.unwire.reason,
5977
6062
  replacedBy: parsed.unwire.replacedBy,
5978
6063
  destroyKeyMaterial: parsed.unwire.destroyKeyMaterial,
5979
- homeDir
6064
+ homeDir,
6065
+ // #3251: the ledger of the root that holds the directory being
6066
+ // retired. Derived from the RESOLVED directory, not from
6067
+ // --credentials-dir: `--unwire --credentials-dir <path>` names the agent
6068
+ // directory itself, not a root. ~/.haven/agents/<slug> →
6069
+ // ~/.haven/tombstones, exactly as before; any other root →
6070
+ // <root>/.tombstones.
6071
+ tombstonesDir: ledgerDir
5980
6072
  });
5981
6073
  const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
5982
6074
  const retained = result.teardown.status === "retained";
@@ -5989,6 +6081,8 @@ async function runCli(argv, io = {
5989
6081
  slug: result.slug ?? null,
5990
6082
  directory: result.directory,
5991
6083
  tombstoned: result.tombstoned,
6084
+ // #3259: additive — present only when the surviving ledger record failed.
6085
+ ...result.mirrorError !== void 0 ? { mirror_error: result.mirrorError } : {},
5992
6086
  runtimes: result.runtimes.map((r) => ({
5993
6087
  runtime: r.runtime,
5994
6088
  label: r.label,
@@ -6014,6 +6108,12 @@ async function runCli(argv, io = {
6014
6108
  io.stdout(
6015
6109
  result.tombstoned ? " \xB7 Tombstoned first: any long-lived host still resolving the old wrapper gets the HAVEN-TOMBSTONE diagnosis.\n" : " \xB7 Directory was already tombstoned.\n"
6016
6110
  );
6111
+ if (result.mirrorError !== void 0) {
6112
+ io.stdout(redactSecrets(
6113
+ ` ! Surviving tombstone record: NOT written to ${ledgerDir} (${result.mirrorError}) \u2014 the in-place tombstone stands; --doctor will not list this retirement once the directory is deleted.
6114
+ `
6115
+ ));
6116
+ }
6017
6117
  for (const r of result.runtimes) {
6018
6118
  const mark = r.status === "removed" ? "\u2713" : r.status === "clean" ? "\u2013" : "\u2717";
6019
6119
  io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
@@ -6266,11 +6366,10 @@ init_runtime_install();
6266
6366
  init_signer_runtime();
6267
6367
  init_runtime_registry();
6268
6368
  init_connect_error();
6369
+ init_installed_clients();
6269
6370
  init_storage();
6270
6371
  init_runtime_manifest();
6271
6372
 
6272
- exports.CONNECTOR_VERSION = CONNECTOR_VERSION;
6273
- exports.CONNECT_OUTCOME_SCHEMA_VERSION = CONNECT_OUTCOME_SCHEMA_VERSION;
6274
6373
  exports.completionOutcome = completionOutcome;
6275
6374
  exports.createConnectApiClient = createConnectApiClient;
6276
6375
  exports.defaultAgentDirectory = defaultAgentDirectory;