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