@atbash/cli 0.5.15-dev.0 → 0.5.15-dev.2
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 +26 -5
- package/dist/bin/atbash.js +4 -1
- package/dist/bin/atbash.js.map +1 -1
- package/dist/commands/connect.d.ts +0 -23
- package/dist/commands/connect.js +7 -53
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/mcp-cmd.d.ts +13 -0
- package/dist/commands/mcp-cmd.js +216 -0
- package/dist/commands/mcp-cmd.js.map +1 -0
- package/dist/commands/setup.d.ts +104 -1
- package/dist/commands/setup.js +505 -78
- package/dist/commands/setup.js.map +1 -1
- package/dist/shared/atbash-targets.d.ts +49 -0
- package/dist/shared/atbash-targets.js +63 -0
- package/dist/shared/atbash-targets.js.map +1 -0
- package/package.json +3 -3
package/dist/commands/setup.js
CHANGED
|
@@ -43,6 +43,13 @@ exports.resolveKeySource = resolveKeySource;
|
|
|
43
43
|
exports.keyFileContents = keyFileContents;
|
|
44
44
|
exports.isJsonc = isJsonc;
|
|
45
45
|
exports.mergeOpenclawConfig = mergeOpenclawConfig;
|
|
46
|
+
exports.detectIndent = detectIndent;
|
|
47
|
+
exports.serializeLike = serializeLike;
|
|
48
|
+
exports.detectMcpClients = detectMcpClients;
|
|
49
|
+
exports.findHermesPython = findHermesPython;
|
|
50
|
+
exports.mergeHermesEnv = mergeHermesEnv;
|
|
51
|
+
exports.hadInlineKey = hadInlineKey;
|
|
52
|
+
exports.mergeMcpServer = mergeMcpServer;
|
|
46
53
|
exports.buildPlan = buildPlan;
|
|
47
54
|
exports.lineDiff = lineDiff;
|
|
48
55
|
exports.renderPlan = renderPlan;
|
|
@@ -56,7 +63,7 @@ const child_process_1 = require("child_process");
|
|
|
56
63
|
const chalk_1 = __importDefault(require("chalk"));
|
|
57
64
|
const jsonc = __importStar(require("jsonc-parser"));
|
|
58
65
|
const sdk_1 = require("@atbash/sdk");
|
|
59
|
-
const
|
|
66
|
+
const atbash_targets_1 = require("../shared/atbash-targets");
|
|
60
67
|
/**
|
|
61
68
|
* `atbash setup` — the write half of onboarding.
|
|
62
69
|
*
|
|
@@ -84,9 +91,11 @@ const connect_1 = require("./connect");
|
|
|
84
91
|
* 2. It never writes the private key into an MCP client config. The documented
|
|
85
92
|
* `@atbash/mcp` wiring passes the key as an `ATBASH_AGENT_PRIVKEY` env value
|
|
86
93
|
* inside e.g. `claude_desktop_config.json` — a file people screenshot, sync
|
|
87
|
-
* and share
|
|
88
|
-
*
|
|
89
|
-
*
|
|
94
|
+
* and share, and that package has no key-file fallback (verified against the
|
|
95
|
+
* published 0.1.3). So the entry setup writes points at `atbash mcp`, which
|
|
96
|
+
* reads the 0600 key file and passes the key to the server through the child
|
|
97
|
+
* environment. The config file itself gets NO credential — and an entry that
|
|
98
|
+
* was hand-wired with one has it removed.
|
|
90
99
|
* 3. It never edits application source. Code-level integrations (LangChain,
|
|
91
100
|
* LangGraph, AutoGen, Eliza, the SDK boundary) are the owner's to write.
|
|
92
101
|
* 4. It never rewrites a config file that uses comments or trailing commas.
|
|
@@ -208,7 +217,45 @@ function normalizePrivkey(raw) {
|
|
|
208
217
|
const clean = raw.replace(/^0x/i, "").trim().toLowerCase();
|
|
209
218
|
return (0, sdk_1.isValidPrivateKey)(clean) ? clean : "";
|
|
210
219
|
}
|
|
211
|
-
/**
|
|
220
|
+
/** Only sniff the contents of small files — a key file is a few hundred bytes. */
|
|
221
|
+
const MAX_SNIFF_BYTES = 8 * 1024;
|
|
222
|
+
/** Bound the content-sniff so `--keys-dir ~` cannot turn into a directory crawl. */
|
|
223
|
+
const MAX_SNIFF_FILES = 60;
|
|
224
|
+
/** Newest-first, so a freshly downloaded key wins over one from last month. */
|
|
225
|
+
function newestFirst(files) {
|
|
226
|
+
return files
|
|
227
|
+
.map((file) => {
|
|
228
|
+
let mtime = 0;
|
|
229
|
+
try {
|
|
230
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
231
|
+
}
|
|
232
|
+
catch { /* unreadable — sorts last */ }
|
|
233
|
+
return { file, mtime };
|
|
234
|
+
})
|
|
235
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
236
|
+
.map((e) => e.file);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Files in a directory that plausibly hold an Atbash agent key, newest first.
|
|
240
|
+
*
|
|
241
|
+
* TWO PASSES, and the second one is the point.
|
|
242
|
+
*
|
|
243
|
+
* By name first — `guard-client-key`, `agent-keys-*.txt` and friends — because
|
|
244
|
+
* matching the name is cheap and unambiguous. But a name-only match is a cliff:
|
|
245
|
+
* rename the download, or export from a wallet UI that picks its own filename,
|
|
246
|
+
* and the operator gets "no key file found" while the key sits right there in the
|
|
247
|
+
* directory they explicitly pointed at.
|
|
248
|
+
*
|
|
249
|
+
* So if no name matches, read the small files and keep the ones that actually
|
|
250
|
+
* PARSE as key material. That is a narrow test — `privkey=`, the documented JSON
|
|
251
|
+
* shape, or a file that is nothing but a 64-hex key — not "contains something
|
|
252
|
+
* hex-looking", so an unrelated file does not get mistaken for an identity.
|
|
253
|
+
*
|
|
254
|
+
* Reading files the operator did not name individually is justified by the flag
|
|
255
|
+
* itself: `--keys-dir` is an explicit instruction to look in that directory. It
|
|
256
|
+
* is bounded to small regular files and a file count, nothing is transmitted, and
|
|
257
|
+
* the caller prints WHICH file it used before doing anything with it.
|
|
258
|
+
*/
|
|
212
259
|
function keyCandidatesInDir(dir) {
|
|
213
260
|
let names;
|
|
214
261
|
try {
|
|
@@ -217,21 +264,31 @@ function keyCandidatesInDir(dir) {
|
|
|
217
264
|
catch {
|
|
218
265
|
return [];
|
|
219
266
|
}
|
|
220
|
-
const
|
|
267
|
+
const byName = names.filter((n) => n === "guard-client-key" ||
|
|
221
268
|
/^agent-keys-.*\.txt$/i.test(n) ||
|
|
222
269
|
/^atbash.*(key|keys).*\.(txt|json)$/i.test(n));
|
|
223
|
-
|
|
224
|
-
.map((n) => path.join(dir, n))
|
|
225
|
-
|
|
226
|
-
|
|
270
|
+
if (byName.length)
|
|
271
|
+
return newestFirst(byName.map((n) => path.join(dir, n)));
|
|
272
|
+
const byContent = [];
|
|
273
|
+
let examined = 0;
|
|
274
|
+
for (const name of names) {
|
|
275
|
+
if (examined >= MAX_SNIFF_FILES)
|
|
276
|
+
break;
|
|
277
|
+
const file = path.join(dir, name);
|
|
227
278
|
try {
|
|
228
|
-
|
|
279
|
+
const stat = fs.statSync(file);
|
|
280
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_SNIFF_BYTES)
|
|
281
|
+
continue;
|
|
229
282
|
}
|
|
230
|
-
catch {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
283
|
+
catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
examined++;
|
|
287
|
+
const text = readTextFile(file);
|
|
288
|
+
if (text !== null && parseKeyMaterial(text))
|
|
289
|
+
byContent.push(file);
|
|
290
|
+
}
|
|
291
|
+
return newestFirst(byContent);
|
|
235
292
|
}
|
|
236
293
|
/**
|
|
237
294
|
* Read a secret from the terminal without echoing it.
|
|
@@ -247,21 +304,27 @@ function keyCandidatesInDir(dir) {
|
|
|
247
304
|
async function promptForKeyOrPath() {
|
|
248
305
|
const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
|
|
249
306
|
return new Promise((resolveP) => {
|
|
250
|
-
const input = process.stdin;
|
|
251
|
-
const rl = createInterface({ input, output: process.stdout, terminal: true });
|
|
252
|
-
// Mute the echo so a pasted key does not sit on screen (or in a scrollback
|
|
253
|
-
// buffer that gets screenshotted). A pasted path is muted too; that is a
|
|
254
|
-
// small cost against leaving a private key visible.
|
|
255
|
-
const asMutable = rl;
|
|
256
307
|
const prompt = "Paste the agent's private key, or the path to its key file: ";
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
// Write the prompt ourselves, THEN suppress every subsequent write.
|
|
309
|
+
//
|
|
310
|
+
// The obvious implementation compares each write against the prompt text and
|
|
311
|
+
// lets that one through — but a pasted value containing the prompt as a
|
|
312
|
+
// substring would then be echoed to the terminal, which is exactly the
|
|
313
|
+
// failure this mute exists to prevent. Emitting the prompt up front means the
|
|
314
|
+
// suppressor never has to decide what a write IS: after this point, nothing
|
|
315
|
+
// is echoed, unconditionally.
|
|
316
|
+
process.stdout.write(prompt);
|
|
317
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
318
|
+
rl._writeToOutput = () => {
|
|
319
|
+
/* nothing typed after the prompt is ever echoed */
|
|
263
320
|
};
|
|
264
|
-
rl.question(
|
|
321
|
+
rl.question("", (answer) => {
|
|
322
|
+
rl.close();
|
|
323
|
+
// readline's own newline was suppressed along with everything else, so the
|
|
324
|
+
// next line of output would otherwise land on the prompt line.
|
|
325
|
+
process.stdout.write("\n");
|
|
326
|
+
resolveP(answer.trim());
|
|
327
|
+
});
|
|
265
328
|
});
|
|
266
329
|
}
|
|
267
330
|
/**
|
|
@@ -297,7 +360,15 @@ async function resolveKeySource(opts) {
|
|
|
297
360
|
const dir = expandHome(opts.keysDir, home);
|
|
298
361
|
const candidates = keyCandidatesInDir(dir);
|
|
299
362
|
if (!candidates.length) {
|
|
300
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
error: [
|
|
365
|
+
`No agent key found in ${dir}.`,
|
|
366
|
+
"Looked for guard-client-key / agent-keys-*.txt by name, then read the small",
|
|
367
|
+
"files there to see if any parsed as an agent key. Neither found one.",
|
|
368
|
+
"Point at the file directly with --key-file, or paste the key with no flags",
|
|
369
|
+
"at all and setup will prompt for it.",
|
|
370
|
+
].join("\n"),
|
|
371
|
+
};
|
|
301
372
|
}
|
|
302
373
|
return fromFile(candidates[0], "--keys-dir");
|
|
303
374
|
}
|
|
@@ -439,6 +510,243 @@ function mergeOpenclawConfig(config, home) {
|
|
|
439
510
|
function isRecord(v) {
|
|
440
511
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
441
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Detect the indentation a JSON file already uses, so a merge does not reformat
|
|
515
|
+
* the parts it did not touch.
|
|
516
|
+
*
|
|
517
|
+
* Without this, `JSON.stringify(obj, null, 2)` re-indents a tab-indented or
|
|
518
|
+
* 4-space config from top to bottom. The RESULT is still correct, but the diff
|
|
519
|
+
* shown for approval becomes every line in the file, which buries the two lines
|
|
520
|
+
* that actually changed — and the operator's own formatting choice is collateral
|
|
521
|
+
* damage in a file we were asked to make one addition to.
|
|
522
|
+
*
|
|
523
|
+
* Falls back to two spaces, which is what the published docs show.
|
|
524
|
+
*/
|
|
525
|
+
function detectIndent(text) {
|
|
526
|
+
if (!text)
|
|
527
|
+
return 2;
|
|
528
|
+
// First line that is indented under an opening brace/bracket tells us the unit.
|
|
529
|
+
const match = text.match(/\n([ \t]+)\S/);
|
|
530
|
+
if (!match)
|
|
531
|
+
return 2;
|
|
532
|
+
const indent = match[1];
|
|
533
|
+
return indent.startsWith("\t") ? "\t" : indent.length;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Serialize a merged config the way the file was already written: same
|
|
537
|
+
* indentation, and a trailing newline only if the original had one.
|
|
538
|
+
*/
|
|
539
|
+
function serializeLike(original, value) {
|
|
540
|
+
const body = JSON.stringify(value, null, detectIndent(original));
|
|
541
|
+
// A file that ended without a newline keeps ending without one. Trivial, but it
|
|
542
|
+
// is one more line of unexplained diff for someone reviewing the change.
|
|
543
|
+
const trailing = original === null || original.endsWith("\n") ? "\n" : "";
|
|
544
|
+
return body + trailing;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* The MCP server entry setup writes into a client's config.
|
|
548
|
+
*
|
|
549
|
+
* Note what is NOT here: an `env` block. The published `@atbash/mcp` wiring
|
|
550
|
+
* carries the agent's private key in one, because that package reads only
|
|
551
|
+
* ATBASH_AGENT_PRIVKEY. Going through `atbash mcp` instead means the launcher
|
|
552
|
+
* reads the 0600 key file and passes the key to the server in the child process
|
|
553
|
+
* environment, so this entry holds no credential and the client's config file is
|
|
554
|
+
* no more sensitive after setup runs than it was before.
|
|
555
|
+
*
|
|
556
|
+
* Deliberately NOT pinned to an exact CLI version: unlike the one-shot connector
|
|
557
|
+
* command, this entry persists in the operator's config and is re-executed every
|
|
558
|
+
* time the client starts. Pinning here would freeze their MCP server at whatever
|
|
559
|
+
* version happened to be current on the day they ran setup.
|
|
560
|
+
*/
|
|
561
|
+
const MCP_SERVER_ENTRY = { command: "npx", args: ["--yes", "@atbash/cli", "mcp"] };
|
|
562
|
+
const MCP_SERVER_NAME = "atbash";
|
|
563
|
+
/**
|
|
564
|
+
* MCP client configs present under this home directory.
|
|
565
|
+
*
|
|
566
|
+
* Paths come from the shared MCP_CONFIGS so the writer and the scanner cannot
|
|
567
|
+
* drift: a client the scan reports but setup cannot find would look like a bug in
|
|
568
|
+
* whichever of the two the operator happened to trust.
|
|
569
|
+
*/
|
|
570
|
+
function detectMcpClients(home) {
|
|
571
|
+
const out = [];
|
|
572
|
+
const seen = new Set();
|
|
573
|
+
for (const { label, segs } of atbash_targets_1.MCP_CONFIGS) {
|
|
574
|
+
if (seen.has(label))
|
|
575
|
+
continue;
|
|
576
|
+
const file = path.join(home, ...segs);
|
|
577
|
+
if (!exists(file))
|
|
578
|
+
continue;
|
|
579
|
+
seen.add(label);
|
|
580
|
+
// Read which key this file already uses rather than assuming. VS Code's
|
|
581
|
+
// mcp.json uses `servers`; writing `mcpServers` into it would be ignored.
|
|
582
|
+
const existing = readJsonLoose(file);
|
|
583
|
+
const serversKey = existing && isRecord(existing.servers) && !isRecord(existing.mcpServers) ? "servers" : "mcpServers";
|
|
584
|
+
out.push({ label, file, format: "json", serversKey });
|
|
585
|
+
}
|
|
586
|
+
// Claude Code and Codex are special-cased in the scanner too — same paths.
|
|
587
|
+
const claudeCode = path.join(home, ".claude.json");
|
|
588
|
+
if (exists(claudeCode))
|
|
589
|
+
out.push({ label: "Claude Code", file: claudeCode, format: "json", serversKey: "mcpServers" });
|
|
590
|
+
const codex = path.join(home, ".codex", "config.toml");
|
|
591
|
+
if (exists(codex))
|
|
592
|
+
out.push({ label: "Codex", file: codex, format: "toml", serversKey: "mcpServers" });
|
|
593
|
+
return out;
|
|
594
|
+
}
|
|
595
|
+
/** Tolerant read used only to sniff an existing file's shape. */
|
|
596
|
+
function readJsonLoose(file) {
|
|
597
|
+
const text = readTextFile(file);
|
|
598
|
+
if (text === null)
|
|
599
|
+
return null;
|
|
600
|
+
const value = jsonc.parse(text, [], { allowTrailingComma: true, disallowComments: false });
|
|
601
|
+
return isRecord(value) ? value : null;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Add the Atbash server to a client config's server map, in place.
|
|
605
|
+
*
|
|
606
|
+
* A merge, like the OpenClaw one: every server the operator already configured
|
|
607
|
+
* stays exactly as it is. An existing `atbash` entry is REPLACED rather than
|
|
608
|
+
* merged field-by-field — a stale `env` block carrying a private key from the old
|
|
609
|
+
* hand-written wiring is precisely what we want gone, and preserving it would
|
|
610
|
+
* defeat the point of routing through the launcher.
|
|
611
|
+
*/
|
|
612
|
+
/** The Hermes plugin, and the exact version the wiring is written against. */
|
|
613
|
+
const HERMES_PKG = "atbash-hermes-plugin";
|
|
614
|
+
const HERMES_VERSION = "0.4.5";
|
|
615
|
+
/**
|
|
616
|
+
* Find the Python interpreter that actually runs Hermes.
|
|
617
|
+
*
|
|
618
|
+
* This is the difference between installing the plugin and only appearing to.
|
|
619
|
+
* `pip install atbash-hermes-plugin` puts the package wherever the *shell's*
|
|
620
|
+
* `pip` points — commonly a system or conda Python — while Hermes typically runs
|
|
621
|
+
* from its own virtualenv. The install succeeds, prints nothing alarming, and the
|
|
622
|
+
* plugin is invisible to Hermes forever. Nobody can debug that from the output.
|
|
623
|
+
*
|
|
624
|
+
* The launcher knows the answer. A pip-installed console script begins with a
|
|
625
|
+
* shebang naming the interpreter that created it:
|
|
626
|
+
*
|
|
627
|
+
* $ head -1 $(command -v hermes)
|
|
628
|
+
* #!/Users/me/.hermes/hermes-agent/venv/bin/python3
|
|
629
|
+
*
|
|
630
|
+
* So resolve `hermes`, read its first line, and use that interpreter directly via
|
|
631
|
+
* `-m pip`. Falls back to the conventional venv location under ~/.hermes, then to
|
|
632
|
+
* null — and a null becomes a printed command rather than a guess, because a
|
|
633
|
+
* wrong guess here is the silent failure this whole function exists to avoid.
|
|
634
|
+
*/
|
|
635
|
+
function findHermesPython(home) {
|
|
636
|
+
const viable = (candidate) => {
|
|
637
|
+
try {
|
|
638
|
+
return fs.statSync(candidate).isFile();
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
// 1. The launcher's own shebang — authoritative, but only for a real run.
|
|
645
|
+
//
|
|
646
|
+
// `--home <dir>` exists so a dry run can be hermetic (the release checklist
|
|
647
|
+
// depends on it). A PATH lookup ignores it entirely: under `--home /tmp/fake`
|
|
648
|
+
// this would find the operator's ACTUAL hermes and plan an install into their
|
|
649
|
+
// real virtualenv. So the shebang route is skipped whenever `home` is not the
|
|
650
|
+
// machine's own home — the caller then falls through to the venv path under the
|
|
651
|
+
// given home, which is correctly scoped.
|
|
652
|
+
const realHome = process.env.HOME || os.homedir();
|
|
653
|
+
const scoped = path.resolve(home) !== path.resolve(realHome);
|
|
654
|
+
const which = scoped
|
|
655
|
+
? { status: 1, stdout: "" }
|
|
656
|
+
: (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["hermes"], { encoding: "utf8" });
|
|
657
|
+
const launcher = which.status === 0 ? which.stdout.split(/\r?\n/)[0]?.trim() : "";
|
|
658
|
+
if (launcher && viable(launcher)) {
|
|
659
|
+
const firstLine = (readTextFile(launcher) ?? "").split(/\r?\n/)[0] ?? "";
|
|
660
|
+
const shebang = firstLine.startsWith("#!") ? firstLine.slice(2).trim() : "";
|
|
661
|
+
// `#!/usr/bin/env python3` names no path; anything else should be absolute.
|
|
662
|
+
const interpreter = shebang.split(/\s+/).filter((part) => !part.endsWith("/env"))[0] ?? "";
|
|
663
|
+
if (/python[0-9.]*$/.test(interpreter) && viable(interpreter)) {
|
|
664
|
+
return { python: interpreter, how: `shebang of ${launcher}` };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// 2. The conventional venv Hermes ships with.
|
|
668
|
+
for (const name of ["python3", "python"]) {
|
|
669
|
+
const candidate = path.join(home, ".hermes", "hermes-agent", "venv", "bin", name);
|
|
670
|
+
if (viable(candidate))
|
|
671
|
+
return { python: candidate, how: "Hermes virtualenv under ~/.hermes" };
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* The env vars the Hermes plugin documents, merged into an existing `.env`.
|
|
677
|
+
*
|
|
678
|
+
* A `.env` is line-oriented and hand-maintained, so this is a line merge rather
|
|
679
|
+
* than a parse-and-reserialize: keys Atbash owns are replaced in place (keeping
|
|
680
|
+
* their position), keys it does not own are never touched, and anything else in
|
|
681
|
+
* the file — comments, blank lines, unrelated settings, ordering — survives
|
|
682
|
+
* exactly as written. Reformatting someone's .env to add four lines would be a
|
|
683
|
+
* poor trade.
|
|
684
|
+
*
|
|
685
|
+
* Values are from the published plugin README (PyPI atbash-hermes-plugin 0.4.5).
|
|
686
|
+
* `ATBASH_ORG_NAME` is deliberately NOT written: its value is the operator's org,
|
|
687
|
+
* which this command has no reliable way to know, and a wrong org sends the SDK
|
|
688
|
+
* at the wrong chain. It is called out in the manual step instead.
|
|
689
|
+
*/
|
|
690
|
+
function mergeHermesEnv(existing) {
|
|
691
|
+
const desired = {
|
|
692
|
+
ATBASH_KEY_PATH: "$HOME/.config/atbash/guard-client-key",
|
|
693
|
+
ATBASH_ENFORCE_DECISION: "true",
|
|
694
|
+
};
|
|
695
|
+
const lines = existing === null ? [] : existing.split("\n");
|
|
696
|
+
const seen = new Set();
|
|
697
|
+
const out = lines.map((line) => {
|
|
698
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/);
|
|
699
|
+
const key = match?.[1];
|
|
700
|
+
if (!key || !(key in desired) || seen.has(key))
|
|
701
|
+
return line;
|
|
702
|
+
seen.add(key);
|
|
703
|
+
// Already correct — keep the operator's own formatting rather than rewriting.
|
|
704
|
+
if (line.trim() === `${key}=${desired[key]}`)
|
|
705
|
+
return line;
|
|
706
|
+
return `${key}=${desired[key]}`;
|
|
707
|
+
});
|
|
708
|
+
const missing = Object.entries(desired).filter(([key]) => !seen.has(key));
|
|
709
|
+
if (missing.length) {
|
|
710
|
+
// Separate the block we add from whatever came before it.
|
|
711
|
+
if (out.length && out[out.length - 1].trim() !== "")
|
|
712
|
+
out.push("");
|
|
713
|
+
// ASCII-only comment on purpose: .env files are read by many different
|
|
714
|
+
// parsers and a stray multi-byte dash is a free way to trip a strict one.
|
|
715
|
+
if (existing !== null)
|
|
716
|
+
out.push("# Added by `atbash setup` - Atbash Hermes plugin");
|
|
717
|
+
for (const [key, value] of missing)
|
|
718
|
+
out.push(`${key}=${value}`);
|
|
719
|
+
}
|
|
720
|
+
let text = out.join("\n");
|
|
721
|
+
if (!text.endsWith("\n"))
|
|
722
|
+
text += "\n";
|
|
723
|
+
return text;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Does this config's existing Atbash entry carry a key in its `env` block?
|
|
727
|
+
*
|
|
728
|
+
* True means the operator hand-wired it from the published docs and their private
|
|
729
|
+
* key is sitting in that file today. Setup takes it out, but the backup it writes
|
|
730
|
+
* first still has it — so this exists to make that sayable rather than silently
|
|
731
|
+
* relocating the leak.
|
|
732
|
+
*/
|
|
733
|
+
function hadInlineKey(config, serversKey = "mcpServers") {
|
|
734
|
+
const servers = isRecord(config[serversKey]) ? config[serversKey] : undefined;
|
|
735
|
+
const entry = servers && isRecord(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : undefined;
|
|
736
|
+
const env = entry && isRecord(entry.env) ? entry.env : undefined;
|
|
737
|
+
if (!env)
|
|
738
|
+
return false;
|
|
739
|
+
// Any 64-hex value, under any key name — not just the documented one, since a
|
|
740
|
+
// hand-edited config may well have renamed it.
|
|
741
|
+
return Object.values(env).some((v) => typeof v === "string" && /^(0x)?[0-9a-fA-F]{64}$/.test(v.trim()));
|
|
742
|
+
}
|
|
743
|
+
function mergeMcpServer(config, serversKey = "mcpServers") {
|
|
744
|
+
const out = { ...config };
|
|
745
|
+
const servers = { ...(isRecord(out[serversKey]) ? out[serversKey] : {}) };
|
|
746
|
+
servers[MCP_SERVER_NAME] = { ...MCP_SERVER_ENTRY, args: [...MCP_SERVER_ENTRY.args] };
|
|
747
|
+
out[serversKey] = servers;
|
|
748
|
+
return out;
|
|
749
|
+
}
|
|
442
750
|
/** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
|
|
443
751
|
function hasExecutable(command) {
|
|
444
752
|
const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
|
|
@@ -522,7 +830,7 @@ function buildPlan(args) {
|
|
|
522
830
|
notes.push(`${openclawConfigFile} is not valid JSON — it will be backed up and rewritten from scratch, which loses whatever was in it. Fix the file first if it holds configuration you need.`);
|
|
523
831
|
}
|
|
524
832
|
}
|
|
525
|
-
const after =
|
|
833
|
+
const after = serializeLike(raw, mergeOpenclawConfig(current, home));
|
|
526
834
|
if (raw !== after) {
|
|
527
835
|
steps.push({
|
|
528
836
|
kind: "write",
|
|
@@ -547,47 +855,135 @@ function buildPlan(args) {
|
|
|
547
855
|
// judged. Setup places the key file and says so; it does not pretend to wire it.
|
|
548
856
|
if (exists(home, ...HERMES_AGENT_REL)) {
|
|
549
857
|
found.push("Hermes");
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
858
|
+
if (wanted("hermes")) {
|
|
859
|
+
const envFile = path.join(home, ".hermes", ".env");
|
|
860
|
+
const raw = readTextFile(envFile);
|
|
861
|
+
const merged = mergeHermesEnv(raw);
|
|
862
|
+
if (merged !== raw) {
|
|
863
|
+
steps.push({
|
|
864
|
+
kind: "write",
|
|
865
|
+
label: raw === null
|
|
866
|
+
? "Create ~/.hermes/.env pointing the Hermes plugin at the agent key"
|
|
867
|
+
: "Point the Hermes plugin at the agent key in ~/.hermes/.env (a merge — your other settings are kept)",
|
|
868
|
+
file: envFile,
|
|
869
|
+
before: raw,
|
|
870
|
+
after: merged,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
notes.push(`${envFile} already points the Hermes plugin at this key — left untouched.`);
|
|
875
|
+
}
|
|
876
|
+
// The Python package must land in the interpreter that RUNS Hermes, not
|
|
877
|
+
// whichever pip the shell happens to resolve. When we can identify that
|
|
878
|
+
// interpreter we install into it directly; when we cannot, we hand the
|
|
879
|
+
// command over rather than guess, because guessing wrong installs
|
|
880
|
+
// successfully and governs nothing.
|
|
881
|
+
if (!noInstall) {
|
|
882
|
+
const hermesPython = findHermesPython(home);
|
|
883
|
+
if (hermesPython) {
|
|
884
|
+
steps.push({
|
|
885
|
+
kind: "exec",
|
|
886
|
+
label: `Install ${HERMES_PKG} into the interpreter that runs Hermes (found via ${hermesPython.how})`,
|
|
887
|
+
command: hermesPython.python,
|
|
888
|
+
args: ["-m", "pip", "install", `${HERMES_PKG}==${HERMES_VERSION}`],
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
steps.push({
|
|
893
|
+
kind: "manual",
|
|
894
|
+
label: "Install the Hermes plugin",
|
|
895
|
+
detail: [
|
|
896
|
+
"The `hermes` launcher is not on this machine's PATH, so setup cannot tell which Python interpreter runs Hermes — and installing into the wrong one succeeds while governing nothing.",
|
|
897
|
+
"",
|
|
898
|
+
"Run this with the interpreter Hermes uses (if it runs in a virtualenv, that venv's python):",
|
|
899
|
+
].join("\n"),
|
|
900
|
+
snippet: `/path/to/hermes/venv/bin/python -m pip install ${HERMES_PKG}==${HERMES_VERSION}`,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
notes.push("Restart Hermes — it reads .env and discovers plugins at startup. Then confirm with `hermes plugins list | grep atbash`.");
|
|
905
|
+
notes.push("ATBASH_ENFORCE_DECISION=true is fail-closed: if Atbash cannot be reached, the Hermes tool call is blocked rather than allowed.");
|
|
906
|
+
notes.push("ATBASH_ORG_NAME is not set for you — it decides which chain the SDK uses, and a wrong value points at the wrong one. Add it to ~/.hermes/.env yourself if your org needs it.");
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// ── 4. MCP clients.
|
|
910
|
+
//
|
|
911
|
+
// This used to be a manual step, and the reason was specific: `@atbash/mcp`
|
|
912
|
+
// reads its identity from ATBASH_AGENT_PRIVKEY with no key-file fallback, so
|
|
913
|
+
// the documented wiring puts a raw private key inside the client's own config —
|
|
914
|
+
// `claude_desktop_config.json` and friends, files that get synced between
|
|
915
|
+
// machines and pasted into help requests. Automating that would have meant the
|
|
916
|
+
// automation's whole job was planting a secret somewhere worse.
|
|
917
|
+
//
|
|
918
|
+
// `atbash mcp` removes the reason. The client spawns the launcher, which reads
|
|
919
|
+
// the key from the 0600 file and hands it to the server through the child
|
|
920
|
+
// environment only. The config entry carries NO credential, so it is safe to
|
|
921
|
+
// write — and a config with no secret in it is strictly better than the one the
|
|
922
|
+
// operator would have hand-written from the docs.
|
|
923
|
+
if (wanted("mcp")) {
|
|
924
|
+
for (const client of detectMcpClients(home)) {
|
|
925
|
+
found.push(client.label);
|
|
926
|
+
if (client.format !== "json") {
|
|
927
|
+
// TOML (Codex) — @iarna/toml can round-trip values but not comments, and
|
|
928
|
+
// a config.toml is usually hand-maintained. Print it instead.
|
|
929
|
+
steps.push({
|
|
930
|
+
kind: "manual",
|
|
931
|
+
label: `Add Atbash to ${client.label}`,
|
|
932
|
+
detail: `${client.file} is TOML, and rewriting it would drop any comments in it. Add this table by hand:`,
|
|
933
|
+
snippet: ["[mcp_servers.atbash]", 'command = "npx"', 'args = ["--yes", "@atbash/cli", "mcp"]'].join("\n"),
|
|
934
|
+
});
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const raw = readTextFile(client.file);
|
|
938
|
+
if (raw !== null && isJsonc(raw)) {
|
|
939
|
+
steps.push({
|
|
940
|
+
kind: "manual",
|
|
941
|
+
label: `Add Atbash to ${client.label}`,
|
|
942
|
+
detail: `${client.file} uses comments or trailing commas, and rewriting it as strict JSON would delete them. Merge this in by hand — note it holds no key, so the file stays as non-secret as it is today:`,
|
|
943
|
+
snippet: JSON.stringify({ mcpServers: { atbash: MCP_SERVER_ENTRY } }, null, 2),
|
|
944
|
+
});
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
let current = {};
|
|
948
|
+
if (raw !== null) {
|
|
949
|
+
try {
|
|
950
|
+
const parsed = JSON.parse(raw);
|
|
951
|
+
if (isRecord(parsed))
|
|
952
|
+
current = parsed;
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
notes.push(`${client.file} is not valid JSON, so it was left alone. Fix the file and re-run to wire ${client.label}.`);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const after = serializeLike(raw, mergeMcpServer(current, client.serversKey));
|
|
960
|
+
if (raw !== after) {
|
|
961
|
+
// A hand-wired entry from the old documented shape carries the private key
|
|
962
|
+
// in an `env` block. Replacing it REMOVES that secret from the live config
|
|
963
|
+
// — good — but the backup we are about to take still contains it, and an
|
|
964
|
+
// operator who does not know that has simply moved the leak to a new file.
|
|
965
|
+
if (hadInlineKey(current, client.serversKey)) {
|
|
966
|
+
notes.push(`${client.file} currently holds your private key in an env block. Setup replaces that entry with the keyless launcher, but the .atbash-bak it leaves behind WILL still contain the key — delete that backup once you have confirmed the client works.`);
|
|
967
|
+
}
|
|
968
|
+
steps.push({
|
|
969
|
+
kind: "write",
|
|
970
|
+
label: `Add Atbash as an MCP server in ${client.label} (a merge — existing servers are kept${hadInlineKey(current, client.serversKey) ? ", and your key is removed from this file" : ""})`,
|
|
971
|
+
file: client.file,
|
|
972
|
+
before: raw,
|
|
973
|
+
after,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
notes.push(`${client.label} already has the Atbash MCP server — left untouched.`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (found.some((f) => f !== "OpenClaw" && f !== "Hermes")) {
|
|
981
|
+
notes.push("Restart any MCP client that was changed — clients read their server list at startup.");
|
|
982
|
+
// A client whose first launch of the server takes ~12s can report a startup
|
|
983
|
+
// timeout that looks like a broken config. Say so, so the first thing an
|
|
984
|
+
// operator does is retry rather than undo the wiring.
|
|
985
|
+
notes.push("The first time a client starts the Atbash server it takes ~10-15s while npx caches the package; after that it is about a second. If a client reports a startup timeout on the very first try, start it again.");
|
|
986
|
+
}
|
|
591
987
|
}
|
|
592
988
|
if (!found.length) {
|
|
593
989
|
notes.push("No OpenClaw, Hermes or MCP client configuration was found under this home directory. The key file is still placed, so an SDK-level integration in your own code will find it — but nothing on this machine is wired to a runtime.");
|
|
@@ -771,10 +1167,27 @@ function applyPlan(plan) {
|
|
|
771
1167
|
continue;
|
|
772
1168
|
const label = `${step.command} ${step.args.join(" ")}`;
|
|
773
1169
|
const run = (0, child_process_1.spawnSync)(step.command, step.args, { stdio: "inherit" });
|
|
774
|
-
|
|
1170
|
+
// Three distinct outcomes, and they used to collapse into one misleading
|
|
1171
|
+
// message. `spawnSync` reports a binary it could not launch via `.error` with
|
|
1172
|
+
// `status` left null — so an ENOENT printed "exited on a signal", which reads
|
|
1173
|
+
// like the plugin installer crashed rather than "that command is not here".
|
|
1174
|
+
// The distinction matters because only one of them is the operator's to fix,
|
|
1175
|
+
// and the fix is to run it somewhere the CLI exists.
|
|
1176
|
+
if (run.error) {
|
|
1177
|
+
const missing = run.error.code === "ENOENT";
|
|
1178
|
+
result.failures.push(missing
|
|
1179
|
+
? `${step.command} is not on this machine's PATH, so \`${label}\` did not run. Everything else above was applied — run that one command wherever the ${step.command} CLI lives.`
|
|
1180
|
+
: `${label} could not start: ${run.error.message}`);
|
|
1181
|
+
}
|
|
1182
|
+
else if (run.status === 0) {
|
|
775
1183
|
result.ran.push(label);
|
|
776
|
-
|
|
777
|
-
|
|
1184
|
+
}
|
|
1185
|
+
else if (run.signal) {
|
|
1186
|
+
result.failures.push(`${label} was killed by ${run.signal}`);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
result.failures.push(`${label} exited with code ${run.status}`);
|
|
1190
|
+
}
|
|
778
1191
|
}
|
|
779
1192
|
return result;
|
|
780
1193
|
}
|
|
@@ -852,7 +1265,7 @@ function registerSetupCommand(program) {
|
|
|
852
1265
|
console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
|
|
853
1266
|
// ── Registration check. Only the public key crosses the network.
|
|
854
1267
|
if (!opts.skipVerify) {
|
|
855
|
-
const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") ||
|
|
1268
|
+
const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
856
1269
|
let hostname = "";
|
|
857
1270
|
try {
|
|
858
1271
|
hostname = new URL(endpoint).hostname.toLowerCase();
|
|
@@ -864,7 +1277,8 @@ function registerSetupCommand(program) {
|
|
|
864
1277
|
// An unrecognized host could answer "registered" for any key, which is
|
|
865
1278
|
// exactly the confirmation this check exists to provide. Exact hostname
|
|
866
1279
|
// match, never a suffix — "atbash.ai.evil.com" must not pass.
|
|
867
|
-
|
|
1280
|
+
const recognizedHost = atbash_targets_1.KNOWN_HOSTS.has(hostname);
|
|
1281
|
+
if (!recognizedHost && !opts.allowUnrecognizedHost) {
|
|
868
1282
|
console.error(chalk_1.default.red(`\n ${hostname} is not a recognized Atbash deployment.`) +
|
|
869
1283
|
chalk_1.default.dim("\n Re-run with --allow-unrecognized-host if you meant to point at a self-hosted instance,\n or with --skip-verify to configure this machine without any network call.\n"));
|
|
870
1284
|
process.exit(1);
|
|
@@ -883,9 +1297,22 @@ function registerSetupCommand(program) {
|
|
|
883
1297
|
return;
|
|
884
1298
|
}
|
|
885
1299
|
}
|
|
886
|
-
else {
|
|
1300
|
+
else if (recognizedHost) {
|
|
887
1301
|
console.log(chalk_1.default.green(` Agent is registered on ${hostname}.`));
|
|
888
1302
|
}
|
|
1303
|
+
else {
|
|
1304
|
+
// --allow-unrecognized-host is a real bypass, and its most dangerous
|
|
1305
|
+
// property is that the check still PRINTS a reassuring answer. A host
|
|
1306
|
+
// chosen by an attacker returns "registered" for any key at all, so a
|
|
1307
|
+
// "✓ registered" line here would be the attacker's own claim wearing
|
|
1308
|
+
// Atbash's voice. Never let that line stand unqualified: say the answer
|
|
1309
|
+
// came from an unvouched-for server, so a talked-into-it operator sees
|
|
1310
|
+
// the one thing that would tell them something is wrong.
|
|
1311
|
+
console.log(chalk_1.default.yellow(` ${hostname} answered "registered" — but this is NOT a recognized Atbash deployment.`));
|
|
1312
|
+
console.log(chalk_1.default.yellow(" A registration check against an unrecognized host proves nothing: any server") +
|
|
1313
|
+
chalk_1.default.yellow("\n can answer \"registered\" for any key. Treat this as UNVERIFIED."));
|
|
1314
|
+
console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
|
|
1315
|
+
}
|
|
889
1316
|
}
|
|
890
1317
|
// ── Plan, show, then (maybe) apply.
|
|
891
1318
|
const plan = buildPlan({
|