@haven_ai/connect 0.1.31-alpha.0 → 0.1.33-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 +75 -3
- package/dist/cli.cjs +588 -43
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +589 -44
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +588 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +214 -59
- package/dist/index.d.ts +214 -59
- package/dist/index.js +587 -45
- package/dist/index.js.map +1 -1
- package/package.json +15 -8
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
import { Wallet } from 'ethers';
|
|
4
|
-
import { mkdir, rm, stat, readdir, readFile, chmod, access,
|
|
4
|
+
import { mkdir, rm, stat, readdir, readFile, writeFile, chmod, access, unlink, rename } from 'fs/promises';
|
|
5
5
|
import { homedir, platform } from 'os';
|
|
6
6
|
import { join, resolve, dirname, basename } from 'path';
|
|
7
7
|
import { ensureConsent, computeConsentHash, loadCredentials, consentInputFromClient, registeredToolNames, MCP_VERSION } from '@haven_ai/mcp';
|
|
@@ -497,11 +497,19 @@ async function restrictPermissions(path, mode, warn) {
|
|
|
497
497
|
);
|
|
498
498
|
}
|
|
499
499
|
}
|
|
500
|
-
|
|
500
|
+
async function writeConnectOutcomeRecord(directory, outcome, warn) {
|
|
501
|
+
const path = join(directory, CONNECT_OUTCOME_FILENAME);
|
|
502
|
+
await writeFile(path, `${JSON.stringify(outcome, null, 2)}
|
|
503
|
+
`, { mode: 384 });
|
|
504
|
+
await restrictPermissions(path, 384, warn);
|
|
505
|
+
return path;
|
|
506
|
+
}
|
|
507
|
+
var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME;
|
|
501
508
|
var init_storage = __esm({
|
|
502
509
|
"src/storage.ts"() {
|
|
503
510
|
REKEY_PENDING_FILENAME = "rekey-pending.json";
|
|
504
511
|
REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
512
|
+
CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
|
|
505
513
|
}
|
|
506
514
|
});
|
|
507
515
|
function mcpPackageSpec() {
|
|
@@ -520,9 +528,9 @@ var init_runtime_manifest = __esm({
|
|
|
520
528
|
mcpPackage: "@haven_ai/mcp",
|
|
521
529
|
mcpVersion: MCP_VERSION,
|
|
522
530
|
sdkPackage: "@haven_ai/sdk",
|
|
523
|
-
sdkVersion: "0.1.
|
|
531
|
+
sdkVersion: "0.1.33-alpha.0",
|
|
524
532
|
signerPackage: "@haven_ai/signer",
|
|
525
|
-
signerVersion: "0.1.
|
|
533
|
+
signerVersion: "0.1.33-alpha.0",
|
|
526
534
|
// Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
|
|
527
535
|
// while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
|
|
528
536
|
// so the guard that was supposed to enforce the floor waved Node v23 through
|
|
@@ -664,6 +672,97 @@ function isHermesEnvAssignment(line, envKey) {
|
|
|
664
672
|
function isAmbiguousHermesEnvLine(line, envKey) {
|
|
665
673
|
return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}\\b`).test(line);
|
|
666
674
|
}
|
|
675
|
+
function removeHermesYaml(existingYaml, names, configPath) {
|
|
676
|
+
if (!existingYaml?.trim()) return existingYaml ?? "";
|
|
677
|
+
const doc = parseDocument(existingYaml, { keepSourceTokens: true });
|
|
678
|
+
if (doc.errors.length > 0 || !isMap(doc.contents)) {
|
|
679
|
+
throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
|
|
680
|
+
}
|
|
681
|
+
const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
|
|
682
|
+
if (!mcpPair || !isMap(mcpPair.value)) return existingYaml;
|
|
683
|
+
const toRemove = mcpPair.value.items.filter((item) => {
|
|
684
|
+
const name = item.key?.toString();
|
|
685
|
+
return name === names.hosted || name === names.signer;
|
|
686
|
+
});
|
|
687
|
+
if (toRemove.length === 0) return existingYaml;
|
|
688
|
+
if (toRemove.length !== mcpPair.value.items.length && toRemove.some((item) => !item.key?.range || !item.value?.range)) {
|
|
689
|
+
throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "an MCP server entry cannot be safely removed");
|
|
690
|
+
}
|
|
691
|
+
let out = existingYaml;
|
|
692
|
+
const ranges = toRemove.map((item) => {
|
|
693
|
+
const keyStart = item.key.range[0];
|
|
694
|
+
const valueEnd = item.value.range[1];
|
|
695
|
+
const lineStart = existingYaml.lastIndexOf("\n", keyStart - 1) + 1;
|
|
696
|
+
let end = valueEnd;
|
|
697
|
+
if (existingYaml.slice(end).startsWith("\r\n")) end += 2;
|
|
698
|
+
else if (existingYaml[end] === "\n") end += 1;
|
|
699
|
+
return [lineStart, end];
|
|
700
|
+
});
|
|
701
|
+
ranges.sort((a, b) => b[0] - a[0]);
|
|
702
|
+
for (const [start, end] of ranges) out = out.slice(0, start) + out.slice(end);
|
|
703
|
+
if (toRemove.length === mcpPair.value.items.length) {
|
|
704
|
+
const reparsed = parseDocument(out, { keepSourceTokens: true });
|
|
705
|
+
if (!reparsed.errors.length && isMap(reparsed.contents)) {
|
|
706
|
+
const pair = reparsed.contents.items.find((item) => item.key?.toString() === "mcp_servers");
|
|
707
|
+
if (pair && pair.key?.range && pair.value?.range) {
|
|
708
|
+
const keyStart = pair.key.range[0];
|
|
709
|
+
const valueEnd = pair.value.range[1];
|
|
710
|
+
const lineStart = out.lastIndexOf("\n", keyStart - 1) + 1;
|
|
711
|
+
let end = valueEnd;
|
|
712
|
+
if (out.slice(end).startsWith("\r\n")) end += 2;
|
|
713
|
+
else if (out[end] === "\n") end += 1;
|
|
714
|
+
out = out.slice(0, lineStart) + out.slice(end);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
function removeHermesEnv(existingEnv, envKey) {
|
|
721
|
+
if (!existingEnv) return "";
|
|
722
|
+
const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
|
|
723
|
+
const hasTrailingNewline = /\r?\n$/.test(existingEnv);
|
|
724
|
+
const lines = existingEnv.split(/\r?\n/);
|
|
725
|
+
if (hasTrailingNewline) lines.pop();
|
|
726
|
+
let removed = 0;
|
|
727
|
+
const kept = lines.filter((line) => {
|
|
728
|
+
if (isHermesEnvAssignment(line, envKey)) {
|
|
729
|
+
removed += 1;
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
if (isAmbiguousHermesEnvLine(line, envKey)) {
|
|
733
|
+
throw new Error("Hermes environment contains an ambiguous managed key");
|
|
734
|
+
}
|
|
735
|
+
return true;
|
|
736
|
+
});
|
|
737
|
+
if (removed === 0) return existingEnv;
|
|
738
|
+
const joined = kept.join(lineEnding);
|
|
739
|
+
return hasTrailingNewline ? `${joined}${lineEnding}` : joined;
|
|
740
|
+
}
|
|
741
|
+
function removeJsonMcpConfig(existingJson, serverRoot, names, configPath) {
|
|
742
|
+
if (!existingJson?.trim()) return existingJson ?? "";
|
|
743
|
+
const config = parseJsonObject(existingJson, configPath);
|
|
744
|
+
const root = config[serverRoot];
|
|
745
|
+
if (!root || typeof root !== "object" || Array.isArray(root)) return existingJson;
|
|
746
|
+
const servers = root;
|
|
747
|
+
let removed = false;
|
|
748
|
+
for (const name of [names.hosted, names.signer]) {
|
|
749
|
+
if (name in servers) {
|
|
750
|
+
delete servers[name];
|
|
751
|
+
removed = true;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (!removed) return existingJson;
|
|
755
|
+
if (Object.keys(servers).length === 0) delete config[serverRoot];
|
|
756
|
+
return `${JSON.stringify(config, null, 2)}
|
|
757
|
+
`;
|
|
758
|
+
}
|
|
759
|
+
function removeCodexToml(existingToml, names) {
|
|
760
|
+
let next = removeTomlTableTree(
|
|
761
|
+
removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
|
|
762
|
+
`mcp_servers.${names.codexSigner}`
|
|
763
|
+
);
|
|
764
|
+
return next;
|
|
765
|
+
}
|
|
667
766
|
function appendHermesMcpServers(source, servers) {
|
|
668
767
|
const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
|
|
669
768
|
if (documentEnd) {
|
|
@@ -1936,6 +2035,9 @@ var init_skill_install = __esm({
|
|
|
1936
2035
|
});
|
|
1937
2036
|
|
|
1938
2037
|
// src/connect-error.ts
|
|
2038
|
+
function isConnectError(err) {
|
|
2039
|
+
return err instanceof ConnectError;
|
|
2040
|
+
}
|
|
1939
2041
|
var ConnectError;
|
|
1940
2042
|
var init_connect_error = __esm({
|
|
1941
2043
|
"src/connect-error.ts"() {
|
|
@@ -2650,14 +2752,49 @@ var init_runtime_install = __esm({
|
|
|
2650
2752
|
}
|
|
2651
2753
|
});
|
|
2652
2754
|
|
|
2755
|
+
// src/rekey-messages.ts
|
|
2756
|
+
var REKEY_FINISH_NEEDS_API_KEY;
|
|
2757
|
+
var init_rekey_messages = __esm({
|
|
2758
|
+
"src/rekey-messages.ts"() {
|
|
2759
|
+
REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
|
|
2760
|
+
}
|
|
2761
|
+
});
|
|
2762
|
+
|
|
2653
2763
|
// src/tombstone.ts
|
|
2654
2764
|
var tombstone_exports = {};
|
|
2655
2765
|
__export(tombstone_exports, {
|
|
2656
2766
|
TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
|
|
2657
2767
|
TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
|
|
2768
|
+
defaultTombstonesDir: () => defaultTombstonesDir,
|
|
2658
2769
|
readAgentTombstone: () => readAgentTombstone,
|
|
2770
|
+
readTombstoneRecords: () => readTombstoneRecords,
|
|
2659
2771
|
writeAgentTombstone: () => writeAgentTombstone
|
|
2660
2772
|
});
|
|
2773
|
+
function defaultTombstonesDir(baseDir) {
|
|
2774
|
+
return join(baseDir ?? join(homedir(), ".haven"), "tombstones");
|
|
2775
|
+
}
|
|
2776
|
+
async function readTombstoneRecords(tombstonesDir) {
|
|
2777
|
+
const root = tombstonesDir ?? defaultTombstonesDir();
|
|
2778
|
+
let entries = [];
|
|
2779
|
+
try {
|
|
2780
|
+
entries = await readdir(root);
|
|
2781
|
+
} catch {
|
|
2782
|
+
return [];
|
|
2783
|
+
}
|
|
2784
|
+
const records = [];
|
|
2785
|
+
for (const entry of entries) {
|
|
2786
|
+
if (!entry.endsWith(".json")) continue;
|
|
2787
|
+
const recordPath = join(root, entry);
|
|
2788
|
+
try {
|
|
2789
|
+
const parsed = JSON.parse(await readFile(recordPath, "utf8"));
|
|
2790
|
+
if (typeof parsed?.agent_id === "string") {
|
|
2791
|
+
records.push({ ...parsed, recordPath });
|
|
2792
|
+
}
|
|
2793
|
+
} catch {
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return records;
|
|
2797
|
+
}
|
|
2661
2798
|
function tombstoneScript(info) {
|
|
2662
2799
|
const lines = [
|
|
2663
2800
|
`${TOMBSTONE_MARKER}: this Haven agent was retired.`,
|
|
@@ -2688,7 +2825,11 @@ function tombstoneScript(info) {
|
|
|
2688
2825
|
async function writeAgentTombstone(input) {
|
|
2689
2826
|
const dirStat = await stat(input.directory).catch(() => null);
|
|
2690
2827
|
if (!dirStat?.isDirectory()) {
|
|
2691
|
-
throw new
|
|
2828
|
+
throw new ConnectError(
|
|
2829
|
+
"tombstone_directory_not_found",
|
|
2830
|
+
`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.`,
|
|
2831
|
+
"retry_with_an_existing_agent_directory"
|
|
2832
|
+
);
|
|
2692
2833
|
}
|
|
2693
2834
|
const info = {
|
|
2694
2835
|
// reason / replaced_by are persisted to disk and re-emitted to the host's
|
|
@@ -2705,8 +2846,13 @@ async function writeAgentTombstone(input) {
|
|
|
2705
2846
|
const wrapperPath = join(binDir, "haven-signer.mjs");
|
|
2706
2847
|
await writeFile(wrapperPath, tombstoneScript(info), "utf8");
|
|
2707
2848
|
await chmod(wrapperPath, 493);
|
|
2708
|
-
|
|
2709
|
-
|
|
2849
|
+
const record = JSON.stringify(info, null, 2) + "\n";
|
|
2850
|
+
await writeFile(join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
|
|
2851
|
+
const root = input.tombstonesDir ?? defaultTombstonesDir();
|
|
2852
|
+
await mkdir(root, { recursive: true, mode: 448 });
|
|
2853
|
+
const recordPath = join(root, `${info.agent_id}.json`);
|
|
2854
|
+
await writeFile(recordPath, record, { mode: MIRROR_MODE });
|
|
2855
|
+
return { ...info, recordPath };
|
|
2710
2856
|
}
|
|
2711
2857
|
async function readAgentTombstone(directory) {
|
|
2712
2858
|
try {
|
|
@@ -2717,15 +2863,185 @@ async function readAgentTombstone(directory) {
|
|
|
2717
2863
|
return null;
|
|
2718
2864
|
}
|
|
2719
2865
|
}
|
|
2720
|
-
var TOMBSTONE_FILENAME, TOMBSTONE_MARKER;
|
|
2866
|
+
var TOMBSTONE_FILENAME, MIRROR_MODE, TOMBSTONE_MARKER;
|
|
2721
2867
|
var init_tombstone = __esm({
|
|
2722
2868
|
"src/tombstone.ts"() {
|
|
2723
2869
|
init_redact();
|
|
2870
|
+
init_connect_error();
|
|
2724
2871
|
TOMBSTONE_FILENAME = "TOMBSTONE.json";
|
|
2872
|
+
MIRROR_MODE = 384;
|
|
2725
2873
|
TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
|
|
2726
2874
|
}
|
|
2727
2875
|
});
|
|
2728
2876
|
|
|
2877
|
+
// src/unwire.ts
|
|
2878
|
+
var unwire_exports = {};
|
|
2879
|
+
__export(unwire_exports, {
|
|
2880
|
+
unwireAgent: () => unwireAgent
|
|
2881
|
+
});
|
|
2882
|
+
function identityAt(directory) {
|
|
2883
|
+
return readFile(join(directory, "identity.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
2884
|
+
}
|
|
2885
|
+
function removeForModel(text, model, names, path) {
|
|
2886
|
+
switch (model.kind) {
|
|
2887
|
+
case "yaml":
|
|
2888
|
+
return removeHermesYaml(text, names, path);
|
|
2889
|
+
case "toml":
|
|
2890
|
+
return removeCodexToml(text, names);
|
|
2891
|
+
case "json":
|
|
2892
|
+
return removeJsonMcpConfig(text, model.serverRoot ?? "mcpServers", names, path);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
function envLineValue(envText, envKey) {
|
|
2896
|
+
const line = envText.split(/\r?\n/).find((candidate) => new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(candidate));
|
|
2897
|
+
if (!line) return void 0;
|
|
2898
|
+
let value = line.slice(line.indexOf("=") + 1);
|
|
2899
|
+
value = value.trim();
|
|
2900
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2901
|
+
value = value.slice(1, -1);
|
|
2902
|
+
}
|
|
2903
|
+
return value;
|
|
2904
|
+
}
|
|
2905
|
+
async function readOptionalText(path) {
|
|
2906
|
+
try {
|
|
2907
|
+
return await readFile(path, "utf8");
|
|
2908
|
+
} catch {
|
|
2909
|
+
return null;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
async function unwireAgent(input) {
|
|
2913
|
+
const homeDir = input.homeDir ?? homedir();
|
|
2914
|
+
const [identity, sidecar] = await Promise.all([identityAt(input.directory), readRuntimeSidecar(input.directory)]);
|
|
2915
|
+
const agentId = identity?.agent_id ?? "unknown";
|
|
2916
|
+
const slug = input.slug ?? sidecar?.server_name;
|
|
2917
|
+
const names = serverNamesFor(slug);
|
|
2918
|
+
const runtimes = [];
|
|
2919
|
+
let tombstoned = false;
|
|
2920
|
+
const tombstonePath = join(input.directory, TOMBSTONE_FILENAME);
|
|
2921
|
+
if (await readOptionalText(tombstonePath) === null) {
|
|
2922
|
+
await writeAgentTombstone({
|
|
2923
|
+
directory: input.directory,
|
|
2924
|
+
agentId,
|
|
2925
|
+
reason: input.reason ?? "unwired via --unwire",
|
|
2926
|
+
replacedBy: input.replacedBy,
|
|
2927
|
+
tombstonesDir: input.tombstonesDir
|
|
2928
|
+
});
|
|
2929
|
+
tombstoned = true;
|
|
2930
|
+
}
|
|
2931
|
+
for (const model of RUNTIMES) {
|
|
2932
|
+
const path = runtimeConfigPathFor(model.runtime, homeDir);
|
|
2933
|
+
if (path === null) continue;
|
|
2934
|
+
const text = await readOptionalText(path);
|
|
2935
|
+
if (text === null) continue;
|
|
2936
|
+
try {
|
|
2937
|
+
if (!slug) {
|
|
2938
|
+
const owned = sidecar?.wrapper_path != null && text.includes(sidecar.wrapper_path);
|
|
2939
|
+
if (!owned) {
|
|
2940
|
+
const pairPresent = removeForModel(text, model, names, path) !== text;
|
|
2941
|
+
if (!pairPresent) continue;
|
|
2942
|
+
runtimes.push({
|
|
2943
|
+
runtime: model.runtime,
|
|
2944
|
+
label: model.label,
|
|
2945
|
+
path,
|
|
2946
|
+
status: "refused",
|
|
2947
|
+
detail: "the bare haven / haven-signer pair in this config launches a different agent (no wrapper from this directory in the file); refusing to guess which one is yours"
|
|
2948
|
+
});
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
const next = removeForModel(text, model, names, path);
|
|
2953
|
+
if (next === text) continue;
|
|
2954
|
+
await writeFile(path, next, "utf8");
|
|
2955
|
+
runtimes.push({ runtime: model.runtime, label: model.label, path, status: "removed" });
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
if (err instanceof UnreadableRuntimeConfigError) {
|
|
2958
|
+
runtimes.push({ runtime: model.runtime, label: model.label, path, status: "unreadable", detail: err.message });
|
|
2959
|
+
continue;
|
|
2960
|
+
}
|
|
2961
|
+
throw err;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
const envPath = hermesEnvPath(homeDir);
|
|
2965
|
+
const envText = await readOptionalText(envPath);
|
|
2966
|
+
if (envText !== null) {
|
|
2967
|
+
try {
|
|
2968
|
+
if (!slug) {
|
|
2969
|
+
const value = envLineValue(envText, names.hermesEnvKey);
|
|
2970
|
+
if (value === void 0) {
|
|
2971
|
+
} else if (!identity?.api_key) {
|
|
2972
|
+
runtimes.push({
|
|
2973
|
+
runtime: "hermes",
|
|
2974
|
+
label: "Hermes env",
|
|
2975
|
+
path: envPath,
|
|
2976
|
+
status: "refused",
|
|
2977
|
+
detail: "MCP_HAVEN_API_KEY is shared by unnamed agents and this directory has no stored API key to compare against; refusing to remove another agent's credential"
|
|
2978
|
+
});
|
|
2979
|
+
} else if (value !== identity.api_key) {
|
|
2980
|
+
runtimes.push({
|
|
2981
|
+
runtime: "hermes",
|
|
2982
|
+
label: "Hermes env",
|
|
2983
|
+
path: envPath,
|
|
2984
|
+
status: "refused",
|
|
2985
|
+
detail: "MCP_HAVEN_API_KEY in the Hermes env holds a different agent's key; refusing to remove another agent's credential"
|
|
2986
|
+
});
|
|
2987
|
+
} else {
|
|
2988
|
+
const next = removeHermesEnv(envText, names.hermesEnvKey);
|
|
2989
|
+
if (next !== envText) {
|
|
2990
|
+
await writeFile(envPath, next, "utf8");
|
|
2991
|
+
runtimes.push({ runtime: "hermes", label: "Hermes env", path: envPath, status: "removed" });
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
} else {
|
|
2995
|
+
const next = removeHermesEnv(envText, names.hermesEnvKey);
|
|
2996
|
+
if (next !== envText) {
|
|
2997
|
+
await writeFile(envPath, next, "utf8");
|
|
2998
|
+
runtimes.push({ runtime: "hermes", label: "Hermes env", path: envPath, status: "removed" });
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
} catch (err) {
|
|
3002
|
+
if (err instanceof Error && err.message.includes("ambiguous managed key")) {
|
|
3003
|
+
runtimes.push({
|
|
3004
|
+
runtime: "hermes",
|
|
3005
|
+
label: "Hermes env",
|
|
3006
|
+
path: envPath,
|
|
3007
|
+
status: "refused",
|
|
3008
|
+
detail: err.message
|
|
3009
|
+
});
|
|
3010
|
+
} else {
|
|
3011
|
+
throw err;
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
await Promise.all([
|
|
3016
|
+
rm(join(input.directory, "signer.json"), { force: true }),
|
|
3017
|
+
rm(join(input.directory, REKEY_PENDING_FILENAME), { force: true })
|
|
3018
|
+
]);
|
|
3019
|
+
if (identity && identity.api_key !== void 0) {
|
|
3020
|
+
const { api_key: _dropped, ...rest } = identity;
|
|
3021
|
+
await writeFile(join(input.directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
|
|
3022
|
+
`, { mode: 384 });
|
|
3023
|
+
}
|
|
3024
|
+
return { directory: input.directory, agentId, slug, tombstoned, runtimes };
|
|
3025
|
+
}
|
|
3026
|
+
var RUNTIMES;
|
|
3027
|
+
var init_unwire = __esm({
|
|
3028
|
+
"src/unwire.ts"() {
|
|
3029
|
+
init_config_writers();
|
|
3030
|
+
init_server_names();
|
|
3031
|
+
init_signer_runtime();
|
|
3032
|
+
init_tombstone();
|
|
3033
|
+
init_storage();
|
|
3034
|
+
RUNTIMES = [
|
|
3035
|
+
{ runtime: "hermes", label: "Hermes Agent config", kind: "yaml" },
|
|
3036
|
+
{ runtime: "codex-cli", label: "Codex config", kind: "toml" },
|
|
3037
|
+
{ runtime: "cursor", label: "Cursor MCP config", kind: "json", serverRoot: "mcpServers" },
|
|
3038
|
+
{ runtime: "vscode", label: "VS Code MCP config", kind: "json", serverRoot: "servers" },
|
|
3039
|
+
{ runtime: "vscode-insiders", label: "VS Code Insiders MCP config", kind: "json", serverRoot: "servers" },
|
|
3040
|
+
{ runtime: "claude-desktop", label: "Claude Desktop config", kind: "json", serverRoot: "mcpServers" }
|
|
3041
|
+
];
|
|
3042
|
+
}
|
|
3043
|
+
});
|
|
3044
|
+
|
|
2729
3045
|
// src/rekey.ts
|
|
2730
3046
|
var rekey_exports = {};
|
|
2731
3047
|
__export(rekey_exports, {
|
|
@@ -2785,7 +3101,7 @@ async function startRekey(options, deps = {}) {
|
|
|
2785
3101
|
async function finishRekey(options, deps = {}) {
|
|
2786
3102
|
const now = deps.now ?? (() => Date.now());
|
|
2787
3103
|
if (!options.newApiKey) {
|
|
2788
|
-
throw new Error(
|
|
3104
|
+
throw new Error(REKEY_FINISH_NEEDS_API_KEY);
|
|
2789
3105
|
}
|
|
2790
3106
|
const stored = await readStoredCredentials(
|
|
2791
3107
|
options.serverName,
|
|
@@ -2928,6 +3244,7 @@ var init_rekey = __esm({
|
|
|
2928
3244
|
init_signer_runtime();
|
|
2929
3245
|
init_key();
|
|
2930
3246
|
init_redact();
|
|
3247
|
+
init_rekey_messages();
|
|
2931
3248
|
init_server_names();
|
|
2932
3249
|
init_storage();
|
|
2933
3250
|
}
|
|
@@ -3407,6 +3724,15 @@ async function runDoctor(input, deps = {}) {
|
|
|
3407
3724
|
if (revoked.length > 0) parts.push(`already revoked: ${revoked.join(", ")}`);
|
|
3408
3725
|
if (retired.length > 0) parts.push(`tombstoned (keys removed): ${retired.join(", ")}`);
|
|
3409
3726
|
if (unverifiable.length > 0) parts.push(`could not verify: ${unverifiable.join(", ")}`);
|
|
3727
|
+
const knownIds = new Set([...inventory].map((e) => e.agentId ?? basename(e.directory)));
|
|
3728
|
+
const ghostRecords = (await readTombstoneRecords(join(homeDir, ".haven", "tombstones"))).filter(
|
|
3729
|
+
(rec) => !knownIds.has(rec.agent_id)
|
|
3730
|
+
);
|
|
3731
|
+
if (ghostRecords.length > 0) {
|
|
3732
|
+
parts.push(
|
|
3733
|
+
`retired records (dir removed): ${ghostRecords.map((rec) => `${rec.agent_id} (${rec.reason})`).join(", ")}`
|
|
3734
|
+
);
|
|
3735
|
+
}
|
|
3410
3736
|
const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
|
|
3411
3737
|
checks.push({
|
|
3412
3738
|
id: "superseded_agents",
|
|
@@ -3641,6 +3967,14 @@ async function scanInstalledClients(options = {}) {
|
|
|
3641
3967
|
return SCAN_ORDER.indexOf(a.runtime) - SCAN_ORDER.indexOf(b.runtime);
|
|
3642
3968
|
});
|
|
3643
3969
|
}
|
|
3970
|
+
function installedClientHint(candidates) {
|
|
3971
|
+
const installedClients = candidates.map((candidate) => candidate.runtime);
|
|
3972
|
+
if (candidates.length === 1) {
|
|
3973
|
+
return { installedClients, suggestedRuntime: candidates[0].runtime };
|
|
3974
|
+
}
|
|
3975
|
+
const configured = candidates.filter((candidate) => candidate.evidence === "config-file");
|
|
3976
|
+
return configured.length === 1 ? { installedClients, suggestedRuntime: configured[0].runtime } : { installedClients };
|
|
3977
|
+
}
|
|
3644
3978
|
var MAX_PROMPT_ATTEMPTS = 3;
|
|
3645
3979
|
async function promptForInstalledClient(candidates, io = defaultPromptIo()) {
|
|
3646
3980
|
if (candidates.length === 0) throw noInstalledClientsError();
|
|
@@ -3714,9 +4048,35 @@ function defaultPromptIo() {
|
|
|
3714
4048
|
// src/runtime.ts
|
|
3715
4049
|
init_local_mcp_runtime();
|
|
3716
4050
|
init_runtime_manifest();
|
|
3717
|
-
var CONNECTOR_VERSION = "0.1.
|
|
4051
|
+
var CONNECTOR_VERSION = "0.1.33-alpha.0";
|
|
3718
4052
|
var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
|
|
4053
|
+
var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
|
|
4054
|
+
function failureOutcomeFor(runtimeHint, error) {
|
|
4055
|
+
if (error !== null && typeof error === "object") {
|
|
4056
|
+
const recorded = failureOutcomesByError.get(error);
|
|
4057
|
+
if (recorded) return recorded;
|
|
4058
|
+
}
|
|
4059
|
+
return failedConnectOutcome(runtimeHint, error);
|
|
4060
|
+
}
|
|
3719
4061
|
async function runConnect(options, deps = {}) {
|
|
4062
|
+
const trace = {};
|
|
4063
|
+
try {
|
|
4064
|
+
return await executeConnect(options, deps, trace);
|
|
4065
|
+
} catch (err) {
|
|
4066
|
+
const outcome = failedConnectOutcome(trace.runtime ?? options.runtime, err);
|
|
4067
|
+
if (err !== null && typeof err === "object") failureOutcomesByError.set(err, outcome);
|
|
4068
|
+
if (trace.directory) await recordConnectOutcome(deps, trace.directory, outcome);
|
|
4069
|
+
throw err;
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
async function recordConnectOutcome(deps, directory, outcome) {
|
|
4073
|
+
try {
|
|
4074
|
+
return await (deps.writeOutcomeRecord ?? writeConnectOutcomeRecord)(directory, outcome);
|
|
4075
|
+
} catch {
|
|
4076
|
+
return void 0;
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
async function executeConnect(options, deps, trace) {
|
|
3720
4080
|
assertSupportedNodeVersion(deps.nodeVersion, MCP_RUNTIME_MANIFEST.minimumNodeVersion);
|
|
3721
4081
|
const connectorVersion = options.connectorVersion ?? CONNECTOR_VERSION;
|
|
3722
4082
|
const api = deps.api ?? createConnectApiClient(options.apiBaseUrl);
|
|
@@ -3736,9 +4096,10 @@ async function runConnect(options, deps = {}) {
|
|
|
3736
4096
|
promptForRuntime: runtimeSelectionPrompt(options, deps)
|
|
3737
4097
|
});
|
|
3738
4098
|
if (!selection.runtime) {
|
|
4099
|
+
const hint = await installedClientHintFor(deps);
|
|
3739
4100
|
throw new ConnectError(
|
|
3740
4101
|
"runtime_undetermined",
|
|
3741
|
-
`Could not determine the agent runtime: nothing was detected in this environment and no --runtime was given. If you are an AI agent running this command: re-run it once, unchanged except for adding --runtime <name>, naming the harness you are running in \u2014 one of: ${RUNTIME_FLAG_VALUES} (the aliases cowork, codex and openclaw are accepted too). Do not guess: if your harness is not one of those, use --runtime other, which stores the credentials and prints the manual MCP steps. Nothing was written and the Haven setup token is still unused
|
|
4102
|
+
`Could not determine the agent runtime: nothing was detected in this environment and no --runtime was given. If you are an AI agent running this command: re-run it once, unchanged except for adding --runtime <name>, naming the harness you are running in \u2014 one of: ${RUNTIME_FLAG_VALUES} (the aliases cowork, codex and openclaw are accepted too). Do not guess: if your harness is not one of those, use --runtime other, which stores the credentials and prints the manual MCP steps. ` + installedClientProse(hint) + "Nothing was written and the Haven setup token is still unused.",
|
|
3742
4103
|
"rerun_connect_with_explicit_runtime",
|
|
3743
4104
|
// #2091: the values must ride structurally too. The backend's setup
|
|
3744
4105
|
// prompt permits a retry only with "one of the values that refusal
|
|
@@ -3746,10 +4107,16 @@ async function runConnect(options, deps = {}) {
|
|
|
3746
4107
|
// every automation run in an undetected runtime (Codex in the field:
|
|
3747
4108
|
// npx needs network, Codex runs network commands unsandboxed, and the
|
|
3748
4109
|
// unsandboxed path carries none of the CODEX_* detection vars).
|
|
3749
|
-
|
|
4110
|
+
//
|
|
4111
|
+
// #2174: the list alone still leaves the retry a nine-value guess. The
|
|
4112
|
+
// scan below narrows it to what is actually on this machine — as a
|
|
4113
|
+
// HINT. It does not select, and this stays a refusal: see
|
|
4114
|
+
// `installedClientHint`.
|
|
4115
|
+
{ allowedRuntimes: RUNTIME_FLAG_VALUE_LIST, ...hint }
|
|
3750
4116
|
);
|
|
3751
4117
|
}
|
|
3752
4118
|
const runtime = selection.runtime;
|
|
4119
|
+
trace.runtime = runtime;
|
|
3753
4120
|
const installCapabilities = runtimeInstallCapabilities(runtime);
|
|
3754
4121
|
if (options.localMcp) {
|
|
3755
4122
|
if (!supportsLocalMcp(runtime)) {
|
|
@@ -3845,6 +4212,7 @@ async function runConnect(options, deps = {}) {
|
|
|
3845
4212
|
x402BindingSigner: setup.x402_binding_signer ?? void 0,
|
|
3846
4213
|
warn: log
|
|
3847
4214
|
});
|
|
4215
|
+
trace.directory = credentialPaths.directory;
|
|
3848
4216
|
log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
|
|
3849
4217
|
log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
|
|
3850
4218
|
log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
|
|
@@ -3892,6 +4260,9 @@ async function runConnect(options, deps = {}) {
|
|
|
3892
4260
|
errorCode: early.errorCode ?? null,
|
|
3893
4261
|
environmentLabel: options.environmentLabel ?? "Local workspace"
|
|
3894
4262
|
});
|
|
4263
|
+
if (!early.errorCode || early.errorCode === "manual_runtime_setup_required") {
|
|
4264
|
+
log("\u2192 Action needed: approve this agent's budget in the Haven dashboard \u2014 the approval button is live now. Setup continues here in the meantime.");
|
|
4265
|
+
}
|
|
3895
4266
|
} catch {
|
|
3896
4267
|
}
|
|
3897
4268
|
}
|
|
@@ -3902,12 +4273,13 @@ async function runConnect(options, deps = {}) {
|
|
|
3902
4273
|
} else {
|
|
3903
4274
|
log("Haven setup on this machine is complete.");
|
|
3904
4275
|
}
|
|
4276
|
+
let supersededAgentIds = [];
|
|
3905
4277
|
try {
|
|
3906
|
-
|
|
3907
|
-
if (
|
|
4278
|
+
supersededAgentIds = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
|
|
4279
|
+
if (supersededAgentIds.length > 0) {
|
|
3908
4280
|
log("");
|
|
3909
4281
|
log(
|
|
3910
|
-
`Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${
|
|
4282
|
+
`Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${supersededAgentIds.join(", ")} \u2014 still exist with their own keys, and any host that was already running keeps acting as them.`
|
|
3911
4283
|
);
|
|
3912
4284
|
log(
|
|
3913
4285
|
`If you meant to replace them: revoke them on the Haven agent page, then restart EVERY long-lived host (gateways, TUI workers, editors) \u2014 each holds the MCP wiring snapshot from its own start time, so after repeated setups each can be stuck on a DIFFERENT old agent. Then remove their directories under ~/.haven/agents (or ${RERUN_HINT} --tombstone <dir> to leave a diagnostic in their place). Run ${RERUN_HINT} --doctor to check whether their keys are still live.`
|
|
@@ -3942,17 +4314,23 @@ async function runConnect(options, deps = {}) {
|
|
|
3942
4314
|
approval = await waitForBudgetApproval(api, registration.setup_id, localApiKey, log, options.approvalWait);
|
|
3943
4315
|
}
|
|
3944
4316
|
printNextSteps(runtimeInstall, log, approval);
|
|
4317
|
+
const outcome = completionOutcome({
|
|
4318
|
+
runtimeInstall,
|
|
4319
|
+
delegateAddress: registration.delegate_address,
|
|
4320
|
+
hostedMcpUrl: registration.hosted_mcp_url,
|
|
4321
|
+
supersededAgentIds,
|
|
4322
|
+
setupChallengeExpiresAt: setup.challenge.expires_at,
|
|
4323
|
+
approvalRequired: registration.agent_status === "pending_approval"
|
|
4324
|
+
});
|
|
4325
|
+
if (await recordConnectOutcome(deps, credentialPaths.directory, outcome)) {
|
|
4326
|
+
log(`Saved this run's outcome to ${CONNECT_OUTCOME_FILENAME} in the agent's credential directory.`);
|
|
4327
|
+
}
|
|
3945
4328
|
return {
|
|
3946
4329
|
setupId: registration.setup_id,
|
|
3947
4330
|
agentId: registration.agent_id,
|
|
3948
4331
|
delegateAddress: registration.delegate_address,
|
|
3949
4332
|
credentialPaths,
|
|
3950
|
-
outcome
|
|
3951
|
-
runtimeInstall,
|
|
3952
|
-
delegateAddress: registration.delegate_address,
|
|
3953
|
-
setupChallengeExpiresAt: setup.challenge.expires_at,
|
|
3954
|
-
approvalRequired: registration.agent_status === "pending_approval"
|
|
3955
|
-
})
|
|
4333
|
+
outcome
|
|
3956
4334
|
};
|
|
3957
4335
|
}
|
|
3958
4336
|
function completionOutcome(input) {
|
|
@@ -3984,11 +4362,35 @@ function completionOutcome(input) {
|
|
|
3984
4362
|
// malformed value arrives, do not echo it into an automation-facing
|
|
3985
4363
|
// record; the human log has already been redacted separately.
|
|
3986
4364
|
delegate_address: /^0x[0-9a-fA-F]{40}$/.test(input.delegateAddress) ? shortAddress(input.delegateAddress) : "[delegate-address-redacted]",
|
|
4365
|
+
// The endpoint Connect wrote into the runtime's MCP config, verbatim — the
|
|
4366
|
+
// same string that already sits in the user's own config file. Passed
|
|
4367
|
+
// through the secret filter anyway: this is the automation contract, the
|
|
4368
|
+
// value is server-supplied, and belts are cheap (the #1589 stance).
|
|
4369
|
+
...input.hostedMcpUrl ? { hosted_mcp_url: redactSecrets(input.hostedMcpUrl) } : {},
|
|
4370
|
+
// Always emitted on a completed run, empty list included: "no superseded
|
|
4371
|
+
// agents" is a fact a caller needs, and an omitted key would be
|
|
4372
|
+
// indistinguishable from an older connector that never reported it.
|
|
4373
|
+
superseded_agent_ids: input.supersededAgentIds ?? [],
|
|
3987
4374
|
...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
|
|
3988
4375
|
...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
|
|
3989
4376
|
};
|
|
3990
4377
|
return outcome;
|
|
3991
4378
|
}
|
|
4379
|
+
async function installedClientHintFor(deps) {
|
|
4380
|
+
try {
|
|
4381
|
+
const candidates = await (deps.scanInstalledClients ?? scanInstalledClients)({ env: deps.env });
|
|
4382
|
+
const hint = installedClientHint(candidates);
|
|
4383
|
+
return hint.installedClients.length > 0 ? hint : {};
|
|
4384
|
+
} catch {
|
|
4385
|
+
return {};
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
function installedClientProse(hint) {
|
|
4389
|
+
const found = hint.installedClients ?? [];
|
|
4390
|
+
if (found.length === 0) return "";
|
|
4391
|
+
const suggestion = hint.suggestedRuntime ? ` The likeliest is ${hint.suggestedRuntime}, but Haven will not choose for you \u2014 pass it yourself if it is right.` : " Haven will not choose between them for you.";
|
|
4392
|
+
return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
|
|
4393
|
+
}
|
|
3992
4394
|
function runtimeSelectionPrompt(options, deps) {
|
|
3993
4395
|
if (options.interactive !== true) return void 0;
|
|
3994
4396
|
if (!(deps.isTty ?? Boolean(process.stdin.isTTY))) return void 0;
|
|
@@ -4022,7 +4424,9 @@ function failedConnectOutcome(runtimeHint, error) {
|
|
|
4022
4424
|
// pinned by test). Redaction stays on as belt-and-braces; plain-Error
|
|
4023
4425
|
// runs still get their redacted message on stderr via the CLI mirror.
|
|
4024
4426
|
...error instanceof ConnectError && message ? { message: redactForAutomation(message) } : {},
|
|
4025
|
-
...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {}
|
|
4427
|
+
...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
|
|
4428
|
+
...error instanceof ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
|
|
4429
|
+
...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {}
|
|
4026
4430
|
}
|
|
4027
4431
|
};
|
|
4028
4432
|
}
|
|
@@ -4095,6 +4499,13 @@ function describeApprovedBudget(budget) {
|
|
|
4095
4499
|
const amount = token ? `${formatAtomicAmount(BigInt(budget.amount), token.decimals)} ${budget.token_symbol}` : `${budget.amount} ${budget.token_symbol} (atomic units)`;
|
|
4096
4500
|
return `${amount} ${describeResetPeriod(budget.reset_period_min)}`;
|
|
4097
4501
|
}
|
|
4502
|
+
function describeWaitBound(timeoutMs) {
|
|
4503
|
+
if (timeoutMs >= 12e4) {
|
|
4504
|
+
const minutes = Math.round(timeoutMs / 6e4);
|
|
4505
|
+
return `${minutes} minutes`;
|
|
4506
|
+
}
|
|
4507
|
+
return `${Math.round(timeoutMs / 1e3)}s`;
|
|
4508
|
+
}
|
|
4098
4509
|
async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
4099
4510
|
const intervalMs = options.intervalMs ?? 5e3;
|
|
4100
4511
|
const timeoutMs = options.timeoutMs ?? 18e4;
|
|
@@ -4105,7 +4516,7 @@ async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
|
4105
4516
|
const announceWaiting = () => {
|
|
4106
4517
|
if (waitingAnnounced) return;
|
|
4107
4518
|
waitingAnnounced = true;
|
|
4108
|
-
log("
|
|
4519
|
+
log("Now waiting for you to approve the budget in the Haven dashboard \u2014 approval is what unlocks the agent's Haven tools\u2026");
|
|
4109
4520
|
};
|
|
4110
4521
|
for (let i = 0; i < maxPolls; i++) {
|
|
4111
4522
|
if (i > 0) await sleep(intervalMs);
|
|
@@ -4132,7 +4543,10 @@ async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
|
4132
4543
|
}
|
|
4133
4544
|
announceWaiting();
|
|
4134
4545
|
if (i > 0 && i % remindEvery === 0) {
|
|
4135
|
-
|
|
4546
|
+
const elapsedSeconds = Math.round(i * intervalMs / 1e3);
|
|
4547
|
+
log(
|
|
4548
|
+
`Still waiting for budget approval in Haven \u2014 ${elapsedSeconds}s elapsed, checking every ${Math.round(intervalMs / 1e3)}s. I'll stop waiting after ${describeWaitBound(timeoutMs)}; you can approve later and the tools unlock then.`
|
|
4549
|
+
);
|
|
4136
4550
|
}
|
|
4137
4551
|
}
|
|
4138
4552
|
log(
|
|
@@ -4217,6 +4631,7 @@ function printNextSteps(result, log, approval) {
|
|
|
4217
4631
|
|
|
4218
4632
|
// src/args.ts
|
|
4219
4633
|
init_server_names();
|
|
4634
|
+
init_rekey_messages();
|
|
4220
4635
|
function parseArgs(argv, env = process.env) {
|
|
4221
4636
|
const options = {
|
|
4222
4637
|
apiBaseUrl: env.HAVEN_API_URL ?? "http://localhost:3001",
|
|
@@ -4231,6 +4646,8 @@ function parseArgs(argv, env = process.env) {
|
|
|
4231
4646
|
let tombstoneDir;
|
|
4232
4647
|
let tombstoneReason;
|
|
4233
4648
|
let tombstoneReplacedBy;
|
|
4649
|
+
let unwire;
|
|
4650
|
+
let unwireDir;
|
|
4234
4651
|
for (let i = 0; i < argv.length; i += 1) {
|
|
4235
4652
|
const arg = argv[i];
|
|
4236
4653
|
if (arg === "--help" || arg === "-h") {
|
|
@@ -4249,6 +4666,13 @@ function parseArgs(argv, env = process.env) {
|
|
|
4249
4666
|
newApiKey = requireValue(argv, ++i, arg);
|
|
4250
4667
|
} else if (arg === "--tombstone") {
|
|
4251
4668
|
tombstoneDir = requireValue(argv, ++i, arg);
|
|
4669
|
+
} else if (arg === "--unwire") {
|
|
4670
|
+
unwire = unwire ?? {};
|
|
4671
|
+
const next = argv[i + 1];
|
|
4672
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
4673
|
+
unwireDir = next;
|
|
4674
|
+
i += 1;
|
|
4675
|
+
}
|
|
4252
4676
|
} else if (arg === "--reason") {
|
|
4253
4677
|
tombstoneReason = requireValue(argv, ++i, arg);
|
|
4254
4678
|
} else if (arg === "--replaced-by") {
|
|
@@ -4298,18 +4722,37 @@ function parseArgs(argv, env = process.env) {
|
|
|
4298
4722
|
);
|
|
4299
4723
|
}
|
|
4300
4724
|
if (rekey.phase === "finish" && !newApiKey) {
|
|
4301
|
-
throw new Error(
|
|
4725
|
+
throw new Error(REKEY_FINISH_NEEDS_API_KEY);
|
|
4302
4726
|
}
|
|
4303
4727
|
return { options, help, json, doctor, repair, tombstone, rekey };
|
|
4304
4728
|
}
|
|
4305
4729
|
if (newApiKey !== void 0) {
|
|
4306
4730
|
throw new Error("--api-key requires --rekey-finish.");
|
|
4307
4731
|
}
|
|
4308
|
-
if (!tombstoneDir && (tombstoneReason !== void 0 || tombstoneReplacedBy !== void 0)) {
|
|
4309
|
-
throw new Error("--reason and --replaced-by require --tombstone <dir
|
|
4732
|
+
if (!tombstoneDir && !unwire && (tombstoneReason !== void 0 || tombstoneReplacedBy !== void 0)) {
|
|
4733
|
+
throw new Error("--reason and --replaced-by require --tombstone <dir> or --unwire.");
|
|
4734
|
+
}
|
|
4735
|
+
if (unwire && (tombstoneReason !== void 0 || tombstoneReplacedBy !== void 0)) {
|
|
4736
|
+
unwire = {
|
|
4737
|
+
...tombstoneReason !== void 0 ? { reason: tombstoneReason } : {},
|
|
4738
|
+
...tombstoneReplacedBy !== void 0 ? { replacedBy: tombstoneReplacedBy } : {},
|
|
4739
|
+
...unwire
|
|
4740
|
+
};
|
|
4310
4741
|
}
|
|
4311
4742
|
if (tombstone) {
|
|
4312
|
-
|
|
4743
|
+
if (unwire) {
|
|
4744
|
+
throw new Error("--unwire and --tombstone are separate operations; run one per invocation.");
|
|
4745
|
+
}
|
|
4746
|
+
return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir };
|
|
4747
|
+
}
|
|
4748
|
+
if (unwire) {
|
|
4749
|
+
if (options.setupToken) {
|
|
4750
|
+
throw new Error("--unwire removes existing wiring; it does not take --setup. Drop --setup.");
|
|
4751
|
+
}
|
|
4752
|
+
if (!unwireDir && !options.serverName && !options.credentialsDir) {
|
|
4753
|
+
throw new Error("--unwire needs a target: --unwire <dir>, --unwire --name <slug>, or --unwire --credentials-dir <path>.");
|
|
4754
|
+
}
|
|
4755
|
+
return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir };
|
|
4313
4756
|
}
|
|
4314
4757
|
if (doctor || repair) {
|
|
4315
4758
|
if (!options.runtime) {
|
|
@@ -4372,8 +4815,20 @@ function helpText() {
|
|
|
4372
4815
|
" --tombstone <dir> Retire an agent credential directory in place (no token): replaces its signer",
|
|
4373
4816
|
" wrapper with a diagnostic that names the retirement in MCP stderr logs, and",
|
|
4374
4817
|
" writes TOMBSTONE.json. Touches NO key material and revokes nothing.",
|
|
4375
|
-
" --
|
|
4376
|
-
"
|
|
4818
|
+
" --unwire [<dir>] Remove one agent\u2019s wiring from every runtime config it appears in (no token):",
|
|
4819
|
+
" tombstone-first, then drops the hosted + signer MCP pair from Hermes YAML, Codex",
|
|
4820
|
+
" TOML and the JSON configs (Cursor, VS Code, Insiders, Claude Desktop), plus the",
|
|
4821
|
+
" Hermes dotenv API-key line (MCP_HAVEN_API_KEY / MCP_HAVEN_<SLUG>_API_KEY).",
|
|
4822
|
+
" Target the directory directly, or add --name <slug> or --credentials-dir.",
|
|
4823
|
+
" An UNNAMED pair is only removed when this directory\u2019s wrapper is the one the",
|
|
4824
|
+
" config launches (or its key is the one the Hermes env holds); otherwise the",
|
|
4825
|
+
" command refuses rather than unwire a different agent \u2014 it never touches a",
|
|
4826
|
+
" pair another agent owns. Tears down the target directory: its signer key and",
|
|
4827
|
+
" API key are removed locally (record kept via the #2155 tombstone mirror) and",
|
|
4828
|
+
" nothing is ever revoked on the backend \u2014 that stays an owner action on the",
|
|
4829
|
+
" Haven agent page.",
|
|
4830
|
+
" --reason <text> Reason recorded in the tombstone (with --tombstone or --unwire).",
|
|
4831
|
+
" --replaced-by <agent-id> Successor agent recorded in the tombstone (with --tombstone or --unwire).",
|
|
4377
4832
|
" --help Show this help.",
|
|
4378
4833
|
"",
|
|
4379
4834
|
"The connector never prints the private key and never sends it to Haven. JSON output never includes credential contents or full credential paths."
|
|
@@ -4389,6 +4844,28 @@ function requireValue(argv, index, option) {
|
|
|
4389
4844
|
|
|
4390
4845
|
// src/cli.ts
|
|
4391
4846
|
init_redact();
|
|
4847
|
+
init_connect_error();
|
|
4848
|
+
function failSubcommand(io, json, err, envelope, fallback) {
|
|
4849
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4850
|
+
io.stderr(`${redactSecrets(message)}
|
|
4851
|
+
`);
|
|
4852
|
+
if (json) {
|
|
4853
|
+
io.stdout(
|
|
4854
|
+
`${redactSecrets(
|
|
4855
|
+
JSON.stringify({
|
|
4856
|
+
...envelope,
|
|
4857
|
+
error: {
|
|
4858
|
+
code: isConnectError(err) ? err.code : fallback.code,
|
|
4859
|
+
next_action: isConnectError(err) ? err.nextAction : fallback.nextAction,
|
|
4860
|
+
...isConnectError(err) && message ? { message } : {}
|
|
4861
|
+
}
|
|
4862
|
+
})
|
|
4863
|
+
)}
|
|
4864
|
+
`
|
|
4865
|
+
);
|
|
4866
|
+
}
|
|
4867
|
+
return 1;
|
|
4868
|
+
}
|
|
4392
4869
|
async function runCli(argv, io = {
|
|
4393
4870
|
stdout: (message) => process.stdout.write(message),
|
|
4394
4871
|
stderr: (message) => process.stderr.write(message)
|
|
@@ -4416,13 +4893,13 @@ async function runCli(argv, io = {
|
|
|
4416
4893
|
}
|
|
4417
4894
|
if (parsed.tombstone) {
|
|
4418
4895
|
const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
|
|
4419
|
-
const { readFile:
|
|
4420
|
-
const { join:
|
|
4896
|
+
const { readFile: readFile13 } = await import('fs/promises');
|
|
4897
|
+
const { join: join11 } = await import('path');
|
|
4421
4898
|
try {
|
|
4422
4899
|
let agentId = "unknown";
|
|
4423
4900
|
try {
|
|
4424
4901
|
const identity = JSON.parse(
|
|
4425
|
-
await
|
|
4902
|
+
await readFile13(join11(parsed.tombstone.directory, "identity.json"), "utf8")
|
|
4426
4903
|
);
|
|
4427
4904
|
agentId = identity.agent_id ?? "unknown";
|
|
4428
4905
|
} catch {
|
|
@@ -4443,14 +4920,80 @@ async function runCli(argv, io = {
|
|
|
4443
4920
|
"Key files were NOT touched and nothing was revoked \u2014 revoke the agent on the Haven agent page if you have not already.\n"
|
|
4444
4921
|
);
|
|
4445
4922
|
io.stdout(
|
|
4446
|
-
|
|
4923
|
+
`A surviving tombstone record was mirrored to ${redactSecrets(info.recordPath)}
|
|
4924
|
+
`
|
|
4925
|
+
);
|
|
4926
|
+
io.stdout(
|
|
4927
|
+
"Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot from its own start time, and the tombstone only speaks when a stale host next probes the old path. The mirrored record keeps this retirement observable even after the agent directory itself is deleted.\n"
|
|
4447
4928
|
);
|
|
4448
4929
|
}
|
|
4449
4930
|
return 0;
|
|
4450
4931
|
} catch (err) {
|
|
4451
|
-
io.
|
|
4452
|
-
|
|
4453
|
-
|
|
4932
|
+
return failSubcommand(io, parsed.json, err, { tombstoned: false }, {
|
|
4933
|
+
code: "tombstone_failed",
|
|
4934
|
+
nextAction: "review_the_error_and_retry_with_a_valid_agent_directory"
|
|
4935
|
+
});
|
|
4936
|
+
}
|
|
4937
|
+
}
|
|
4938
|
+
if (parsed.unwire) {
|
|
4939
|
+
const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
|
|
4940
|
+
const { homedir: homedir10 } = await import('os');
|
|
4941
|
+
const { join: join11 } = await import('path');
|
|
4942
|
+
const homeDir = homedir10();
|
|
4943
|
+
const root = parsed.options.credentialsDir ?? join11(homeDir, ".haven", "agents");
|
|
4944
|
+
const directory = parsed.unwireDir ?? (parsed.options.serverName ? join11(root, parsed.options.serverName) : root);
|
|
4945
|
+
try {
|
|
4946
|
+
const result = await unwireAgent2({
|
|
4947
|
+
directory,
|
|
4948
|
+
slug: parsed.options.serverName,
|
|
4949
|
+
reason: parsed.unwire.reason,
|
|
4950
|
+
replacedBy: parsed.unwire.replacedBy,
|
|
4951
|
+
homeDir
|
|
4952
|
+
});
|
|
4953
|
+
const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
|
|
4954
|
+
if (parsed.json) {
|
|
4955
|
+
io.stdout(
|
|
4956
|
+
`${redactSecrets(
|
|
4957
|
+
JSON.stringify({
|
|
4958
|
+
unwired: true,
|
|
4959
|
+
agent_id: result.agentId,
|
|
4960
|
+
slug: result.slug ?? null,
|
|
4961
|
+
directory: result.directory,
|
|
4962
|
+
tombstoned: result.tombstoned,
|
|
4963
|
+
runtimes: result.runtimes.map((r) => ({
|
|
4964
|
+
runtime: r.runtime,
|
|
4965
|
+
label: r.label,
|
|
4966
|
+
status: r.status,
|
|
4967
|
+
...r.detail ? { detail: r.detail } : {}
|
|
4968
|
+
}))
|
|
4969
|
+
})
|
|
4970
|
+
)}
|
|
4971
|
+
`
|
|
4972
|
+
);
|
|
4973
|
+
} else {
|
|
4974
|
+
io.stdout(redactSecrets(`Unwired agent ${result.agentId} at ${result.directory}.
|
|
4975
|
+
`));
|
|
4976
|
+
io.stdout(
|
|
4977
|
+
result.tombstoned ? " \xB7 Tombstoned first: any long-lived host still resolving the old wrapper gets the HAVEN-TOMBSTONE diagnosis.\n" : " \xB7 Directory was already tombstoned.\n"
|
|
4978
|
+
);
|
|
4979
|
+
for (const r of result.runtimes) {
|
|
4980
|
+
const mark = r.status === "removed" ? "\u2713" : r.status === "clean" ? "\u2013" : "\u2717";
|
|
4981
|
+
io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
|
|
4982
|
+
`));
|
|
4983
|
+
}
|
|
4984
|
+
io.stdout(
|
|
4985
|
+
failures.length > 0 ? " Some entries were NOT removed (\u2717 above). Re-run `--unwire` after resolving each refusal \u2014\n it is idempotent.\n" : " Verify: `--doctor --runtime <runtime>` per host should report this agent as `retired` with a\n clean runtime-config check.\n"
|
|
4986
|
+
);
|
|
4987
|
+
io.stdout(
|
|
4988
|
+
"Restart EVERY long-lived MCP host (gateway, TUI workers, editors): each holds the wiring snapshot\nfrom its own start time. This directory\u2019s local key material was removed and the tombstone\nrecord + #2155 mirror survive \u2014 but nothing was REVOKED on the backend. If you have not\nalready, revoke the agent on the Haven agent page to stop it spending entirely.\n"
|
|
4989
|
+
);
|
|
4990
|
+
}
|
|
4991
|
+
return failures.length > 0 ? 1 : 0;
|
|
4992
|
+
} catch (err) {
|
|
4993
|
+
return failSubcommand(io, parsed.json, err, { unwired: false }, {
|
|
4994
|
+
code: "unwire_failed",
|
|
4995
|
+
nextAction: "review_the_error_and_rerun_unwire_which_is_idempotent"
|
|
4996
|
+
});
|
|
4454
4997
|
}
|
|
4455
4998
|
}
|
|
4456
4999
|
if (parsed.rekey) {
|
|
@@ -4506,9 +5049,10 @@ async function runCli(argv, io = {
|
|
|
4506
5049
|
}
|
|
4507
5050
|
return 0;
|
|
4508
5051
|
} catch (err) {
|
|
4509
|
-
io.
|
|
4510
|
-
|
|
4511
|
-
|
|
5052
|
+
return failSubcommand(io, parsed.json, err, { rekey: "failed" }, {
|
|
5053
|
+
code: "rekey_failed",
|
|
5054
|
+
nextAction: "review_the_error_and_rerun_the_rekey_phase"
|
|
5055
|
+
});
|
|
4512
5056
|
}
|
|
4513
5057
|
}
|
|
4514
5058
|
if (parsed.doctor || parsed.repair) {
|
|
@@ -4554,9 +5098,10 @@ async function runCli(argv, io = {
|
|
|
4554
5098
|
}
|
|
4555
5099
|
return report.ok ? 0 : 1;
|
|
4556
5100
|
} catch (err) {
|
|
4557
|
-
io.
|
|
4558
|
-
|
|
4559
|
-
|
|
5101
|
+
return failSubcommand(io, parsed.json, err, { doctor: "failed" }, {
|
|
5102
|
+
code: "doctor_failed",
|
|
5103
|
+
nextAction: "review_the_error_and_rerun_doctor"
|
|
5104
|
+
});
|
|
4560
5105
|
}
|
|
4561
5106
|
}
|
|
4562
5107
|
try {
|
|
@@ -4583,7 +5128,7 @@ async function runCli(argv, io = {
|
|
|
4583
5128
|
if (parsed.json) {
|
|
4584
5129
|
io.stderr(`${redactForAutomation(err instanceof Error ? err.message : String(err))}
|
|
4585
5130
|
`);
|
|
4586
|
-
io.stdout(`${JSON.stringify(
|
|
5131
|
+
io.stdout(`${JSON.stringify(failureOutcomeFor(parsed.options.runtime, err))}
|
|
4587
5132
|
`);
|
|
4588
5133
|
} else {
|
|
4589
5134
|
io.stderr(`${redactSecrets(err instanceof Error ? err.message : String(err))}
|