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