@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.cjs
CHANGED
|
@@ -504,11 +504,19 @@ async function restrictPermissions(path, mode, warn) {
|
|
|
504
504
|
);
|
|
505
505
|
}
|
|
506
506
|
}
|
|
507
|
-
|
|
507
|
+
async function writeConnectOutcomeRecord(directory, outcome, warn) {
|
|
508
|
+
const path$1 = path.join(directory, CONNECT_OUTCOME_FILENAME);
|
|
509
|
+
await promises.writeFile(path$1, `${JSON.stringify(outcome, null, 2)}
|
|
510
|
+
`, { mode: 384 });
|
|
511
|
+
await restrictPermissions(path$1, 384, warn);
|
|
512
|
+
return path$1;
|
|
513
|
+
}
|
|
514
|
+
var REKEY_PENDING_FILENAME, REKEY_PENDING_TTL_MS, CONNECT_OUTCOME_FILENAME;
|
|
508
515
|
var init_storage = __esm({
|
|
509
516
|
"src/storage.ts"() {
|
|
510
517
|
REKEY_PENDING_FILENAME = "rekey-pending.json";
|
|
511
518
|
REKEY_PENDING_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
519
|
+
CONNECT_OUTCOME_FILENAME = "last-connect-outcome.json";
|
|
512
520
|
}
|
|
513
521
|
});
|
|
514
522
|
function mcpPackageSpec() {
|
|
@@ -527,9 +535,9 @@ var init_runtime_manifest = __esm({
|
|
|
527
535
|
mcpPackage: "@haven_ai/mcp",
|
|
528
536
|
mcpVersion: mcp.MCP_VERSION,
|
|
529
537
|
sdkPackage: "@haven_ai/sdk",
|
|
530
|
-
sdkVersion: "0.1.
|
|
538
|
+
sdkVersion: "0.1.33-alpha.0",
|
|
531
539
|
signerPackage: "@haven_ai/signer",
|
|
532
|
-
signerVersion: "0.1.
|
|
540
|
+
signerVersion: "0.1.33-alpha.0",
|
|
533
541
|
// Sourced from the SDK, never a literal (#1161). This field read '20.0.0'
|
|
534
542
|
// while every package's `engines` said `>=24` and the docs said `>=24.0.0`,
|
|
535
543
|
// so the guard that was supposed to enforce the floor waved Node v23 through
|
|
@@ -671,6 +679,97 @@ function isHermesEnvAssignment(line, envKey) {
|
|
|
671
679
|
function isAmbiguousHermesEnvLine(line, envKey) {
|
|
672
680
|
return new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}\\b`).test(line);
|
|
673
681
|
}
|
|
682
|
+
function removeHermesYaml(existingYaml, names, configPath) {
|
|
683
|
+
if (!existingYaml?.trim()) return existingYaml ?? "";
|
|
684
|
+
const doc = yaml.parseDocument(existingYaml, { keepSourceTokens: true });
|
|
685
|
+
if (doc.errors.length > 0 || !yaml.isMap(doc.contents)) {
|
|
686
|
+
throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "it is not a YAML object");
|
|
687
|
+
}
|
|
688
|
+
const mcpPair = doc.contents.items.find((item) => item.key?.toString() === "mcp_servers");
|
|
689
|
+
if (!mcpPair || !yaml.isMap(mcpPair.value)) return existingYaml;
|
|
690
|
+
const toRemove = mcpPair.value.items.filter((item) => {
|
|
691
|
+
const name = item.key?.toString();
|
|
692
|
+
return name === names.hosted || name === names.signer;
|
|
693
|
+
});
|
|
694
|
+
if (toRemove.length === 0) return existingYaml;
|
|
695
|
+
if (toRemove.length !== mcpPair.value.items.length && toRemove.some((item) => !item.key?.range || !item.value?.range)) {
|
|
696
|
+
throw new UnreadableRuntimeConfigError(configPath ?? "the Hermes config", "an MCP server entry cannot be safely removed");
|
|
697
|
+
}
|
|
698
|
+
let out = existingYaml;
|
|
699
|
+
const ranges = toRemove.map((item) => {
|
|
700
|
+
const keyStart = item.key.range[0];
|
|
701
|
+
const valueEnd = item.value.range[1];
|
|
702
|
+
const lineStart = existingYaml.lastIndexOf("\n", keyStart - 1) + 1;
|
|
703
|
+
let end = valueEnd;
|
|
704
|
+
if (existingYaml.slice(end).startsWith("\r\n")) end += 2;
|
|
705
|
+
else if (existingYaml[end] === "\n") end += 1;
|
|
706
|
+
return [lineStart, end];
|
|
707
|
+
});
|
|
708
|
+
ranges.sort((a, b) => b[0] - a[0]);
|
|
709
|
+
for (const [start, end] of ranges) out = out.slice(0, start) + out.slice(end);
|
|
710
|
+
if (toRemove.length === mcpPair.value.items.length) {
|
|
711
|
+
const reparsed = yaml.parseDocument(out, { keepSourceTokens: true });
|
|
712
|
+
if (!reparsed.errors.length && yaml.isMap(reparsed.contents)) {
|
|
713
|
+
const pair = reparsed.contents.items.find((item) => item.key?.toString() === "mcp_servers");
|
|
714
|
+
if (pair && pair.key?.range && pair.value?.range) {
|
|
715
|
+
const keyStart = pair.key.range[0];
|
|
716
|
+
const valueEnd = pair.value.range[1];
|
|
717
|
+
const lineStart = out.lastIndexOf("\n", keyStart - 1) + 1;
|
|
718
|
+
let end = valueEnd;
|
|
719
|
+
if (out.slice(end).startsWith("\r\n")) end += 2;
|
|
720
|
+
else if (out[end] === "\n") end += 1;
|
|
721
|
+
out = out.slice(0, lineStart) + out.slice(end);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}
|
|
727
|
+
function removeHermesEnv(existingEnv, envKey) {
|
|
728
|
+
if (!existingEnv) return "";
|
|
729
|
+
const lineEnding = existingEnv.includes("\r\n") ? "\r\n" : "\n";
|
|
730
|
+
const hasTrailingNewline = /\r?\n$/.test(existingEnv);
|
|
731
|
+
const lines = existingEnv.split(/\r?\n/);
|
|
732
|
+
if (hasTrailingNewline) lines.pop();
|
|
733
|
+
let removed = 0;
|
|
734
|
+
const kept = lines.filter((line) => {
|
|
735
|
+
if (isHermesEnvAssignment(line, envKey)) {
|
|
736
|
+
removed += 1;
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
if (isAmbiguousHermesEnvLine(line, envKey)) {
|
|
740
|
+
throw new Error("Hermes environment contains an ambiguous managed key");
|
|
741
|
+
}
|
|
742
|
+
return true;
|
|
743
|
+
});
|
|
744
|
+
if (removed === 0) return existingEnv;
|
|
745
|
+
const joined = kept.join(lineEnding);
|
|
746
|
+
return hasTrailingNewline ? `${joined}${lineEnding}` : joined;
|
|
747
|
+
}
|
|
748
|
+
function removeJsonMcpConfig(existingJson, serverRoot, names, configPath) {
|
|
749
|
+
if (!existingJson?.trim()) return existingJson ?? "";
|
|
750
|
+
const config = parseJsonObject(existingJson, configPath);
|
|
751
|
+
const root = config[serverRoot];
|
|
752
|
+
if (!root || typeof root !== "object" || Array.isArray(root)) return existingJson;
|
|
753
|
+
const servers = root;
|
|
754
|
+
let removed = false;
|
|
755
|
+
for (const name of [names.hosted, names.signer]) {
|
|
756
|
+
if (name in servers) {
|
|
757
|
+
delete servers[name];
|
|
758
|
+
removed = true;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (!removed) return existingJson;
|
|
762
|
+
if (Object.keys(servers).length === 0) delete config[serverRoot];
|
|
763
|
+
return `${JSON.stringify(config, null, 2)}
|
|
764
|
+
`;
|
|
765
|
+
}
|
|
766
|
+
function removeCodexToml(existingToml, names) {
|
|
767
|
+
let next = removeTomlTableTree(
|
|
768
|
+
removeTomlTableTree(existingToml, `mcp_servers.${names.codexHosted}`),
|
|
769
|
+
`mcp_servers.${names.codexSigner}`
|
|
770
|
+
);
|
|
771
|
+
return next;
|
|
772
|
+
}
|
|
674
773
|
function appendHermesMcpServers(source, servers) {
|
|
675
774
|
const documentEnd = /(?:^|\n)[ \t]*\.\.\.[ \t]*(?:#[^\n]*)?\r?\n?$/.exec(source);
|
|
676
775
|
if (documentEnd) {
|
|
@@ -1943,6 +2042,9 @@ var init_skill_install = __esm({
|
|
|
1943
2042
|
});
|
|
1944
2043
|
|
|
1945
2044
|
// src/connect-error.ts
|
|
2045
|
+
function isConnectError(err) {
|
|
2046
|
+
return err instanceof ConnectError;
|
|
2047
|
+
}
|
|
1946
2048
|
var ConnectError;
|
|
1947
2049
|
var init_connect_error = __esm({
|
|
1948
2050
|
"src/connect-error.ts"() {
|
|
@@ -2657,14 +2759,49 @@ var init_runtime_install = __esm({
|
|
|
2657
2759
|
}
|
|
2658
2760
|
});
|
|
2659
2761
|
|
|
2762
|
+
// src/rekey-messages.ts
|
|
2763
|
+
var REKEY_FINISH_NEEDS_API_KEY;
|
|
2764
|
+
var init_rekey_messages = __esm({
|
|
2765
|
+
"src/rekey-messages.ts"() {
|
|
2766
|
+
REKEY_FINISH_NEEDS_API_KEY = "--rekey-finish needs --api-key <key> \u2014 the one the Haven agent page showed once.";
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2660
2770
|
// src/tombstone.ts
|
|
2661
2771
|
var tombstone_exports = {};
|
|
2662
2772
|
__export(tombstone_exports, {
|
|
2663
2773
|
TOMBSTONE_FILENAME: () => TOMBSTONE_FILENAME,
|
|
2664
2774
|
TOMBSTONE_MARKER: () => TOMBSTONE_MARKER,
|
|
2775
|
+
defaultTombstonesDir: () => defaultTombstonesDir,
|
|
2665
2776
|
readAgentTombstone: () => readAgentTombstone,
|
|
2777
|
+
readTombstoneRecords: () => readTombstoneRecords,
|
|
2666
2778
|
writeAgentTombstone: () => writeAgentTombstone
|
|
2667
2779
|
});
|
|
2780
|
+
function defaultTombstonesDir(baseDir) {
|
|
2781
|
+
return path.join(baseDir ?? path.join(os.homedir(), ".haven"), "tombstones");
|
|
2782
|
+
}
|
|
2783
|
+
async function readTombstoneRecords(tombstonesDir) {
|
|
2784
|
+
const root = tombstonesDir ?? defaultTombstonesDir();
|
|
2785
|
+
let entries = [];
|
|
2786
|
+
try {
|
|
2787
|
+
entries = await promises.readdir(root);
|
|
2788
|
+
} catch {
|
|
2789
|
+
return [];
|
|
2790
|
+
}
|
|
2791
|
+
const records = [];
|
|
2792
|
+
for (const entry of entries) {
|
|
2793
|
+
if (!entry.endsWith(".json")) continue;
|
|
2794
|
+
const recordPath = path.join(root, entry);
|
|
2795
|
+
try {
|
|
2796
|
+
const parsed = JSON.parse(await promises.readFile(recordPath, "utf8"));
|
|
2797
|
+
if (typeof parsed?.agent_id === "string") {
|
|
2798
|
+
records.push({ ...parsed, recordPath });
|
|
2799
|
+
}
|
|
2800
|
+
} catch {
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
return records;
|
|
2804
|
+
}
|
|
2668
2805
|
function tombstoneScript(info) {
|
|
2669
2806
|
const lines = [
|
|
2670
2807
|
`${TOMBSTONE_MARKER}: this Haven agent was retired.`,
|
|
@@ -2695,7 +2832,11 @@ function tombstoneScript(info) {
|
|
|
2695
2832
|
async function writeAgentTombstone(input) {
|
|
2696
2833
|
const dirStat = await promises.stat(input.directory).catch(() => null);
|
|
2697
2834
|
if (!dirStat?.isDirectory()) {
|
|
2698
|
-
throw new
|
|
2835
|
+
throw new ConnectError(
|
|
2836
|
+
"tombstone_directory_not_found",
|
|
2837
|
+
`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.`,
|
|
2838
|
+
"retry_with_an_existing_agent_directory"
|
|
2839
|
+
);
|
|
2699
2840
|
}
|
|
2700
2841
|
const info = {
|
|
2701
2842
|
// reason / replaced_by are persisted to disk and re-emitted to the host's
|
|
@@ -2712,8 +2853,13 @@ async function writeAgentTombstone(input) {
|
|
|
2712
2853
|
const wrapperPath = path.join(binDir, "haven-signer.mjs");
|
|
2713
2854
|
await promises.writeFile(wrapperPath, tombstoneScript(info), "utf8");
|
|
2714
2855
|
await promises.chmod(wrapperPath, 493);
|
|
2715
|
-
|
|
2716
|
-
|
|
2856
|
+
const record = JSON.stringify(info, null, 2) + "\n";
|
|
2857
|
+
await promises.writeFile(path.join(input.directory, TOMBSTONE_FILENAME), record, "utf8");
|
|
2858
|
+
const root = input.tombstonesDir ?? defaultTombstonesDir();
|
|
2859
|
+
await promises.mkdir(root, { recursive: true, mode: 448 });
|
|
2860
|
+
const recordPath = path.join(root, `${info.agent_id}.json`);
|
|
2861
|
+
await promises.writeFile(recordPath, record, { mode: MIRROR_MODE });
|
|
2862
|
+
return { ...info, recordPath };
|
|
2717
2863
|
}
|
|
2718
2864
|
async function readAgentTombstone(directory) {
|
|
2719
2865
|
try {
|
|
@@ -2724,15 +2870,185 @@ async function readAgentTombstone(directory) {
|
|
|
2724
2870
|
return null;
|
|
2725
2871
|
}
|
|
2726
2872
|
}
|
|
2727
|
-
var TOMBSTONE_FILENAME, TOMBSTONE_MARKER;
|
|
2873
|
+
var TOMBSTONE_FILENAME, MIRROR_MODE, TOMBSTONE_MARKER;
|
|
2728
2874
|
var init_tombstone = __esm({
|
|
2729
2875
|
"src/tombstone.ts"() {
|
|
2730
2876
|
init_redact();
|
|
2877
|
+
init_connect_error();
|
|
2731
2878
|
TOMBSTONE_FILENAME = "TOMBSTONE.json";
|
|
2879
|
+
MIRROR_MODE = 384;
|
|
2732
2880
|
TOMBSTONE_MARKER = "HAVEN-TOMBSTONE";
|
|
2733
2881
|
}
|
|
2734
2882
|
});
|
|
2735
2883
|
|
|
2884
|
+
// src/unwire.ts
|
|
2885
|
+
var unwire_exports = {};
|
|
2886
|
+
__export(unwire_exports, {
|
|
2887
|
+
unwireAgent: () => unwireAgent
|
|
2888
|
+
});
|
|
2889
|
+
function identityAt(directory) {
|
|
2890
|
+
return promises.readFile(path.join(directory, "identity.json"), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
2891
|
+
}
|
|
2892
|
+
function removeForModel(text, model, names, path) {
|
|
2893
|
+
switch (model.kind) {
|
|
2894
|
+
case "yaml":
|
|
2895
|
+
return removeHermesYaml(text, names, path);
|
|
2896
|
+
case "toml":
|
|
2897
|
+
return removeCodexToml(text, names);
|
|
2898
|
+
case "json":
|
|
2899
|
+
return removeJsonMcpConfig(text, model.serverRoot ?? "mcpServers", names, path);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
function envLineValue(envText, envKey) {
|
|
2903
|
+
const line = envText.split(/\r?\n/).find((candidate) => new RegExp(`^\\s*(?:export[ \\t]+)?${envKey}[ \\t]*=`).test(candidate));
|
|
2904
|
+
if (!line) return void 0;
|
|
2905
|
+
let value = line.slice(line.indexOf("=") + 1);
|
|
2906
|
+
value = value.trim();
|
|
2907
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2908
|
+
value = value.slice(1, -1);
|
|
2909
|
+
}
|
|
2910
|
+
return value;
|
|
2911
|
+
}
|
|
2912
|
+
async function readOptionalText(path) {
|
|
2913
|
+
try {
|
|
2914
|
+
return await promises.readFile(path, "utf8");
|
|
2915
|
+
} catch {
|
|
2916
|
+
return null;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
async function unwireAgent(input) {
|
|
2920
|
+
const homeDir = input.homeDir ?? os.homedir();
|
|
2921
|
+
const [identity, sidecar] = await Promise.all([identityAt(input.directory), readRuntimeSidecar(input.directory)]);
|
|
2922
|
+
const agentId = identity?.agent_id ?? "unknown";
|
|
2923
|
+
const slug = input.slug ?? sidecar?.server_name;
|
|
2924
|
+
const names = serverNamesFor(slug);
|
|
2925
|
+
const runtimes = [];
|
|
2926
|
+
let tombstoned = false;
|
|
2927
|
+
const tombstonePath = path.join(input.directory, TOMBSTONE_FILENAME);
|
|
2928
|
+
if (await readOptionalText(tombstonePath) === null) {
|
|
2929
|
+
await writeAgentTombstone({
|
|
2930
|
+
directory: input.directory,
|
|
2931
|
+
agentId,
|
|
2932
|
+
reason: input.reason ?? "unwired via --unwire",
|
|
2933
|
+
replacedBy: input.replacedBy,
|
|
2934
|
+
tombstonesDir: input.tombstonesDir
|
|
2935
|
+
});
|
|
2936
|
+
tombstoned = true;
|
|
2937
|
+
}
|
|
2938
|
+
for (const model of RUNTIMES) {
|
|
2939
|
+
const path = runtimeConfigPathFor(model.runtime, homeDir);
|
|
2940
|
+
if (path === null) continue;
|
|
2941
|
+
const text = await readOptionalText(path);
|
|
2942
|
+
if (text === null) continue;
|
|
2943
|
+
try {
|
|
2944
|
+
if (!slug) {
|
|
2945
|
+
const owned = sidecar?.wrapper_path != null && text.includes(sidecar.wrapper_path);
|
|
2946
|
+
if (!owned) {
|
|
2947
|
+
const pairPresent = removeForModel(text, model, names, path) !== text;
|
|
2948
|
+
if (!pairPresent) continue;
|
|
2949
|
+
runtimes.push({
|
|
2950
|
+
runtime: model.runtime,
|
|
2951
|
+
label: model.label,
|
|
2952
|
+
path,
|
|
2953
|
+
status: "refused",
|
|
2954
|
+
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"
|
|
2955
|
+
});
|
|
2956
|
+
continue;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
const next = removeForModel(text, model, names, path);
|
|
2960
|
+
if (next === text) continue;
|
|
2961
|
+
await promises.writeFile(path, next, "utf8");
|
|
2962
|
+
runtimes.push({ runtime: model.runtime, label: model.label, path, status: "removed" });
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
if (err instanceof UnreadableRuntimeConfigError) {
|
|
2965
|
+
runtimes.push({ runtime: model.runtime, label: model.label, path, status: "unreadable", detail: err.message });
|
|
2966
|
+
continue;
|
|
2967
|
+
}
|
|
2968
|
+
throw err;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
const envPath = hermesEnvPath(homeDir);
|
|
2972
|
+
const envText = await readOptionalText(envPath);
|
|
2973
|
+
if (envText !== null) {
|
|
2974
|
+
try {
|
|
2975
|
+
if (!slug) {
|
|
2976
|
+
const value = envLineValue(envText, names.hermesEnvKey);
|
|
2977
|
+
if (value === void 0) {
|
|
2978
|
+
} else if (!identity?.api_key) {
|
|
2979
|
+
runtimes.push({
|
|
2980
|
+
runtime: "hermes",
|
|
2981
|
+
label: "Hermes env",
|
|
2982
|
+
path: envPath,
|
|
2983
|
+
status: "refused",
|
|
2984
|
+
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"
|
|
2985
|
+
});
|
|
2986
|
+
} else if (value !== identity.api_key) {
|
|
2987
|
+
runtimes.push({
|
|
2988
|
+
runtime: "hermes",
|
|
2989
|
+
label: "Hermes env",
|
|
2990
|
+
path: envPath,
|
|
2991
|
+
status: "refused",
|
|
2992
|
+
detail: "MCP_HAVEN_API_KEY in the Hermes env holds a different agent's key; refusing to remove another agent's credential"
|
|
2993
|
+
});
|
|
2994
|
+
} else {
|
|
2995
|
+
const next = removeHermesEnv(envText, names.hermesEnvKey);
|
|
2996
|
+
if (next !== envText) {
|
|
2997
|
+
await promises.writeFile(envPath, next, "utf8");
|
|
2998
|
+
runtimes.push({ runtime: "hermes", label: "Hermes env", path: envPath, status: "removed" });
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
} else {
|
|
3002
|
+
const next = removeHermesEnv(envText, names.hermesEnvKey);
|
|
3003
|
+
if (next !== envText) {
|
|
3004
|
+
await promises.writeFile(envPath, next, "utf8");
|
|
3005
|
+
runtimes.push({ runtime: "hermes", label: "Hermes env", path: envPath, status: "removed" });
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
} catch (err) {
|
|
3009
|
+
if (err instanceof Error && err.message.includes("ambiguous managed key")) {
|
|
3010
|
+
runtimes.push({
|
|
3011
|
+
runtime: "hermes",
|
|
3012
|
+
label: "Hermes env",
|
|
3013
|
+
path: envPath,
|
|
3014
|
+
status: "refused",
|
|
3015
|
+
detail: err.message
|
|
3016
|
+
});
|
|
3017
|
+
} else {
|
|
3018
|
+
throw err;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
await Promise.all([
|
|
3023
|
+
promises.rm(path.join(input.directory, "signer.json"), { force: true }),
|
|
3024
|
+
promises.rm(path.join(input.directory, REKEY_PENDING_FILENAME), { force: true })
|
|
3025
|
+
]);
|
|
3026
|
+
if (identity && identity.api_key !== void 0) {
|
|
3027
|
+
const { api_key: _dropped, ...rest } = identity;
|
|
3028
|
+
await promises.writeFile(path.join(input.directory, "identity.json"), `${JSON.stringify(rest, null, 2)}
|
|
3029
|
+
`, { mode: 384 });
|
|
3030
|
+
}
|
|
3031
|
+
return { directory: input.directory, agentId, slug, tombstoned, runtimes };
|
|
3032
|
+
}
|
|
3033
|
+
var RUNTIMES;
|
|
3034
|
+
var init_unwire = __esm({
|
|
3035
|
+
"src/unwire.ts"() {
|
|
3036
|
+
init_config_writers();
|
|
3037
|
+
init_server_names();
|
|
3038
|
+
init_signer_runtime();
|
|
3039
|
+
init_tombstone();
|
|
3040
|
+
init_storage();
|
|
3041
|
+
RUNTIMES = [
|
|
3042
|
+
{ runtime: "hermes", label: "Hermes Agent config", kind: "yaml" },
|
|
3043
|
+
{ runtime: "codex-cli", label: "Codex config", kind: "toml" },
|
|
3044
|
+
{ runtime: "cursor", label: "Cursor MCP config", kind: "json", serverRoot: "mcpServers" },
|
|
3045
|
+
{ runtime: "vscode", label: "VS Code MCP config", kind: "json", serverRoot: "servers" },
|
|
3046
|
+
{ runtime: "vscode-insiders", label: "VS Code Insiders MCP config", kind: "json", serverRoot: "servers" },
|
|
3047
|
+
{ runtime: "claude-desktop", label: "Claude Desktop config", kind: "json", serverRoot: "mcpServers" }
|
|
3048
|
+
];
|
|
3049
|
+
}
|
|
3050
|
+
});
|
|
3051
|
+
|
|
2736
3052
|
// src/rekey.ts
|
|
2737
3053
|
var rekey_exports = {};
|
|
2738
3054
|
__export(rekey_exports, {
|
|
@@ -2792,7 +3108,7 @@ async function startRekey(options, deps = {}) {
|
|
|
2792
3108
|
async function finishRekey(options, deps = {}) {
|
|
2793
3109
|
const now = deps.now ?? (() => Date.now());
|
|
2794
3110
|
if (!options.newApiKey) {
|
|
2795
|
-
throw new Error(
|
|
3111
|
+
throw new Error(REKEY_FINISH_NEEDS_API_KEY);
|
|
2796
3112
|
}
|
|
2797
3113
|
const stored = await readStoredCredentials(
|
|
2798
3114
|
options.serverName,
|
|
@@ -2935,6 +3251,7 @@ var init_rekey = __esm({
|
|
|
2935
3251
|
init_signer_runtime();
|
|
2936
3252
|
init_key();
|
|
2937
3253
|
init_redact();
|
|
3254
|
+
init_rekey_messages();
|
|
2938
3255
|
init_server_names();
|
|
2939
3256
|
init_storage();
|
|
2940
3257
|
}
|
|
@@ -3414,6 +3731,15 @@ async function runDoctor(input, deps = {}) {
|
|
|
3414
3731
|
if (revoked.length > 0) parts.push(`already revoked: ${revoked.join(", ")}`);
|
|
3415
3732
|
if (retired.length > 0) parts.push(`tombstoned (keys removed): ${retired.join(", ")}`);
|
|
3416
3733
|
if (unverifiable.length > 0) parts.push(`could not verify: ${unverifiable.join(", ")}`);
|
|
3734
|
+
const knownIds = new Set([...inventory].map((e) => e.agentId ?? path.basename(e.directory)));
|
|
3735
|
+
const ghostRecords = (await readTombstoneRecords(path.join(homeDir, ".haven", "tombstones"))).filter(
|
|
3736
|
+
(rec) => !knownIds.has(rec.agent_id)
|
|
3737
|
+
);
|
|
3738
|
+
if (ghostRecords.length > 0) {
|
|
3739
|
+
parts.push(
|
|
3740
|
+
`retired records (dir removed): ${ghostRecords.map((rec) => `${rec.agent_id} (${rec.reason})`).join(", ")}`
|
|
3741
|
+
);
|
|
3742
|
+
}
|
|
3417
3743
|
const supersededLive = live.filter((item) => item.entry.classification !== "wired").map((item) => item.label);
|
|
3418
3744
|
checks.push({
|
|
3419
3745
|
id: "superseded_agents",
|
|
@@ -3648,6 +3974,14 @@ async function scanInstalledClients(options = {}) {
|
|
|
3648
3974
|
return SCAN_ORDER.indexOf(a.runtime) - SCAN_ORDER.indexOf(b.runtime);
|
|
3649
3975
|
});
|
|
3650
3976
|
}
|
|
3977
|
+
function installedClientHint(candidates) {
|
|
3978
|
+
const installedClients = candidates.map((candidate) => candidate.runtime);
|
|
3979
|
+
if (candidates.length === 1) {
|
|
3980
|
+
return { installedClients, suggestedRuntime: candidates[0].runtime };
|
|
3981
|
+
}
|
|
3982
|
+
const configured = candidates.filter((candidate) => candidate.evidence === "config-file");
|
|
3983
|
+
return configured.length === 1 ? { installedClients, suggestedRuntime: configured[0].runtime } : { installedClients };
|
|
3984
|
+
}
|
|
3651
3985
|
var MAX_PROMPT_ATTEMPTS = 3;
|
|
3652
3986
|
async function promptForInstalledClient(candidates, io = defaultPromptIo()) {
|
|
3653
3987
|
if (candidates.length === 0) throw noInstalledClientsError();
|
|
@@ -3721,9 +4055,35 @@ function defaultPromptIo() {
|
|
|
3721
4055
|
// src/runtime.ts
|
|
3722
4056
|
init_local_mcp_runtime();
|
|
3723
4057
|
init_runtime_manifest();
|
|
3724
|
-
var CONNECTOR_VERSION = "0.1.
|
|
4058
|
+
var CONNECTOR_VERSION = "0.1.33-alpha.0";
|
|
3725
4059
|
var CONNECT_OUTCOME_SCHEMA_VERSION = 1;
|
|
4060
|
+
var failureOutcomesByError = /* @__PURE__ */ new WeakMap();
|
|
4061
|
+
function failureOutcomeFor(runtimeHint, error) {
|
|
4062
|
+
if (error !== null && typeof error === "object") {
|
|
4063
|
+
const recorded = failureOutcomesByError.get(error);
|
|
4064
|
+
if (recorded) return recorded;
|
|
4065
|
+
}
|
|
4066
|
+
return failedConnectOutcome(runtimeHint, error);
|
|
4067
|
+
}
|
|
3726
4068
|
async function runConnect(options, deps = {}) {
|
|
4069
|
+
const trace = {};
|
|
4070
|
+
try {
|
|
4071
|
+
return await executeConnect(options, deps, trace);
|
|
4072
|
+
} catch (err) {
|
|
4073
|
+
const outcome = failedConnectOutcome(trace.runtime ?? options.runtime, err);
|
|
4074
|
+
if (err !== null && typeof err === "object") failureOutcomesByError.set(err, outcome);
|
|
4075
|
+
if (trace.directory) await recordConnectOutcome(deps, trace.directory, outcome);
|
|
4076
|
+
throw err;
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
async function recordConnectOutcome(deps, directory, outcome) {
|
|
4080
|
+
try {
|
|
4081
|
+
return await (deps.writeOutcomeRecord ?? writeConnectOutcomeRecord)(directory, outcome);
|
|
4082
|
+
} catch {
|
|
4083
|
+
return void 0;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
async function executeConnect(options, deps, trace) {
|
|
3727
4087
|
assertSupportedNodeVersion(deps.nodeVersion, MCP_RUNTIME_MANIFEST.minimumNodeVersion);
|
|
3728
4088
|
const connectorVersion = options.connectorVersion ?? CONNECTOR_VERSION;
|
|
3729
4089
|
const api = deps.api ?? createConnectApiClient(options.apiBaseUrl);
|
|
@@ -3743,9 +4103,10 @@ async function runConnect(options, deps = {}) {
|
|
|
3743
4103
|
promptForRuntime: runtimeSelectionPrompt(options, deps)
|
|
3744
4104
|
});
|
|
3745
4105
|
if (!selection.runtime) {
|
|
4106
|
+
const hint = await installedClientHintFor(deps);
|
|
3746
4107
|
throw new ConnectError(
|
|
3747
4108
|
"runtime_undetermined",
|
|
3748
|
-
`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
|
|
4109
|
+
`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.",
|
|
3749
4110
|
"rerun_connect_with_explicit_runtime",
|
|
3750
4111
|
// #2091: the values must ride structurally too. The backend's setup
|
|
3751
4112
|
// prompt permits a retry only with "one of the values that refusal
|
|
@@ -3753,10 +4114,16 @@ async function runConnect(options, deps = {}) {
|
|
|
3753
4114
|
// every automation run in an undetected runtime (Codex in the field:
|
|
3754
4115
|
// npx needs network, Codex runs network commands unsandboxed, and the
|
|
3755
4116
|
// unsandboxed path carries none of the CODEX_* detection vars).
|
|
3756
|
-
|
|
4117
|
+
//
|
|
4118
|
+
// #2174: the list alone still leaves the retry a nine-value guess. The
|
|
4119
|
+
// scan below narrows it to what is actually on this machine — as a
|
|
4120
|
+
// HINT. It does not select, and this stays a refusal: see
|
|
4121
|
+
// `installedClientHint`.
|
|
4122
|
+
{ allowedRuntimes: RUNTIME_FLAG_VALUE_LIST, ...hint }
|
|
3757
4123
|
);
|
|
3758
4124
|
}
|
|
3759
4125
|
const runtime = selection.runtime;
|
|
4126
|
+
trace.runtime = runtime;
|
|
3760
4127
|
const installCapabilities = runtimeInstallCapabilities(runtime);
|
|
3761
4128
|
if (options.localMcp) {
|
|
3762
4129
|
if (!supportsLocalMcp(runtime)) {
|
|
@@ -3852,6 +4219,7 @@ async function runConnect(options, deps = {}) {
|
|
|
3852
4219
|
x402BindingSigner: setup.x402_binding_signer ?? void 0,
|
|
3853
4220
|
warn: log
|
|
3854
4221
|
});
|
|
4222
|
+
trace.directory = credentialPaths.directory;
|
|
3855
4223
|
log(`Stored Haven identity credential locally: ${credentialPaths.identityPath}`);
|
|
3856
4224
|
log(`Stored local signer credential locally: ${credentialPaths.signerPath}`);
|
|
3857
4225
|
log(`Stored non-secret agent orientation locally: ${credentialPaths.agentPath}`);
|
|
@@ -3899,6 +4267,9 @@ async function runConnect(options, deps = {}) {
|
|
|
3899
4267
|
errorCode: early.errorCode ?? null,
|
|
3900
4268
|
environmentLabel: options.environmentLabel ?? "Local workspace"
|
|
3901
4269
|
});
|
|
4270
|
+
if (!early.errorCode || early.errorCode === "manual_runtime_setup_required") {
|
|
4271
|
+
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.");
|
|
4272
|
+
}
|
|
3902
4273
|
} catch {
|
|
3903
4274
|
}
|
|
3904
4275
|
}
|
|
@@ -3909,12 +4280,13 @@ async function runConnect(options, deps = {}) {
|
|
|
3909
4280
|
} else {
|
|
3910
4281
|
log("Haven setup on this machine is complete.");
|
|
3911
4282
|
}
|
|
4283
|
+
let supersededAgentIds = [];
|
|
3912
4284
|
try {
|
|
3913
|
-
|
|
3914
|
-
if (
|
|
4285
|
+
supersededAgentIds = await listOtherAgentIds(options.credentialsDir, credentialPaths.directory);
|
|
4286
|
+
if (supersededAgentIds.length > 0) {
|
|
3915
4287
|
log("");
|
|
3916
4288
|
log(
|
|
3917
|
-
`Heads-up: this setup created a NEW agent. Your previous agent(s) \u2014 ${
|
|
4289
|
+
`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.`
|
|
3918
4290
|
);
|
|
3919
4291
|
log(
|
|
3920
4292
|
`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.`
|
|
@@ -3949,17 +4321,23 @@ async function runConnect(options, deps = {}) {
|
|
|
3949
4321
|
approval = await waitForBudgetApproval(api, registration.setup_id, localApiKey, log, options.approvalWait);
|
|
3950
4322
|
}
|
|
3951
4323
|
printNextSteps(runtimeInstall, log, approval);
|
|
4324
|
+
const outcome = completionOutcome({
|
|
4325
|
+
runtimeInstall,
|
|
4326
|
+
delegateAddress: registration.delegate_address,
|
|
4327
|
+
hostedMcpUrl: registration.hosted_mcp_url,
|
|
4328
|
+
supersededAgentIds,
|
|
4329
|
+
setupChallengeExpiresAt: setup.challenge.expires_at,
|
|
4330
|
+
approvalRequired: registration.agent_status === "pending_approval"
|
|
4331
|
+
});
|
|
4332
|
+
if (await recordConnectOutcome(deps, credentialPaths.directory, outcome)) {
|
|
4333
|
+
log(`Saved this run's outcome to ${CONNECT_OUTCOME_FILENAME} in the agent's credential directory.`);
|
|
4334
|
+
}
|
|
3952
4335
|
return {
|
|
3953
4336
|
setupId: registration.setup_id,
|
|
3954
4337
|
agentId: registration.agent_id,
|
|
3955
4338
|
delegateAddress: registration.delegate_address,
|
|
3956
4339
|
credentialPaths,
|
|
3957
|
-
outcome
|
|
3958
|
-
runtimeInstall,
|
|
3959
|
-
delegateAddress: registration.delegate_address,
|
|
3960
|
-
setupChallengeExpiresAt: setup.challenge.expires_at,
|
|
3961
|
-
approvalRequired: registration.agent_status === "pending_approval"
|
|
3962
|
-
})
|
|
4340
|
+
outcome
|
|
3963
4341
|
};
|
|
3964
4342
|
}
|
|
3965
4343
|
function completionOutcome(input) {
|
|
@@ -3991,11 +4369,35 @@ function completionOutcome(input) {
|
|
|
3991
4369
|
// malformed value arrives, do not echo it into an automation-facing
|
|
3992
4370
|
// record; the human log has already been redacted separately.
|
|
3993
4371
|
delegate_address: /^0x[0-9a-fA-F]{40}$/.test(input.delegateAddress) ? shortAddress(input.delegateAddress) : "[delegate-address-redacted]",
|
|
4372
|
+
// The endpoint Connect wrote into the runtime's MCP config, verbatim — the
|
|
4373
|
+
// same string that already sits in the user's own config file. Passed
|
|
4374
|
+
// through the secret filter anyway: this is the automation contract, the
|
|
4375
|
+
// value is server-supplied, and belts are cheap (the #1589 stance).
|
|
4376
|
+
...input.hostedMcpUrl ? { hosted_mcp_url: redactSecrets(input.hostedMcpUrl) } : {},
|
|
4377
|
+
// Always emitted on a completed run, empty list included: "no superseded
|
|
4378
|
+
// agents" is a fact a caller needs, and an omitted key would be
|
|
4379
|
+
// indistinguishable from an older connector that never reported it.
|
|
4380
|
+
superseded_agent_ids: input.supersededAgentIds ?? [],
|
|
3994
4381
|
...input.setupChallengeExpiresAt ? { setup_challenge_expires_at: input.setupChallengeExpiresAt } : {},
|
|
3995
4382
|
...runtimeInstall.errorCode ? { error: { code: runtimeInstall.errorCode, next_action: nextAction2 } } : {}
|
|
3996
4383
|
};
|
|
3997
4384
|
return outcome;
|
|
3998
4385
|
}
|
|
4386
|
+
async function installedClientHintFor(deps) {
|
|
4387
|
+
try {
|
|
4388
|
+
const candidates = await (deps.scanInstalledClients ?? scanInstalledClients)({ env: deps.env });
|
|
4389
|
+
const hint = installedClientHint(candidates);
|
|
4390
|
+
return hint.installedClients.length > 0 ? hint : {};
|
|
4391
|
+
} catch {
|
|
4392
|
+
return {};
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
function installedClientProse(hint) {
|
|
4396
|
+
const found = hint.installedClients ?? [];
|
|
4397
|
+
if (found.length === 0) return "";
|
|
4398
|
+
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.";
|
|
4399
|
+
return `Haven can see these agent clients installed here, likeliest first: ${found.join(", ")}.${suggestion} `;
|
|
4400
|
+
}
|
|
3999
4401
|
function runtimeSelectionPrompt(options, deps) {
|
|
4000
4402
|
if (options.interactive !== true) return void 0;
|
|
4001
4403
|
if (!(deps.isTty ?? Boolean(process.stdin.isTTY))) return void 0;
|
|
@@ -4029,7 +4431,9 @@ function failedConnectOutcome(runtimeHint, error) {
|
|
|
4029
4431
|
// pinned by test). Redaction stays on as belt-and-braces; plain-Error
|
|
4030
4432
|
// runs still get their redacted message on stderr via the CLI mirror.
|
|
4031
4433
|
...error instanceof ConnectError && message ? { message: redactForAutomation(message) } : {},
|
|
4032
|
-
...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {}
|
|
4434
|
+
...error instanceof ConnectError && error.details.allowedRuntimes ? { allowed_runtimes: error.details.allowedRuntimes } : {},
|
|
4435
|
+
...error instanceof ConnectError && error.details.installedClients?.length ? { installed_clients: error.details.installedClients } : {},
|
|
4436
|
+
...error instanceof ConnectError && error.details.suggestedRuntime ? { suggested_runtime: error.details.suggestedRuntime } : {}
|
|
4033
4437
|
}
|
|
4034
4438
|
};
|
|
4035
4439
|
}
|
|
@@ -4102,6 +4506,13 @@ function describeApprovedBudget(budget) {
|
|
|
4102
4506
|
const amount = token ? `${formatAtomicAmount(BigInt(budget.amount), token.decimals)} ${budget.token_symbol}` : `${budget.amount} ${budget.token_symbol} (atomic units)`;
|
|
4103
4507
|
return `${amount} ${describeResetPeriod(budget.reset_period_min)}`;
|
|
4104
4508
|
}
|
|
4509
|
+
function describeWaitBound(timeoutMs) {
|
|
4510
|
+
if (timeoutMs >= 12e4) {
|
|
4511
|
+
const minutes = Math.round(timeoutMs / 6e4);
|
|
4512
|
+
return `${minutes} minutes`;
|
|
4513
|
+
}
|
|
4514
|
+
return `${Math.round(timeoutMs / 1e3)}s`;
|
|
4515
|
+
}
|
|
4105
4516
|
async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
4106
4517
|
const intervalMs = options.intervalMs ?? 5e3;
|
|
4107
4518
|
const timeoutMs = options.timeoutMs ?? 18e4;
|
|
@@ -4112,7 +4523,7 @@ async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
|
4112
4523
|
const announceWaiting = () => {
|
|
4113
4524
|
if (waitingAnnounced) return;
|
|
4114
4525
|
waitingAnnounced = true;
|
|
4115
|
-
log("
|
|
4526
|
+
log("Now waiting for you to approve the budget in the Haven dashboard \u2014 approval is what unlocks the agent's Haven tools\u2026");
|
|
4116
4527
|
};
|
|
4117
4528
|
for (let i = 0; i < maxPolls; i++) {
|
|
4118
4529
|
if (i > 0) await sleep(intervalMs);
|
|
@@ -4139,7 +4550,10 @@ async function waitForBudgetApproval(api, setupId, apiKey, log, options = {}) {
|
|
|
4139
4550
|
}
|
|
4140
4551
|
announceWaiting();
|
|
4141
4552
|
if (i > 0 && i % remindEvery === 0) {
|
|
4142
|
-
|
|
4553
|
+
const elapsedSeconds = Math.round(i * intervalMs / 1e3);
|
|
4554
|
+
log(
|
|
4555
|
+
`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.`
|
|
4556
|
+
);
|
|
4143
4557
|
}
|
|
4144
4558
|
}
|
|
4145
4559
|
log(
|
|
@@ -4224,6 +4638,7 @@ function printNextSteps(result, log, approval) {
|
|
|
4224
4638
|
|
|
4225
4639
|
// src/args.ts
|
|
4226
4640
|
init_server_names();
|
|
4641
|
+
init_rekey_messages();
|
|
4227
4642
|
function parseArgs(argv, env = process.env) {
|
|
4228
4643
|
const options = {
|
|
4229
4644
|
apiBaseUrl: env.HAVEN_API_URL ?? "http://localhost:3001",
|
|
@@ -4238,6 +4653,8 @@ function parseArgs(argv, env = process.env) {
|
|
|
4238
4653
|
let tombstoneDir;
|
|
4239
4654
|
let tombstoneReason;
|
|
4240
4655
|
let tombstoneReplacedBy;
|
|
4656
|
+
let unwire;
|
|
4657
|
+
let unwireDir;
|
|
4241
4658
|
for (let i = 0; i < argv.length; i += 1) {
|
|
4242
4659
|
const arg = argv[i];
|
|
4243
4660
|
if (arg === "--help" || arg === "-h") {
|
|
@@ -4256,6 +4673,13 @@ function parseArgs(argv, env = process.env) {
|
|
|
4256
4673
|
newApiKey = requireValue(argv, ++i, arg);
|
|
4257
4674
|
} else if (arg === "--tombstone") {
|
|
4258
4675
|
tombstoneDir = requireValue(argv, ++i, arg);
|
|
4676
|
+
} else if (arg === "--unwire") {
|
|
4677
|
+
unwire = unwire ?? {};
|
|
4678
|
+
const next = argv[i + 1];
|
|
4679
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
4680
|
+
unwireDir = next;
|
|
4681
|
+
i += 1;
|
|
4682
|
+
}
|
|
4259
4683
|
} else if (arg === "--reason") {
|
|
4260
4684
|
tombstoneReason = requireValue(argv, ++i, arg);
|
|
4261
4685
|
} else if (arg === "--replaced-by") {
|
|
@@ -4305,18 +4729,37 @@ function parseArgs(argv, env = process.env) {
|
|
|
4305
4729
|
);
|
|
4306
4730
|
}
|
|
4307
4731
|
if (rekey.phase === "finish" && !newApiKey) {
|
|
4308
|
-
throw new Error(
|
|
4732
|
+
throw new Error(REKEY_FINISH_NEEDS_API_KEY);
|
|
4309
4733
|
}
|
|
4310
4734
|
return { options, help, json, doctor, repair, tombstone, rekey };
|
|
4311
4735
|
}
|
|
4312
4736
|
if (newApiKey !== void 0) {
|
|
4313
4737
|
throw new Error("--api-key requires --rekey-finish.");
|
|
4314
4738
|
}
|
|
4315
|
-
if (!tombstoneDir && (tombstoneReason !== void 0 || tombstoneReplacedBy !== void 0)) {
|
|
4316
|
-
throw new Error("--reason and --replaced-by require --tombstone <dir
|
|
4739
|
+
if (!tombstoneDir && !unwire && (tombstoneReason !== void 0 || tombstoneReplacedBy !== void 0)) {
|
|
4740
|
+
throw new Error("--reason and --replaced-by require --tombstone <dir> or --unwire.");
|
|
4741
|
+
}
|
|
4742
|
+
if (unwire && (tombstoneReason !== void 0 || tombstoneReplacedBy !== void 0)) {
|
|
4743
|
+
unwire = {
|
|
4744
|
+
...tombstoneReason !== void 0 ? { reason: tombstoneReason } : {},
|
|
4745
|
+
...tombstoneReplacedBy !== void 0 ? { replacedBy: tombstoneReplacedBy } : {},
|
|
4746
|
+
...unwire
|
|
4747
|
+
};
|
|
4317
4748
|
}
|
|
4318
4749
|
if (tombstone) {
|
|
4319
|
-
|
|
4750
|
+
if (unwire) {
|
|
4751
|
+
throw new Error("--unwire and --tombstone are separate operations; run one per invocation.");
|
|
4752
|
+
}
|
|
4753
|
+
return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir };
|
|
4754
|
+
}
|
|
4755
|
+
if (unwire) {
|
|
4756
|
+
if (options.setupToken) {
|
|
4757
|
+
throw new Error("--unwire removes existing wiring; it does not take --setup. Drop --setup.");
|
|
4758
|
+
}
|
|
4759
|
+
if (!unwireDir && !options.serverName && !options.credentialsDir) {
|
|
4760
|
+
throw new Error("--unwire needs a target: --unwire <dir>, --unwire --name <slug>, or --unwire --credentials-dir <path>.");
|
|
4761
|
+
}
|
|
4762
|
+
return { options, help, json, doctor, repair, tombstone, rekey, unwire, unwireDir };
|
|
4320
4763
|
}
|
|
4321
4764
|
if (doctor || repair) {
|
|
4322
4765
|
if (!options.runtime) {
|
|
@@ -4379,8 +4822,20 @@ function helpText() {
|
|
|
4379
4822
|
" --tombstone <dir> Retire an agent credential directory in place (no token): replaces its signer",
|
|
4380
4823
|
" wrapper with a diagnostic that names the retirement in MCP stderr logs, and",
|
|
4381
4824
|
" writes TOMBSTONE.json. Touches NO key material and revokes nothing.",
|
|
4382
|
-
" --
|
|
4383
|
-
"
|
|
4825
|
+
" --unwire [<dir>] Remove one agent\u2019s wiring from every runtime config it appears in (no token):",
|
|
4826
|
+
" tombstone-first, then drops the hosted + signer MCP pair from Hermes YAML, Codex",
|
|
4827
|
+
" TOML and the JSON configs (Cursor, VS Code, Insiders, Claude Desktop), plus the",
|
|
4828
|
+
" Hermes dotenv API-key line (MCP_HAVEN_API_KEY / MCP_HAVEN_<SLUG>_API_KEY).",
|
|
4829
|
+
" Target the directory directly, or add --name <slug> or --credentials-dir.",
|
|
4830
|
+
" An UNNAMED pair is only removed when this directory\u2019s wrapper is the one the",
|
|
4831
|
+
" config launches (or its key is the one the Hermes env holds); otherwise the",
|
|
4832
|
+
" command refuses rather than unwire a different agent \u2014 it never touches a",
|
|
4833
|
+
" pair another agent owns. Tears down the target directory: its signer key and",
|
|
4834
|
+
" API key are removed locally (record kept via the #2155 tombstone mirror) and",
|
|
4835
|
+
" nothing is ever revoked on the backend \u2014 that stays an owner action on the",
|
|
4836
|
+
" Haven agent page.",
|
|
4837
|
+
" --reason <text> Reason recorded in the tombstone (with --tombstone or --unwire).",
|
|
4838
|
+
" --replaced-by <agent-id> Successor agent recorded in the tombstone (with --tombstone or --unwire).",
|
|
4384
4839
|
" --help Show this help.",
|
|
4385
4840
|
"",
|
|
4386
4841
|
"The connector never prints the private key and never sends it to Haven. JSON output never includes credential contents or full credential paths."
|
|
@@ -4396,6 +4851,28 @@ function requireValue(argv, index, option) {
|
|
|
4396
4851
|
|
|
4397
4852
|
// src/cli.ts
|
|
4398
4853
|
init_redact();
|
|
4854
|
+
init_connect_error();
|
|
4855
|
+
function failSubcommand(io, json, err, envelope, fallback) {
|
|
4856
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4857
|
+
io.stderr(`${redactSecrets(message)}
|
|
4858
|
+
`);
|
|
4859
|
+
if (json) {
|
|
4860
|
+
io.stdout(
|
|
4861
|
+
`${redactSecrets(
|
|
4862
|
+
JSON.stringify({
|
|
4863
|
+
...envelope,
|
|
4864
|
+
error: {
|
|
4865
|
+
code: isConnectError(err) ? err.code : fallback.code,
|
|
4866
|
+
next_action: isConnectError(err) ? err.nextAction : fallback.nextAction,
|
|
4867
|
+
...isConnectError(err) && message ? { message } : {}
|
|
4868
|
+
}
|
|
4869
|
+
})
|
|
4870
|
+
)}
|
|
4871
|
+
`
|
|
4872
|
+
);
|
|
4873
|
+
}
|
|
4874
|
+
return 1;
|
|
4875
|
+
}
|
|
4399
4876
|
async function runCli(argv, io = {
|
|
4400
4877
|
stdout: (message) => process.stdout.write(message),
|
|
4401
4878
|
stderr: (message) => process.stderr.write(message)
|
|
@@ -4423,13 +4900,13 @@ async function runCli(argv, io = {
|
|
|
4423
4900
|
}
|
|
4424
4901
|
if (parsed.tombstone) {
|
|
4425
4902
|
const { writeAgentTombstone: writeAgentTombstone2 } = await Promise.resolve().then(() => (init_tombstone(), tombstone_exports));
|
|
4426
|
-
const { readFile:
|
|
4427
|
-
const { join:
|
|
4903
|
+
const { readFile: readFile13 } = await import('fs/promises');
|
|
4904
|
+
const { join: join11 } = await import('path');
|
|
4428
4905
|
try {
|
|
4429
4906
|
let agentId = "unknown";
|
|
4430
4907
|
try {
|
|
4431
4908
|
const identity = JSON.parse(
|
|
4432
|
-
await
|
|
4909
|
+
await readFile13(join11(parsed.tombstone.directory, "identity.json"), "utf8")
|
|
4433
4910
|
);
|
|
4434
4911
|
agentId = identity.agent_id ?? "unknown";
|
|
4435
4912
|
} catch {
|
|
@@ -4450,14 +4927,80 @@ async function runCli(argv, io = {
|
|
|
4450
4927
|
"Key files were NOT touched and nothing was revoked \u2014 revoke the agent on the Haven agent page if you have not already.\n"
|
|
4451
4928
|
);
|
|
4452
4929
|
io.stdout(
|
|
4453
|
-
|
|
4930
|
+
`A surviving tombstone record was mirrored to ${redactSecrets(info.recordPath)}
|
|
4931
|
+
`
|
|
4932
|
+
);
|
|
4933
|
+
io.stdout(
|
|
4934
|
+
"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"
|
|
4454
4935
|
);
|
|
4455
4936
|
}
|
|
4456
4937
|
return 0;
|
|
4457
4938
|
} catch (err) {
|
|
4458
|
-
io.
|
|
4459
|
-
|
|
4460
|
-
|
|
4939
|
+
return failSubcommand(io, parsed.json, err, { tombstoned: false }, {
|
|
4940
|
+
code: "tombstone_failed",
|
|
4941
|
+
nextAction: "review_the_error_and_retry_with_a_valid_agent_directory"
|
|
4942
|
+
});
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4945
|
+
if (parsed.unwire) {
|
|
4946
|
+
const { unwireAgent: unwireAgent2 } = await Promise.resolve().then(() => (init_unwire(), unwire_exports));
|
|
4947
|
+
const { homedir: homedir10 } = await import('os');
|
|
4948
|
+
const { join: join11 } = await import('path');
|
|
4949
|
+
const homeDir = homedir10();
|
|
4950
|
+
const root = parsed.options.credentialsDir ?? join11(homeDir, ".haven", "agents");
|
|
4951
|
+
const directory = parsed.unwireDir ?? (parsed.options.serverName ? join11(root, parsed.options.serverName) : root);
|
|
4952
|
+
try {
|
|
4953
|
+
const result = await unwireAgent2({
|
|
4954
|
+
directory,
|
|
4955
|
+
slug: parsed.options.serverName,
|
|
4956
|
+
reason: parsed.unwire.reason,
|
|
4957
|
+
replacedBy: parsed.unwire.replacedBy,
|
|
4958
|
+
homeDir
|
|
4959
|
+
});
|
|
4960
|
+
const failures = result.runtimes.filter((r) => r.status === "refused" || r.status === "unreadable");
|
|
4961
|
+
if (parsed.json) {
|
|
4962
|
+
io.stdout(
|
|
4963
|
+
`${redactSecrets(
|
|
4964
|
+
JSON.stringify({
|
|
4965
|
+
unwired: true,
|
|
4966
|
+
agent_id: result.agentId,
|
|
4967
|
+
slug: result.slug ?? null,
|
|
4968
|
+
directory: result.directory,
|
|
4969
|
+
tombstoned: result.tombstoned,
|
|
4970
|
+
runtimes: result.runtimes.map((r) => ({
|
|
4971
|
+
runtime: r.runtime,
|
|
4972
|
+
label: r.label,
|
|
4973
|
+
status: r.status,
|
|
4974
|
+
...r.detail ? { detail: r.detail } : {}
|
|
4975
|
+
}))
|
|
4976
|
+
})
|
|
4977
|
+
)}
|
|
4978
|
+
`
|
|
4979
|
+
);
|
|
4980
|
+
} else {
|
|
4981
|
+
io.stdout(redactSecrets(`Unwired agent ${result.agentId} at ${result.directory}.
|
|
4982
|
+
`));
|
|
4983
|
+
io.stdout(
|
|
4984
|
+
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"
|
|
4985
|
+
);
|
|
4986
|
+
for (const r of result.runtimes) {
|
|
4987
|
+
const mark = r.status === "removed" ? "\u2713" : r.status === "clean" ? "\u2013" : "\u2717";
|
|
4988
|
+
io.stdout(redactSecrets(` ${mark} ${r.label}: ${r.status}${r.detail ? ` \u2014 ${r.detail}` : ""}
|
|
4989
|
+
`));
|
|
4990
|
+
}
|
|
4991
|
+
io.stdout(
|
|
4992
|
+
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"
|
|
4993
|
+
);
|
|
4994
|
+
io.stdout(
|
|
4995
|
+
"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"
|
|
4996
|
+
);
|
|
4997
|
+
}
|
|
4998
|
+
return failures.length > 0 ? 1 : 0;
|
|
4999
|
+
} catch (err) {
|
|
5000
|
+
return failSubcommand(io, parsed.json, err, { unwired: false }, {
|
|
5001
|
+
code: "unwire_failed",
|
|
5002
|
+
nextAction: "review_the_error_and_rerun_unwire_which_is_idempotent"
|
|
5003
|
+
});
|
|
4461
5004
|
}
|
|
4462
5005
|
}
|
|
4463
5006
|
if (parsed.rekey) {
|
|
@@ -4513,9 +5056,10 @@ async function runCli(argv, io = {
|
|
|
4513
5056
|
}
|
|
4514
5057
|
return 0;
|
|
4515
5058
|
} catch (err) {
|
|
4516
|
-
io.
|
|
4517
|
-
|
|
4518
|
-
|
|
5059
|
+
return failSubcommand(io, parsed.json, err, { rekey: "failed" }, {
|
|
5060
|
+
code: "rekey_failed",
|
|
5061
|
+
nextAction: "review_the_error_and_rerun_the_rekey_phase"
|
|
5062
|
+
});
|
|
4519
5063
|
}
|
|
4520
5064
|
}
|
|
4521
5065
|
if (parsed.doctor || parsed.repair) {
|
|
@@ -4561,9 +5105,10 @@ async function runCli(argv, io = {
|
|
|
4561
5105
|
}
|
|
4562
5106
|
return report.ok ? 0 : 1;
|
|
4563
5107
|
} catch (err) {
|
|
4564
|
-
io.
|
|
4565
|
-
|
|
4566
|
-
|
|
5108
|
+
return failSubcommand(io, parsed.json, err, { doctor: "failed" }, {
|
|
5109
|
+
code: "doctor_failed",
|
|
5110
|
+
nextAction: "review_the_error_and_rerun_doctor"
|
|
5111
|
+
});
|
|
4567
5112
|
}
|
|
4568
5113
|
}
|
|
4569
5114
|
try {
|
|
@@ -4590,7 +5135,7 @@ async function runCli(argv, io = {
|
|
|
4590
5135
|
if (parsed.json) {
|
|
4591
5136
|
io.stderr(`${redactForAutomation(err instanceof Error ? err.message : String(err))}
|
|
4592
5137
|
`);
|
|
4593
|
-
io.stdout(`${JSON.stringify(
|
|
5138
|
+
io.stdout(`${JSON.stringify(failureOutcomeFor(parsed.options.runtime, err))}
|
|
4594
5139
|
`);
|
|
4595
5140
|
} else {
|
|
4596
5141
|
io.stderr(`${redactSecrets(err instanceof Error ? err.message : String(err))}
|