@atbash/cli 0.5.15-dev.0 → 0.5.15-dev.10

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.
@@ -43,6 +43,18 @@ 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.wantedRuntime = wantedRuntime;
51
+ exports.mergeHermesEnabledPlugins = mergeHermesEnabledPlugins;
52
+ exports.hermesGatewayRunning = hermesGatewayRunning;
53
+ exports.hermesPluginState = hermesPluginState;
54
+ exports.pythonInstallStrategy = pythonInstallStrategy;
55
+ exports.mergeHermesEnv = mergeHermesEnv;
56
+ exports.hadInlineKey = hadInlineKey;
57
+ exports.mergeMcpServer = mergeMcpServer;
46
58
  exports.buildPlan = buildPlan;
47
59
  exports.lineDiff = lineDiff;
48
60
  exports.renderPlan = renderPlan;
@@ -55,8 +67,9 @@ const path = __importStar(require("path"));
55
67
  const child_process_1 = require("child_process");
56
68
  const chalk_1 = __importDefault(require("chalk"));
57
69
  const jsonc = __importStar(require("jsonc-parser"));
70
+ const yaml_1 = require("yaml");
58
71
  const sdk_1 = require("@atbash/sdk");
59
- const connect_1 = require("./connect");
72
+ const atbash_targets_1 = require("../shared/atbash-targets");
60
73
  /**
61
74
  * `atbash setup` — the write half of onboarding.
62
75
  *
@@ -84,9 +97,11 @@ const connect_1 = require("./connect");
84
97
  * 2. It never writes the private key into an MCP client config. The documented
85
98
  * `@atbash/mcp` wiring passes the key as an `ATBASH_AGENT_PRIVKEY` env value
86
99
  * inside e.g. `claude_desktop_config.json` — a file people screenshot, sync
87
- * and share. That package has no key-file fallback today (verified against
88
- * the published 0.1.3), so MCP clients are REPORTED with the snippet to add
89
- * by hand rather than silently seeded with a secret.
100
+ * and share, and that package has no key-file fallback (verified against the
101
+ * published 0.1.3). So the entry setup writes points at `atbash mcp`, which
102
+ * reads the 0600 key file and passes the key to the server through the child
103
+ * environment. The config file itself gets NO credential — and an entry that
104
+ * was hand-wired with one has it removed.
90
105
  * 3. It never edits application source. Code-level integrations (LangChain,
91
106
  * LangGraph, AutoGen, Eliza, the SDK boundary) are the owner's to write.
92
107
  * 4. It never rewrites a config file that uses comments or trailing commas.
@@ -118,8 +133,51 @@ const HERMES_AGENT_REL = [".hermes", "hermes-agent"];
118
133
  * is already governed and must not be given a second, duplicate entry.
119
134
  */
120
135
  const OPENCLAW_PKG = "@atbash/atbash-openclaw";
121
- const OPENCLAW_ENTRY = "openclaw";
136
+ /**
137
+ * The entry key `@atbash/atbash-openclaw` registers as.
138
+ *
139
+ * ⚠️ It is `atbash-openclaw`, NOT `openclaw`. `openclaw` is the id of the RETIRED
140
+ * `@atbash-plugin/openclaw` package, and the published docs are explicit about
141
+ * what happens if you use it: "the plugin id here is `atbash-openclaw`, so a
142
+ * leftover `openclaw` entry silently configures nothing." Earlier releases of
143
+ * this command wrote `openclaw`, so every config they produced was inert — no
144
+ * error, no warning, just a plugin reading a config block nobody looks at.
145
+ */
146
+ const OPENCLAW_ENTRY = "atbash-openclaw";
147
+ /** Installs of the earlier `@atbash/atbash-plugin` register under this. */
122
148
  const OPENCLAW_LEGACY_ENTRY = "atbash-plugin";
149
+ /** The retired `@atbash-plugin/openclaw` id — inert if left in a config. */
150
+ const OPENCLAW_RETIRED_ENTRY = "openclaw";
151
+ /**
152
+ * The npm tag to install, chosen from the deployment the agent was onboarded on.
153
+ *
154
+ * The judge endpoint and chain ids are NOT configurable at runtime — they come
155
+ * from the SDK the plugin bundles, so the tag is an environment choice. Verified
156
+ * on the registry: the two tarballs' plugin code is byte-identical and only
157
+ * `package.json` differs —
158
+ *
159
+ * 0.1.14 → @atbash/sdk@0.7.0 → https://atbash.ai BRID 0163241D…
160
+ * 0.1.14-dev.0 → @atbash/sdk@0.10.5-dev.0 → …dev-two.vercel.app BRID 02668C52…
161
+ *
162
+ * Install the wrong one and it loads cleanly, fires its hook, and fails every
163
+ * judge call because the agent does not exist on the chain that build targets.
164
+ * The docs warn that a resolving org name is not proof the build is right, since
165
+ * org names are not unique across environments — so this is derived from --host
166
+ * and nothing else.
167
+ */
168
+ function openclawPackageForHost(endpoint) {
169
+ if (!endpoint)
170
+ return OPENCLAW_PKG; // no host given: production is the safe default
171
+ let hostname = "";
172
+ try {
173
+ hostname = new URL(endpoint).hostname.toLowerCase();
174
+ }
175
+ catch {
176
+ return OPENCLAW_PKG;
177
+ }
178
+ const isProduction = hostname === "atbash.ai" || hostname === "www.atbash.ai";
179
+ return isProduction ? OPENCLAW_PKG : `${OPENCLAW_PKG}@dev`;
180
+ }
123
181
  /** File modes: 0700 for the key directory, 0600 for the key itself. */
124
182
  const DIR_MODE = 0o700;
125
183
  const KEY_MODE = 0o600;
@@ -208,7 +266,45 @@ function normalizePrivkey(raw) {
208
266
  const clean = raw.replace(/^0x/i, "").trim().toLowerCase();
209
267
  return (0, sdk_1.isValidPrivateKey)(clean) ? clean : "";
210
268
  }
211
- /** Files in a directory that plausibly hold an Atbash agent key, newest first. */
269
+ /** Only sniff the contents of small files a key file is a few hundred bytes. */
270
+ const MAX_SNIFF_BYTES = 8 * 1024;
271
+ /** Bound the content-sniff so `--keys-dir ~` cannot turn into a directory crawl. */
272
+ const MAX_SNIFF_FILES = 60;
273
+ /** Newest-first, so a freshly downloaded key wins over one from last month. */
274
+ function newestFirst(files) {
275
+ return files
276
+ .map((file) => {
277
+ let mtime = 0;
278
+ try {
279
+ mtime = fs.statSync(file).mtimeMs;
280
+ }
281
+ catch { /* unreadable — sorts last */ }
282
+ return { file, mtime };
283
+ })
284
+ .sort((a, b) => b.mtime - a.mtime)
285
+ .map((e) => e.file);
286
+ }
287
+ /**
288
+ * Files in a directory that plausibly hold an Atbash agent key, newest first.
289
+ *
290
+ * TWO PASSES, and the second one is the point.
291
+ *
292
+ * By name first — `guard-client-key`, `agent-keys-*.txt` and friends — because
293
+ * matching the name is cheap and unambiguous. But a name-only match is a cliff:
294
+ * rename the download, or export from a wallet UI that picks its own filename,
295
+ * and the operator gets "no key file found" while the key sits right there in the
296
+ * directory they explicitly pointed at.
297
+ *
298
+ * So if no name matches, read the small files and keep the ones that actually
299
+ * PARSE as key material. That is a narrow test — `privkey=`, the documented JSON
300
+ * shape, or a file that is nothing but a 64-hex key — not "contains something
301
+ * hex-looking", so an unrelated file does not get mistaken for an identity.
302
+ *
303
+ * Reading files the operator did not name individually is justified by the flag
304
+ * itself: `--keys-dir` is an explicit instruction to look in that directory. It
305
+ * is bounded to small regular files and a file count, nothing is transmitted, and
306
+ * the caller prints WHICH file it used before doing anything with it.
307
+ */
212
308
  function keyCandidatesInDir(dir) {
213
309
  let names;
214
310
  try {
@@ -217,21 +313,31 @@ function keyCandidatesInDir(dir) {
217
313
  catch {
218
314
  return [];
219
315
  }
220
- const matches = names.filter((n) => n === "guard-client-key" ||
316
+ const byName = names.filter((n) => n === "guard-client-key" ||
221
317
  /^agent-keys-.*\.txt$/i.test(n) ||
222
318
  /^atbash.*(key|keys).*\.(txt|json)$/i.test(n));
223
- return matches
224
- .map((n) => path.join(dir, n))
225
- .map((file) => {
226
- let mtime = 0;
319
+ if (byName.length)
320
+ return newestFirst(byName.map((n) => path.join(dir, n)));
321
+ const byContent = [];
322
+ let examined = 0;
323
+ for (const name of names) {
324
+ if (examined >= MAX_SNIFF_FILES)
325
+ break;
326
+ const file = path.join(dir, name);
227
327
  try {
228
- mtime = fs.statSync(file).mtimeMs;
328
+ const stat = fs.statSync(file);
329
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_SNIFF_BYTES)
330
+ continue;
229
331
  }
230
- catch { /* unreadable — sorts last */ }
231
- return { file, mtime };
232
- })
233
- .sort((a, b) => b.mtime - a.mtime)
234
- .map((e) => e.file);
332
+ catch {
333
+ continue;
334
+ }
335
+ examined++;
336
+ const text = readTextFile(file);
337
+ if (text !== null && parseKeyMaterial(text))
338
+ byContent.push(file);
339
+ }
340
+ return newestFirst(byContent);
235
341
  }
236
342
  /**
237
343
  * Read a secret from the terminal without echoing it.
@@ -247,23 +353,56 @@ function keyCandidatesInDir(dir) {
247
353
  async function promptForKeyOrPath() {
248
354
  const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
249
355
  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
356
  const prompt = "Paste the agent's private key, or the path to its key file: ";
257
- asMutable._writeToOutput = function (s) {
258
- if (s.includes(prompt))
259
- asMutable.output?.write(s);
260
- else if (s === "\r\n" || s === "\n")
261
- asMutable.output?.write(s);
262
- // every other keystroke echo is dropped
357
+ // Write the prompt ourselves, THEN suppress every subsequent write.
358
+ //
359
+ // The obvious implementation compares each write against the prompt text and
360
+ // lets that one through but a pasted value containing the prompt as a
361
+ // substring would then be echoed to the terminal, which is exactly the
362
+ // failure this mute exists to prevent. Emitting the prompt up front means the
363
+ // suppressor never has to decide what a write IS: after this point, nothing
364
+ // is echoed, unconditionally.
365
+ process.stdout.write(prompt);
366
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
367
+ rl._writeToOutput = () => {
368
+ /* nothing typed after the prompt is ever echoed */
263
369
  };
264
- rl.question(prompt, (answer) => { rl.close(); resolveP(answer.trim()); });
370
+ rl.question("", (answer) => {
371
+ rl.close();
372
+ // readline's own newline was suppressed along with everything else, so the
373
+ // next line of output would otherwise land on the prompt line.
374
+ process.stdout.write("\n");
375
+ resolveP(answer.trim());
376
+ });
265
377
  });
266
378
  }
379
+ /**
380
+ * Interpret whatever someone typed at the prompt: a raw key, a file, or a
381
+ * directory to search. Shared by the "no key found" prompt and the "use the
382
+ * existing key?" prompt so both accept the same things — an operator who can
383
+ * paste a path in one place should not find it rejected in the other.
384
+ */
385
+ async function keyFromAnswer(answer, home) {
386
+ const direct = parseKeyMaterial(answer);
387
+ if (direct)
388
+ return { material: direct, from: "key entered at the prompt" };
389
+ const asPath = expandHome(answer, home);
390
+ if (!exists(asPath))
391
+ return null;
392
+ let isDir = false;
393
+ try {
394
+ isDir = fs.statSync(asPath).isDirectory();
395
+ }
396
+ catch {
397
+ return null;
398
+ }
399
+ const file = isDir ? keyCandidatesInDir(asPath)[0] : asPath;
400
+ if (!file)
401
+ return null;
402
+ const text = readTextFile(file);
403
+ const material = text === null ? null : parseKeyMaterial(text);
404
+ return material ? { material, from: `${file} (given at the prompt)` } : null;
405
+ }
267
406
  /**
268
407
  * Find the agent key, trying every way an owner could plausibly have it.
269
408
  *
@@ -297,7 +436,15 @@ async function resolveKeySource(opts) {
297
436
  const dir = expandHome(opts.keysDir, home);
298
437
  const candidates = keyCandidatesInDir(dir);
299
438
  if (!candidates.length) {
300
- return { error: `No agent key file found in ${dir} (looked for guard-client-key and agent-keys-*.txt).` };
439
+ return {
440
+ error: [
441
+ `No agent key found in ${dir}.`,
442
+ "Looked for guard-client-key / agent-keys-*.txt by name, then read the small",
443
+ "files there to see if any parsed as an agent key. Neither found one.",
444
+ "Point at the file directly with --key-file, or paste the key with no flags",
445
+ "at all and setup will prompt for it.",
446
+ ].join("\n"),
447
+ };
301
448
  }
302
449
  return fromFile(candidates[0], "--keys-dir");
303
450
  }
@@ -309,11 +456,39 @@ async function resolveKeySource(opts) {
309
456
  return { material, from: "ATBASH_AGENT_KEY" };
310
457
  }
311
458
  // 5. Already in the canonical place — a re-run, or a machine set up before.
459
+ //
460
+ // This used to be taken SILENTLY, and that was a trap. A machine that has ever
461
+ // governed one agent already has a key here, so onboarding a SECOND agent
462
+ // picked up the first one's key and then failed registration against a pubkey
463
+ // the operator never chose:
464
+ //
465
+ // Agent key source: existing key file (~/.config/atbash/guard-client-key)
466
+ // That agent is not registered on this deployment.
467
+ //
468
+ // which reads as "onboarding is broken" rather than "I used a different key
469
+ // than you meant". So when there is someone to ask, ask — and make the existing
470
+ // key the easy answer for the common case (a genuine re-run) without making it
471
+ // the only answer.
312
472
  const keyFile = path.join(home, ...KEY_FILE_REL);
313
473
  if (exists(keyFile)) {
314
474
  const found = fromFile(keyFile, "existing key file");
315
- if (!("error" in found))
316
- return found;
475
+ if (!("error" in found)) {
476
+ if (!opts.allowPrompt)
477
+ return found; // non-interactive: same as before
478
+ const existingPub = (0, sdk_1.derivePublicKey)(found.material.privkey);
479
+ process.stdout.write(`\n This machine already has an agent key at ${keyFile}\n` +
480
+ ` Public key: ${existingPub}\n`);
481
+ const useExisting = await confirm(" Use that key? [Y/n] ", true);
482
+ if (useExisting)
483
+ return found;
484
+ const answer = await promptForKeyOrPath();
485
+ if (!answer)
486
+ return { error: "No key provided." };
487
+ const supplied = await keyFromAnswer(answer, home);
488
+ if (supplied)
489
+ return supplied;
490
+ return { error: "That is neither a 64-hex private key nor a path that exists." };
491
+ }
317
492
  }
318
493
  // 6. The CLI's own config, populated by `atbash set agent-key`.
319
494
  const fromConfig = (0, sdk_1.resolve)("agentKey");
@@ -327,20 +502,9 @@ async function resolveKeySource(opts) {
327
502
  const answer = await promptForKeyOrPath();
328
503
  if (!answer)
329
504
  return { error: "No key provided." };
330
- const direct = parseKeyMaterial(answer);
331
- if (direct)
332
- return { material: direct, from: "interactive prompt" };
333
- const asPath = expandHome(answer, home);
334
- if (exists(asPath)) {
335
- const stat = fs.statSync(asPath);
336
- if (stat.isDirectory()) {
337
- const candidates = keyCandidatesInDir(asPath);
338
- if (candidates.length)
339
- return fromFile(candidates[0], "directory given at the prompt");
340
- return { error: `No agent key file found in ${asPath}.` };
341
- }
342
- return fromFile(asPath, "file given at the prompt");
343
- }
505
+ const supplied = await keyFromAnswer(answer, home);
506
+ if (supplied)
507
+ return supplied;
344
508
  return { error: "That is neither a 64-hex private key nor a path that exists." };
345
509
  }
346
510
  return {
@@ -391,54 +555,553 @@ function isJsonc(text) {
391
555
  * `<your-username>` placeholder that people paste verbatim, producing a path that
392
556
  * does not exist and a plugin that never loads.
393
557
  */
394
- function mergeOpenclawConfig(config, home) {
558
+ function mergeOpenclawConfig(config, home, orgName) {
395
559
  const out = { ...config };
396
560
  const plugins = { ...(isRecord(out.plugins) ? out.plugins : {}) };
397
- // Which key is this install governed under? Keep an existing legacy entry
398
- // where it is instead of adding a second one.
399
561
  const entries = { ...(isRecord(plugins.entries) ? plugins.entries : {}) };
400
- const entryKey = OPENCLAW_LEGACY_ENTRY in entries && !(OPENCLAW_ENTRY in entries)
401
- ? OPENCLAW_LEGACY_ENTRY
402
- : OPENCLAW_ENTRY;
562
+ // Which entry is this install governed under, and does one already exist?
563
+ const legacy = OPENCLAW_LEGACY_ENTRY in entries;
564
+ const modern = OPENCLAW_ENTRY in entries;
565
+ const entryKey = legacy && !modern ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY;
566
+ const existing = isRecord(entries[entryKey]) ? entries[entryKey] : null;
567
+ // ⚠️ AN EXISTING ENTRY IS TOUCHED AS LITTLE AS POSSIBLE.
568
+ //
569
+ // This function used to assert the full documented shape onto whatever it
570
+ // found, which broke a real machine: a legacy `atbash-plugin` install accepts
571
+ // only `config` and `enabled`, so writing the `hooks` block that the NEW
572
+ // `openclaw` entry documents made the whole file invalid —
573
+ //
574
+ // Invalid config at ~/.openclaw/openclaw.json:
575
+ // - plugins.entries.atbash-plugin: Unrecognized key: "hooks"
576
+ //
577
+ // and OpenClaw then refused to load any config at all. It also added a
578
+ // `load.paths` entry pointing at `extensions/openclaw` on a box whose plugin
579
+ // lives in `extensions/atbash-plugin`, and rewrote an absolute
580
+ // `chromiaSecretPath` to a `~` one for no gain.
581
+ //
582
+ // A plugin that is already installed and registered does not need `allow`,
583
+ // `load` or `hooks` re-declared — it is working. The only thing setup has any
584
+ // business changing is that it is switched on and pointed at the right key.
585
+ if (existing) {
586
+ const existingConfig = isRecord(existing.config) ? existing.config : {};
587
+ const repaired = { ...existing };
588
+ // ONE exception to leaving an existing entry alone: remove a `hooks` block
589
+ // from a LEGACY entry.
590
+ //
591
+ // Legacy `atbash-plugin` accepts only `config` and `enabled`. An earlier
592
+ // release of this command wrote the NEW entry's `hooks` block onto it, and
593
+ // OpenClaw then rejects the entire file —
594
+ //
595
+ // Invalid config: plugins.entries.atbash-plugin: Unrecognized key: "hooks"
596
+ //
597
+ // refusing to load ANY config, which also makes `openclaw plugins install`
598
+ // exit 1. "Touch as little as possible" is the right rule, but it must not
599
+ // mean stepping politely around damage this tool caused: a later run would
600
+ // leave the machine broken forever and report nothing wrong. Only ever
601
+ // removes the key on the legacy entry, where it is invalid by definition —
602
+ // never on the modern `openclaw` entry, which documents it.
603
+ if (entryKey === OPENCLAW_LEGACY_ENTRY && "hooks" in repaired)
604
+ delete repaired.hooks;
605
+ entries[entryKey] = {
606
+ ...repaired,
607
+ enabled: true,
608
+ config: {
609
+ ...existingConfig,
610
+ enabled: true,
611
+ enforceDecision: true,
612
+ ...keyPathUpdate(existingConfig, home),
613
+ ...orgNameUpdate(existingConfig, orgName),
614
+ },
615
+ };
616
+ plugins.entries = entries;
617
+ out.plugins = plugins;
618
+ return out;
619
+ }
620
+ // No entry yet — write the shape the published plugin documents, in full.
403
621
  const allow = Array.isArray(plugins.allow) ? [...plugins.allow] : [];
404
622
  if (!allow.includes(entryKey))
405
623
  allow.push(entryKey);
406
624
  plugins.allow = allow;
407
625
  const load = { ...(isRecord(plugins.load) ? plugins.load : {}) };
408
- const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY);
626
+ const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, entryKey);
409
627
  const paths = Array.isArray(load.paths) ? [...load.paths] : [];
410
628
  if (!paths.includes(extensionPath))
411
629
  paths.push(extensionPath);
412
630
  load.paths = paths;
413
631
  plugins.load = load;
414
- // Preserve any unrelated fields the owner set on the entry (debug, custom
415
- // hooks); only the fields Atbash owns are asserted.
416
- const existing = isRecord(entries[entryKey]) ? entries[entryKey] : {};
417
- const existingConfig = isRecord(existing.config) ? existing.config : {};
418
- const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
419
632
  entries[entryKey] = {
420
- ...existing,
421
633
  enabled: true,
422
634
  config: {
423
- ...existingConfig,
424
635
  enabled: true,
425
636
  enforceDecision: true,
426
- // The path, not the key. This is the whole point of the canonical location.
427
637
  chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}`,
638
+ ...(orgName?.trim() ? { orgName: orgName.trim() } : {}),
428
639
  },
429
- hooks: {
430
- ...existingHooks,
431
- allowConversationAccess: true,
432
- allowPromptInjection: true,
433
- },
640
+ hooks: { allowConversationAccess: true, allowPromptInjection: true },
434
641
  };
435
642
  plugins.entries = entries;
436
643
  out.plugins = plugins;
437
644
  return out;
438
645
  }
646
+ /**
647
+ * The key-path field, but only when it actually needs changing.
648
+ *
649
+ * `/Users/me/.config/atbash/guard-client-key` and `~/.config/atbash/guard-client-key`
650
+ * are the same file, and the plugin expands `~`. Rewriting one into the other is
651
+ * a diff the operator has to read and approve for no behavioural change, so an
652
+ * existing value that already resolves to the canonical key file is left exactly
653
+ * as they wrote it.
654
+ */
655
+ /**
656
+ * `orgName`, but only when we have one and it differs from what is there.
657
+ *
658
+ * The plugin resolves WHICH CHAIN to query from this, so it is required for orgs
659
+ * on a private chain and harmless otherwise. It cannot be derived on the machine
660
+ * — it has to match the dashboard exactly — so it arrives via --org-name, which
661
+ * the post-deploy screen fills in because the browser already knows it.
662
+ *
663
+ * Never overwrites an existing value with nothing: a run without the flag must
664
+ * not silently strip an orgName someone already has working.
665
+ */
666
+ function orgNameUpdate(existingConfig, orgName) {
667
+ const wanted = orgName?.trim();
668
+ if (!wanted)
669
+ return {};
670
+ return existingConfig.orgName === wanted ? {} : { orgName: wanted };
671
+ }
672
+ function keyPathUpdate(existingConfig, home) {
673
+ const canonical = path.join(home, ...KEY_FILE_REL);
674
+ const current = existingConfig.chromiaSecretPath;
675
+ if (typeof current === "string" && path.resolve(expandHome(current.trim(), home)) === path.resolve(canonical)) {
676
+ return {};
677
+ }
678
+ return { chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}` };
679
+ }
439
680
  function isRecord(v) {
440
681
  return !!v && typeof v === "object" && !Array.isArray(v);
441
682
  }
683
+ /**
684
+ * Detect the indentation a JSON file already uses, so a merge does not reformat
685
+ * the parts it did not touch.
686
+ *
687
+ * Without this, `JSON.stringify(obj, null, 2)` re-indents a tab-indented or
688
+ * 4-space config from top to bottom. The RESULT is still correct, but the diff
689
+ * shown for approval becomes every line in the file, which buries the two lines
690
+ * that actually changed — and the operator's own formatting choice is collateral
691
+ * damage in a file we were asked to make one addition to.
692
+ *
693
+ * Falls back to two spaces, which is what the published docs show.
694
+ */
695
+ function detectIndent(text) {
696
+ if (!text)
697
+ return 2;
698
+ // First line that is indented under an opening brace/bracket tells us the unit.
699
+ const match = text.match(/\n([ \t]+)\S/);
700
+ if (!match)
701
+ return 2;
702
+ const indent = match[1];
703
+ return indent.startsWith("\t") ? "\t" : indent.length;
704
+ }
705
+ /**
706
+ * Serialize a merged config the way the file was already written: same
707
+ * indentation, and a trailing newline only if the original had one.
708
+ */
709
+ function serializeLike(original, value) {
710
+ const body = JSON.stringify(value, null, detectIndent(original));
711
+ // A file that ended without a newline keeps ending without one. Trivial, but it
712
+ // is one more line of unexplained diff for someone reviewing the change.
713
+ const trailing = original === null || original.endsWith("\n") ? "\n" : "";
714
+ return body + trailing;
715
+ }
716
+ /**
717
+ * The MCP server entry setup writes into a client's config.
718
+ *
719
+ * Note what is NOT here: an `env` block. The published `@atbash/mcp` wiring
720
+ * carries the agent's private key in one, because that package reads only
721
+ * ATBASH_AGENT_PRIVKEY. Going through `atbash mcp` instead means the launcher
722
+ * reads the 0600 key file and passes the key to the server in the child process
723
+ * environment, so this entry holds no credential and the client's config file is
724
+ * no more sensitive after setup runs than it was before.
725
+ *
726
+ * Deliberately NOT pinned to an exact CLI version: unlike the one-shot connector
727
+ * command, this entry persists in the operator's config and is re-executed every
728
+ * time the client starts. Pinning here would freeze their MCP server at whatever
729
+ * version happened to be current on the day they ran setup.
730
+ */
731
+ const MCP_SERVER_ENTRY = { command: "npx", args: ["--yes", "@atbash/cli", "mcp"] };
732
+ const MCP_SERVER_NAME = "atbash";
733
+ /**
734
+ * MCP client configs present under this home directory.
735
+ *
736
+ * Paths come from the shared MCP_CONFIGS so the writer and the scanner cannot
737
+ * drift: a client the scan reports but setup cannot find would look like a bug in
738
+ * whichever of the two the operator happened to trust.
739
+ */
740
+ function detectMcpClients(home) {
741
+ const out = [];
742
+ const seen = new Set();
743
+ for (const { label, segs } of atbash_targets_1.MCP_CONFIGS) {
744
+ if (seen.has(label))
745
+ continue;
746
+ const file = path.join(home, ...segs);
747
+ if (!exists(file))
748
+ continue;
749
+ seen.add(label);
750
+ // Read which key this file already uses rather than assuming. VS Code's
751
+ // mcp.json uses `servers`; writing `mcpServers` into it would be ignored.
752
+ const existing = readJsonLoose(file);
753
+ const serversKey = existing && isRecord(existing.servers) && !isRecord(existing.mcpServers) ? "servers" : "mcpServers";
754
+ out.push({ label, file, format: "json", serversKey });
755
+ }
756
+ // Claude Code and Codex are special-cased in the scanner too — same paths.
757
+ const claudeCode = path.join(home, ".claude.json");
758
+ if (exists(claudeCode))
759
+ out.push({ label: "Claude Code", file: claudeCode, format: "json", serversKey: "mcpServers" });
760
+ const codex = path.join(home, ".codex", "config.toml");
761
+ if (exists(codex))
762
+ out.push({ label: "Codex", file: codex, format: "toml", serversKey: "mcpServers" });
763
+ return out;
764
+ }
765
+ /** Tolerant read used only to sniff an existing file's shape. */
766
+ function readJsonLoose(file) {
767
+ const text = readTextFile(file);
768
+ if (text === null)
769
+ return null;
770
+ const value = jsonc.parse(text, [], { allowTrailingComma: true, disallowComments: false });
771
+ return isRecord(value) ? value : null;
772
+ }
773
+ /**
774
+ * Add the Atbash server to a client config's server map, in place.
775
+ *
776
+ * A merge, like the OpenClaw one: every server the operator already configured
777
+ * stays exactly as it is. An existing `atbash` entry is REPLACED rather than
778
+ * merged field-by-field — a stale `env` block carrying a private key from the old
779
+ * hand-written wiring is precisely what we want gone, and preserving it would
780
+ * defeat the point of routing through the launcher.
781
+ */
782
+ /** The Hermes plugin, and the exact version the wiring is written against. */
783
+ const HERMES_PKG = "atbash-hermes-plugin";
784
+ const HERMES_VERSION = "0.4.5";
785
+ /** The name Hermes lists this plugin under, and the allow-list entry. */
786
+ const HERMES_PLUGIN_ENTRY = "atbash-hermes-plugin";
787
+ /**
788
+ * Find the Python interpreter that actually runs Hermes.
789
+ *
790
+ * This is the difference between installing the plugin and only appearing to.
791
+ * `pip install atbash-hermes-plugin` puts the package wherever the *shell's*
792
+ * `pip` points — commonly a system or conda Python — while Hermes typically runs
793
+ * from its own virtualenv. The install succeeds, prints nothing alarming, and the
794
+ * plugin is invisible to Hermes forever. Nobody can debug that from the output.
795
+ *
796
+ * The launcher knows the answer. A pip-installed console script begins with a
797
+ * shebang naming the interpreter that created it:
798
+ *
799
+ * $ head -1 $(command -v hermes)
800
+ * #!/Users/me/.hermes/hermes-agent/venv/bin/python3
801
+ *
802
+ * So resolve `hermes`, read its first line, and use that interpreter directly via
803
+ * `-m pip`. Falls back to the conventional venv location under ~/.hermes, then to
804
+ * null — and a null becomes a printed command rather than a guess, because a
805
+ * wrong guess here is the silent failure this whole function exists to avoid.
806
+ */
807
+ function findHermesPython(home) {
808
+ const viable = (candidate) => {
809
+ try {
810
+ return fs.statSync(candidate).isFile();
811
+ }
812
+ catch {
813
+ return false;
814
+ }
815
+ };
816
+ // 1. The launcher's own shebang — authoritative, but only for a real run.
817
+ //
818
+ // `--home <dir>` exists so a dry run can be hermetic (the release checklist
819
+ // depends on it). A PATH lookup ignores it entirely: under `--home /tmp/fake`
820
+ // this would find the operator's ACTUAL hermes and plan an install into their
821
+ // real virtualenv. So the shebang route is skipped whenever `home` is not the
822
+ // machine's own home — the caller then falls through to the venv path under the
823
+ // given home, which is correctly scoped.
824
+ const realHome = process.env.HOME || os.homedir();
825
+ const scoped = path.resolve(home) !== path.resolve(realHome);
826
+ const which = scoped
827
+ ? { status: 1, stdout: "" }
828
+ : (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["hermes"], { encoding: "utf8" });
829
+ const launcher = which.status === 0 ? which.stdout.split(/\r?\n/)[0]?.trim() : "";
830
+ if (launcher && viable(launcher)) {
831
+ const firstLine = (readTextFile(launcher) ?? "").split(/\r?\n/)[0] ?? "";
832
+ const shebang = firstLine.startsWith("#!") ? firstLine.slice(2).trim() : "";
833
+ // `#!/usr/bin/env python3` names no path; anything else should be absolute.
834
+ const interpreter = shebang.split(/\s+/).filter((part) => !part.endsWith("/env"))[0] ?? "";
835
+ if (/python[0-9.]*$/.test(interpreter) && viable(interpreter)) {
836
+ return { python: interpreter, how: `shebang of ${launcher}` };
837
+ }
838
+ }
839
+ // 2. The conventional venv Hermes ships with.
840
+ for (const name of ["python3", "python"]) {
841
+ const candidate = path.join(home, ".hermes", "hermes-agent", "venv", "bin", name);
842
+ if (viable(candidate))
843
+ return { python: candidate, how: "Hermes virtualenv under ~/.hermes" };
844
+ }
845
+ return null;
846
+ }
847
+ /** Same rule as buildPlan's `wanted`: an empty --runtime list means everything. */
848
+ function wantedRuntime(id, only) {
849
+ return only.length === 0 || only.includes(id);
850
+ }
851
+ /**
852
+ * Add the plugin to Hermes' opt-in allow-list at `plugins.enabled` in
853
+ * `~/.hermes/config.yaml`.
854
+ *
855
+ * WHY NOT `hermes plugins enable`: that command cannot accept this plugin, on
856
+ * this version, ever. `_plugin_exists` (hermes_cli/plugins_cmd.py) looks only for
857
+ * a DIRECTORY in the user plugins dir or a bundled dir — it never consults entry
858
+ * points. Meanwhile the runtime loader (`plugins.py:_scan_entry_points`) does
859
+ * discover them. So Hermes will happily LOAD a pip-installed plugin but refuses
860
+ * to put one on the allow-list it requires, and since plugins are opt-in, a
861
+ * package that cannot get onto the list can never load. Running that command
862
+ * exits 1 with "not installed or bundled". Writing the list entry directly is the
863
+ * only route that works.
864
+ *
865
+ * A LINE MERGE, not a parse-and-reserialize. config.yaml is tens of kilobytes of
866
+ * heavily commented configuration; round-tripping it through a YAML emitter would
867
+ * strip every comment and reflow the file. So this inserts the one line needed and
868
+ * leaves every other byte alone — the same discipline as the .env merge.
869
+ */
870
+ function mergeHermesEnabledPlugins(existing, plugin = HERMES_PLUGIN_ENTRY) {
871
+ const lines = existing === null ? [] : existing.split("\n");
872
+ // Already on the list? Then there is nothing to do.
873
+ const pluginsAt = lines.findIndex((l) => /^plugins:\s*(#.*)?$/.test(l));
874
+ if (pluginsAt !== -1) {
875
+ // Walk the plugins block (indented lines) looking for `enabled:` and its items.
876
+ let enabledAt = -1;
877
+ let end = lines.length;
878
+ for (let i = pluginsAt + 1; i < lines.length; i++) {
879
+ const line = lines[i];
880
+ if (line.trim() === "" || line.startsWith("#"))
881
+ continue;
882
+ if (!/^\s/.test(line)) {
883
+ end = i;
884
+ break;
885
+ } // dedented — block over
886
+ if (/^\s+enabled:\s*(#.*)?$/.test(line))
887
+ enabledAt = i;
888
+ }
889
+ if (enabledAt !== -1) {
890
+ // Collect the list items under `enabled:` and bail if ours is there.
891
+ const itemIndent = (lines[enabledAt].match(/^\s*/)?.[0] ?? " ") + " ";
892
+ let last = enabledAt;
893
+ for (let i = enabledAt + 1; i < end; i++) {
894
+ if (/^\s*-\s+/.test(lines[i])) {
895
+ if (lines[i].replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "") === plugin)
896
+ return existing;
897
+ last = i;
898
+ }
899
+ else if (lines[i].trim() !== "")
900
+ break;
901
+ }
902
+ lines.splice(last + 1, 0, `${itemIndent}- ${plugin}`);
903
+ return lines.join("\n");
904
+ }
905
+ // A plugins block with no `enabled:` key — add one inside it.
906
+ lines.splice(pluginsAt + 1, 0, " enabled:", ` - ${plugin}`);
907
+ return lines.join("\n");
908
+ }
909
+ // No plugins block at all — which is the default, and means nothing loads.
910
+ const out = existing === null ? [] : [...lines];
911
+ if (out.length && out[out.length - 1].trim() !== "")
912
+ out.push("");
913
+ out.push("# Added by `atbash setup` - Hermes loads only plugins on this allow-list");
914
+ out.push("plugins:", " enabled:", ` - ${plugin}`);
915
+ let text = out.join("\n");
916
+ if (!text.endsWith("\n"))
917
+ text += "\n";
918
+ return text;
919
+ }
920
+ /**
921
+ * Is a Hermes gateway service running, and therefore restartable?
922
+ *
923
+ * "Restart Hermes" only means something when there is a service to bounce.
924
+ * Hermes has two shapes: `hermes` starts an interactive chat session, and
925
+ * `hermes gateway install` registers a launchd/systemd background service. A
926
+ * setup command must not conflate them —
927
+ *
928
+ * - with a service running, `hermes gateway restart` picks up the new config and
929
+ * is worth doing for the operator;
930
+ * - without one, there is nothing to restart. The plugin loads the next time
931
+ * they run `hermes`, and anything we "restarted" would either be a no-op or,
932
+ * worse, an interactive session someone is in the middle of using.
933
+ *
934
+ * `gateway status` exits 0 either way, so the state comes from its text.
935
+ */
936
+ function hermesGatewayRunning() {
937
+ const probe = (0, child_process_1.spawnSync)("hermes", ["gateway", "status"], { encoding: "utf8" });
938
+ if (probe.status !== 0 || typeof probe.stdout !== "string")
939
+ return false;
940
+ // "✗ Gateway is not running" vs a running report.
941
+ return !/not\s+running/i.test(probe.stdout);
942
+ }
943
+ /**
944
+ * Is Hermes CONFIGURED to load the plugin?
945
+ *
946
+ * This used to shell out to `hermes plugins list` and look for an atbash row —
947
+ * a signal that can never be true. `_discover_all_plugins` (plugins_cmd.py) walks
948
+ * plugin DIRECTORIES only: bundled, user, project. It never scans entry points,
949
+ * exactly like the `_plugin_exists` gate behind `plugins enable`. So a
950
+ * pip-installed plugin is invisible to both, and the check reported "not picked
951
+ * up" while everything was in fact correct — then told the operator to run the
952
+ * enable command that cannot work. Advising a known-impossible fix is worse than
953
+ * saying nothing.
954
+ *
955
+ * The two facts that actually decide it are both on disk:
956
+ *
957
+ * 1. the package is importable by the interpreter that runs Hermes, and
958
+ * 2. its name is on the `plugins.enabled` allow-list in config.yaml, which is
959
+ * what the RUNTIME loader (`plugins.py:_scan_entry_points`) honours.
960
+ *
961
+ * "configured" is as far as a setup command can honestly go. Proof of loading is
962
+ * a line in the agent log after Hermes next starts, which is why the caller points
963
+ * at that rather than claiming enforcement.
964
+ */
965
+ function hermesPluginState(home) {
966
+ const venvBase = path.join(home, ".hermes", "hermes-agent", "venv", "lib");
967
+ let installed = false;
968
+ try {
969
+ installed = fs.readdirSync(venvBase, { withFileTypes: true })
970
+ .filter((e) => e.isDirectory())
971
+ .some((e) => exists(venvBase, e.name, "site-packages", "atbash_hermes_plugin"));
972
+ }
973
+ catch {
974
+ return "unknown";
975
+ }
976
+ const configYaml = readTextFile(path.join(home, ".hermes", "config.yaml"));
977
+ if (configYaml === null)
978
+ return installed ? "not-enabled" : "not-installed";
979
+ let enabled = false;
980
+ try {
981
+ const parsed = (0, yaml_1.parse)(configYaml);
982
+ const list = parsed?.plugins?.enabled;
983
+ enabled = Array.isArray(list) && list.some((n) => String(n).trim() === HERMES_PLUGIN_ENTRY);
984
+ }
985
+ catch {
986
+ return "unknown";
987
+ }
988
+ if (!installed)
989
+ return "not-installed";
990
+ return enabled ? "configured" : "not-enabled";
991
+ }
992
+ /**
993
+ * How to install a Python package into a specific interpreter on THIS machine.
994
+ *
995
+ * `<python> -m pip install` is the obvious answer and it is frequently wrong: a
996
+ * venv created by `uv venv` has no pip at all (that is uv's default), so the
997
+ * command fails with
998
+ *
999
+ * /path/venv/bin/python3: No module named pip
1000
+ *
1001
+ * after setup has already written every config file — which is exactly what
1002
+ * happened on a real Hermes box. So probe, in order of what suits the venv:
1003
+ *
1004
+ * 1. `uv pip install --python <python>` when uv is present. Correct for a
1005
+ * uv-created venv and fast; uv is also what created most pip-less venvs.
1006
+ * 2. `<python> -m pip install` when pip actually answers.
1007
+ * 3. Neither — hand it over, with `ensurepip` named, rather than planning a
1008
+ * command that is known in advance to fail.
1009
+ */
1010
+ function pythonInstallStrategy(python, pkg) {
1011
+ const uv = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["uv"], { stdio: "ignore" });
1012
+ if (uv.status === 0) {
1013
+ return {
1014
+ kind: "exec",
1015
+ command: "uv",
1016
+ args: ["pip", "install", "--python", python, pkg],
1017
+ how: "uv, targeting the interpreter that runs Hermes",
1018
+ };
1019
+ }
1020
+ const pip = (0, child_process_1.spawnSync)(python, ["-m", "pip", "--version"], { stdio: "ignore" });
1021
+ if (pip.status === 0) {
1022
+ return { kind: "exec", command: python, args: ["-m", "pip", "install", pkg], how: "pip in the interpreter that runs Hermes" };
1023
+ }
1024
+ return {
1025
+ kind: "manual",
1026
+ why: `${python} has no pip (a venv created by \`uv venv\` has none by default) and uv is not on PATH, so setup cannot install into it. Bootstrap pip, then install:`,
1027
+ snippet: [`${python} -m ensurepip --upgrade`, `${python} -m pip install ${pkg}`].join("\n"),
1028
+ };
1029
+ }
1030
+ /**
1031
+ * The env vars the Hermes plugin documents, merged into an existing `.env`.
1032
+ *
1033
+ * A `.env` is line-oriented and hand-maintained, so this is a line merge rather
1034
+ * than a parse-and-reserialize: keys Atbash owns are replaced in place (keeping
1035
+ * their position), keys it does not own are never touched, and anything else in
1036
+ * the file — comments, blank lines, unrelated settings, ordering — survives
1037
+ * exactly as written. Reformatting someone's .env to add four lines would be a
1038
+ * poor trade.
1039
+ *
1040
+ * Values are from the published plugin README (PyPI atbash-hermes-plugin 0.4.5).
1041
+ * `ATBASH_ORG_NAME` is deliberately NOT written: its value is the operator's org,
1042
+ * which this command has no reliable way to know, and a wrong org sends the SDK
1043
+ * at the wrong chain. It is called out in the manual step instead.
1044
+ */
1045
+ function mergeHermesEnv(existing) {
1046
+ const desired = {
1047
+ ATBASH_KEY_PATH: "$HOME/.config/atbash/guard-client-key",
1048
+ ATBASH_ENFORCE_DECISION: "true",
1049
+ };
1050
+ const lines = existing === null ? [] : existing.split("\n");
1051
+ const seen = new Set();
1052
+ const out = lines.map((line) => {
1053
+ const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/);
1054
+ const key = match?.[1];
1055
+ if (!key || !(key in desired) || seen.has(key))
1056
+ return line;
1057
+ seen.add(key);
1058
+ // Already correct — keep the operator's own formatting rather than rewriting.
1059
+ if (line.trim() === `${key}=${desired[key]}`)
1060
+ return line;
1061
+ return `${key}=${desired[key]}`;
1062
+ });
1063
+ const missing = Object.entries(desired).filter(([key]) => !seen.has(key));
1064
+ if (missing.length) {
1065
+ // Separate the block we add from whatever came before it.
1066
+ if (out.length && out[out.length - 1].trim() !== "")
1067
+ out.push("");
1068
+ // ASCII-only comment on purpose: .env files are read by many different
1069
+ // parsers and a stray multi-byte dash is a free way to trip a strict one.
1070
+ if (existing !== null)
1071
+ out.push("# Added by `atbash setup` - Atbash Hermes plugin");
1072
+ for (const [key, value] of missing)
1073
+ out.push(`${key}=${value}`);
1074
+ }
1075
+ let text = out.join("\n");
1076
+ if (!text.endsWith("\n"))
1077
+ text += "\n";
1078
+ return text;
1079
+ }
1080
+ /**
1081
+ * Does this config's existing Atbash entry carry a key in its `env` block?
1082
+ *
1083
+ * True means the operator hand-wired it from the published docs and their private
1084
+ * key is sitting in that file today. Setup takes it out, but the backup it writes
1085
+ * first still has it — so this exists to make that sayable rather than silently
1086
+ * relocating the leak.
1087
+ */
1088
+ function hadInlineKey(config, serversKey = "mcpServers") {
1089
+ const servers = isRecord(config[serversKey]) ? config[serversKey] : undefined;
1090
+ const entry = servers && isRecord(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : undefined;
1091
+ const env = entry && isRecord(entry.env) ? entry.env : undefined;
1092
+ if (!env)
1093
+ return false;
1094
+ // Any 64-hex value, under any key name — not just the documented one, since a
1095
+ // hand-edited config may well have renamed it.
1096
+ return Object.values(env).some((v) => typeof v === "string" && /^(0x)?[0-9a-fA-F]{64}$/.test(v.trim()));
1097
+ }
1098
+ function mergeMcpServer(config, serversKey = "mcpServers") {
1099
+ const out = { ...config };
1100
+ const servers = { ...(isRecord(out[serversKey]) ? out[serversKey] : {}) };
1101
+ servers[MCP_SERVER_NAME] = { ...MCP_SERVER_ENTRY, args: [...MCP_SERVER_ENTRY.args] };
1102
+ out[serversKey] = servers;
1103
+ return out;
1104
+ }
442
1105
  /** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
443
1106
  function hasExecutable(command) {
444
1107
  const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
@@ -453,7 +1116,7 @@ function hasExecutable(command) {
453
1116
  * writes a config for a plugin they do not have.
454
1117
  */
455
1118
  function buildPlan(args) {
456
- const { home, privkey, pubkey, noInstall, only } = args;
1119
+ const { home, privkey, pubkey, noInstall, only, orgName, endpoint } = args;
457
1120
  const steps = [];
458
1121
  const notes = [];
459
1122
  const found = [];
@@ -466,7 +1129,33 @@ function buildPlan(args) {
466
1129
  const alreadyThisKey = currentKeyFile !== null && parseKeyMaterial(currentKeyFile)?.privkey === privkey;
467
1130
  if (!alreadyThisKey) {
468
1131
  if (currentKeyFile !== null) {
469
- notes.push(`${keyFile} already holds a DIFFERENT agent key. It will be backed up before being replaced — check that you meant to re-point this machine at another agent.`);
1132
+ // The outgoing key belongs to a real agent that this machine may still be
1133
+ // governing. A `.atbash-bak` preserves the bytes but not the identity — six
1134
+ // months later nobody knows which agent `guard-client-key.atbash-bak` was.
1135
+ // So archive it under its own public key: recoverable, self-identifying,
1136
+ // and a path a runtime config can point at directly if this box needs to
1137
+ // run two agents (both OpenClaw's `chromiaSecretPath` and Hermes'
1138
+ // `ATBASH_KEY_PATH` take an explicit path).
1139
+ const outgoing = parseKeyMaterial(currentKeyFile);
1140
+ const outgoingPub = outgoing ? (0, sdk_1.derivePublicKey)(outgoing.privkey) : null;
1141
+ if (outgoingPub) {
1142
+ const archive = path.join(home, ".config", "atbash", "keys", `${outgoingPub}.key`);
1143
+ if (!exists(archive)) {
1144
+ steps.push({
1145
+ kind: "write",
1146
+ label: `Archive the agent key already on this machine (${outgoingPub.slice(0, 12)}…)`,
1147
+ file: archive,
1148
+ mode: KEY_MODE,
1149
+ before: null,
1150
+ after: currentKeyFile,
1151
+ secret: true,
1152
+ });
1153
+ }
1154
+ notes.push(`${keyFile} currently holds a DIFFERENT agent (${outgoingPub.slice(0, 12)}…). It is archived to ~/.config/atbash/keys/${outgoingPub.slice(0, 12)}….key before being replaced, so that agent is recoverable — but every integration on this machine reading the default path will switch to the new agent.`);
1155
+ }
1156
+ else {
1157
+ notes.push(`${keyFile} holds something this command could not parse as an agent key. It will be backed up before being replaced.`);
1158
+ }
470
1159
  }
471
1160
  steps.push({
472
1161
  kind: "write",
@@ -489,14 +1178,25 @@ function buildPlan(args) {
489
1178
  if (wanted("openclaw")) {
490
1179
  if (!noInstall) {
491
1180
  if (hasExecutable("openclaw")) {
492
- steps.push({ kind: "exec", label: `Install ${OPENCLAW_PKG}`, command: "openclaw", args: ["plugins", "install", OPENCLAW_PKG] });
1181
+ const spec = openclawPackageForHost(endpoint);
1182
+ steps.push({
1183
+ kind: "exec",
1184
+ label: spec.endsWith("@dev")
1185
+ ? `Install ${spec} — the development build, matching the deployment this agent was onboarded on`
1186
+ : `Install ${spec} — the production build`,
1187
+ command: "openclaw",
1188
+ args: ["plugins", "install", spec],
1189
+ });
1190
+ if (spec.endsWith("@dev")) {
1191
+ notes.push("The plugin's judge endpoint and chain ids come from the SDK it bundles, so the npm tag is an environment choice. This agent was onboarded on a development deployment, so the `@dev` build is the matching one — the production build would load, fire its hook, and fail every judge call because the agent does not exist on the chain it targets.");
1192
+ }
493
1193
  }
494
1194
  else {
495
1195
  steps.push({
496
1196
  kind: "manual",
497
1197
  label: "Install the OpenClaw plugin",
498
1198
  detail: "The `openclaw` command is not on this machine's PATH, so the plugin cannot be installed for you. Run this wherever the OpenClaw CLI lives:",
499
- snippet: `openclaw plugins install ${OPENCLAW_PKG}`,
1199
+ snippet: `openclaw plugins install ${openclawPackageForHost(endpoint)}`,
500
1200
  });
501
1201
  }
502
1202
  }
@@ -507,7 +1207,7 @@ function buildPlan(args) {
507
1207
  kind: "manual",
508
1208
  label: `Enable the plugin in ${openclawConfigFile}`,
509
1209
  detail: "That file uses comments or trailing commas, and rewriting it as strict JSON would delete them. Merge this into the existing `plugins` object by hand — keep any other plugins already in `allow` and `entries`:",
510
- snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home), null, 2),
1210
+ snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home, orgName), null, 2),
511
1211
  });
512
1212
  }
513
1213
  else {
@@ -522,7 +1222,7 @@ function buildPlan(args) {
522
1222
  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
1223
  }
524
1224
  }
525
- const after = JSON.stringify(mergeOpenclawConfig(current, home), null, 2) + "\n";
1225
+ const after = serializeLike(raw, mergeOpenclawConfig(current, home, orgName));
526
1226
  if (raw !== after) {
527
1227
  steps.push({
528
1228
  kind: "write",
@@ -547,47 +1247,188 @@ function buildPlan(args) {
547
1247
  // judged. Setup places the key file and says so; it does not pretend to wire it.
548
1248
  if (exists(home, ...HERMES_AGENT_REL)) {
549
1249
  found.push("Hermes");
550
- notes.push("Hermes is installed here. It shares this agent's skills and signing key, but the Atbash hook lives in the OpenClaw gateway — actions driven through the Hermes API are NOT judged, even while the OpenClaw side reports enforcing. Route that work through OpenClaw, or guard it in code with @atbash/sdk.");
551
- }
552
- // ── 4. MCP clients: detected and reported, never seeded.
553
- // The documented @atbash/mcp wiring carries the private key as an
554
- // ATBASH_AGENT_PRIVKEY env value inside the client's own config file, and that
555
- // package has no key-file fallback (checked against the published 0.1.3). We
556
- // will not write a private key into a file people share and sync, so this is
557
- // the one place setup deliberately stays manual.
558
- const mcpClients = [];
559
- const seenClients = new Set();
560
- for (const { label, segs } of connect_1.MCP_CONFIGS) {
561
- if (seenClients.has(label))
562
- continue;
563
- if (exists(home, ...segs)) {
564
- seenClients.add(label);
565
- mcpClients.push(`${label} (${path.join(home, ...segs)})`);
566
- }
567
- }
568
- // Claude Code and Codex live outside MCP_CONFIGS in the scanner too — same paths.
569
- if (exists(home, ".claude.json"))
570
- mcpClients.push(`Claude Code (${path.join(home, ".claude.json")})`);
571
- if (exists(home, ".codex", "config.toml"))
572
- mcpClients.push(`Codex (${path.join(home, ".codex", "config.toml")})`);
573
- if (mcpClients.length) {
574
- found.push(`${mcpClients.length} MCP client config${mcpClients.length === 1 ? "" : "s"}`);
575
- steps.push({
576
- kind: "manual",
577
- label: `Optional: expose Atbash as an MCP server to ${mcpClients.length} detected client${mcpClients.length === 1 ? "" : "s"}`,
578
- detail: [
579
- `Detected: ${mcpClients.join(", ")}.`,
580
- "",
581
- "This step is NOT done for you, on purpose. The published @atbash/mcp wiring takes the agent's private key as an ATBASH_AGENT_PRIVKEY value inside the client's own config file, and that package has no key-file fallback today. Atbash will not write your private key into a file that gets synced, shared and screenshotted.",
582
- "",
583
- "If you want it anyway, add this yourself and fill in the key — and treat that config file as a secret from then on:",
584
- ].join("\n"),
585
- snippet: JSON.stringify({
586
- mcpServers: {
587
- atbash: { command: "npx", args: ["-y", "@atbash/mcp"], env: { ATBASH_AGENT_PRIVKEY: "<your agent private key>" } },
588
- },
589
- }, null, 2),
590
- });
1250
+ if (wanted("hermes")) {
1251
+ const envFile = path.join(home, ".hermes", ".env");
1252
+ const raw = readTextFile(envFile);
1253
+ const merged = mergeHermesEnv(raw);
1254
+ if (merged !== raw) {
1255
+ steps.push({
1256
+ kind: "write",
1257
+ label: raw === null
1258
+ ? "Create ~/.hermes/.env pointing the Hermes plugin at the agent key"
1259
+ : "Point the Hermes plugin at the agent key in ~/.hermes/.env (a merge — your other settings are kept)",
1260
+ file: envFile,
1261
+ before: raw,
1262
+ after: merged,
1263
+ });
1264
+ }
1265
+ else {
1266
+ notes.push(`${envFile} already points the Hermes plugin at this key — left untouched.`);
1267
+ }
1268
+ const hermesConfig = path.join(home, ".hermes", "config.yaml");
1269
+ const rawConfig = readTextFile(hermesConfig);
1270
+ const withPlugin = mergeHermesEnabledPlugins(rawConfig);
1271
+ if (withPlugin !== rawConfig) {
1272
+ steps.push({
1273
+ kind: "write",
1274
+ label: rawConfig === null
1275
+ ? "Create ~/.hermes/config.yaml enabling the plugin"
1276
+ : "Add the plugin to Hermes' enabled allow-list in ~/.hermes/config.yaml",
1277
+ file: hermesConfig,
1278
+ before: rawConfig,
1279
+ after: withPlugin,
1280
+ });
1281
+ notes.push("Hermes loads ONLY plugins on the `plugins.enabled` allow-list, so installing the package is not enough. `hermes plugins enable` cannot add it that command only recognises plugin directories, not pip-installed entry points so setup writes the list entry directly.");
1282
+ }
1283
+ else if (rawConfig !== null) {
1284
+ notes.push(`${hermesConfig} already has the plugin on its enabled list — left untouched.`);
1285
+ }
1286
+ // Only a running gateway service can be restarted for them. An interactive
1287
+ // session cannot, and must not be see hermesGatewayRunning.
1288
+ const restartable = hasExecutable("hermes") && hermesGatewayRunning();
1289
+ // The Python package must land in the interpreter that RUNS Hermes, not
1290
+ // whichever pip the shell happens to resolve. When we can identify that
1291
+ // interpreter we install into it directly; when we cannot, we hand the
1292
+ // command over rather than guess, because guessing wrong installs
1293
+ // successfully and governs nothing.
1294
+ if (!noInstall) {
1295
+ const hermesPython = findHermesPython(home);
1296
+ if (hermesPython) {
1297
+ const spec = `${HERMES_PKG}==${HERMES_VERSION}`;
1298
+ const strategy = pythonInstallStrategy(hermesPython.python, spec);
1299
+ if (strategy.kind === "exec") {
1300
+ steps.push({
1301
+ kind: "exec",
1302
+ label: `Install ${HERMES_PKG} via ${strategy.how} (interpreter found via ${hermesPython.how})`,
1303
+ command: strategy.command,
1304
+ args: strategy.args,
1305
+ });
1306
+ }
1307
+ else {
1308
+ steps.push({
1309
+ kind: "manual",
1310
+ label: "Install the Hermes plugin",
1311
+ detail: strategy.why,
1312
+ snippet: strategy.snippet,
1313
+ });
1314
+ }
1315
+ // The plugin pins atbash-sdk==0.4.5, which constrains cryptography — on
1316
+ // a venv with a newer one this install is a DOWNGRADE of a shared
1317
+ // dependency. Say so; it is the kind of thing that breaks the host app
1318
+ // and is invisible in a list of file writes.
1319
+ notes.push(`Installing ${HERMES_PKG} may change shared Python dependencies in that venv (it pins atbash-sdk==${HERMES_VERSION}, which constrains cryptography). Check the resolver output before confirming if Hermes depends on newer versions.`);
1320
+ // Installing is not enabling. Hermes keeps plugins off until told
1321
+ // otherwise, so without this the hook is never registered and the
1322
+ // agent is not governed — while every file on disk says it is.
1323
+ // Ordered after the install because `enable` needs the package present.
1324
+ }
1325
+ else {
1326
+ steps.push({
1327
+ kind: "manual",
1328
+ label: "Install the Hermes plugin",
1329
+ detail: [
1330
+ "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.",
1331
+ "",
1332
+ "Run this with the interpreter Hermes uses (if it runs in a virtualenv, that venv's python):",
1333
+ ].join("\n"),
1334
+ snippet: `/path/to/hermes/venv/bin/python -m pip install ${HERMES_PKG}==${HERMES_VERSION}`,
1335
+ });
1336
+ }
1337
+ }
1338
+ if (restartable) {
1339
+ steps.push({
1340
+ kind: "exec",
1341
+ label: "Restart the Hermes gateway so it loads the plugin",
1342
+ command: "hermes",
1343
+ args: ["gateway", "restart"],
1344
+ });
1345
+ }
1346
+ else {
1347
+ notes.push("No Hermes gateway service is running, so there is nothing to restart — the plugin loads the next time you start Hermes. (If you run it as a service, `hermes gateway install` then `hermes gateway restart`.)");
1348
+ }
1349
+ notes.push("Confirm the hook registered after Hermes next starts: `tail -n 50 ~/.hermes/logs/agent.log | grep -i atbash`. `hermes plugins list` will not show it — that listing only walks plugin directories.");
1350
+ notes.push("ATBASH_ENFORCE_DECISION=true is fail-closed: if Atbash cannot be reached, the Hermes tool call is blocked rather than allowed.");
1351
+ 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.");
1352
+ }
1353
+ }
1354
+ // ── 4. MCP clients.
1355
+ //
1356
+ // This used to be a manual step, and the reason was specific: `@atbash/mcp`
1357
+ // reads its identity from ATBASH_AGENT_PRIVKEY with no key-file fallback, so
1358
+ // the documented wiring puts a raw private key inside the client's own config —
1359
+ // `claude_desktop_config.json` and friends, files that get synced between
1360
+ // machines and pasted into help requests. Automating that would have meant the
1361
+ // automation's whole job was planting a secret somewhere worse.
1362
+ //
1363
+ // `atbash mcp` removes the reason. The client spawns the launcher, which reads
1364
+ // the key from the 0600 file and hands it to the server through the child
1365
+ // environment only. The config entry carries NO credential, so it is safe to
1366
+ // write — and a config with no secret in it is strictly better than the one the
1367
+ // operator would have hand-written from the docs.
1368
+ if (wanted("mcp")) {
1369
+ for (const client of detectMcpClients(home)) {
1370
+ found.push(client.label);
1371
+ if (client.format !== "json") {
1372
+ // TOML (Codex) — @iarna/toml can round-trip values but not comments, and
1373
+ // a config.toml is usually hand-maintained. Print it instead.
1374
+ steps.push({
1375
+ kind: "manual",
1376
+ label: `Add Atbash to ${client.label}`,
1377
+ detail: `${client.file} is TOML, and rewriting it would drop any comments in it. Add this table by hand:`,
1378
+ snippet: ["[mcp_servers.atbash]", 'command = "npx"', 'args = ["--yes", "@atbash/cli", "mcp"]'].join("\n"),
1379
+ });
1380
+ continue;
1381
+ }
1382
+ const raw = readTextFile(client.file);
1383
+ if (raw !== null && isJsonc(raw)) {
1384
+ steps.push({
1385
+ kind: "manual",
1386
+ label: `Add Atbash to ${client.label}`,
1387
+ 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:`,
1388
+ snippet: JSON.stringify({ mcpServers: { atbash: MCP_SERVER_ENTRY } }, null, 2),
1389
+ });
1390
+ continue;
1391
+ }
1392
+ let current = {};
1393
+ if (raw !== null) {
1394
+ try {
1395
+ const parsed = JSON.parse(raw);
1396
+ if (isRecord(parsed))
1397
+ current = parsed;
1398
+ }
1399
+ catch {
1400
+ notes.push(`${client.file} is not valid JSON, so it was left alone. Fix the file and re-run to wire ${client.label}.`);
1401
+ continue;
1402
+ }
1403
+ }
1404
+ const after = serializeLike(raw, mergeMcpServer(current, client.serversKey));
1405
+ if (raw !== after) {
1406
+ // A hand-wired entry from the old documented shape carries the private key
1407
+ // in an `env` block. Replacing it REMOVES that secret from the live config
1408
+ // — good — but the backup we are about to take still contains it, and an
1409
+ // operator who does not know that has simply moved the leak to a new file.
1410
+ if (hadInlineKey(current, client.serversKey)) {
1411
+ 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.`);
1412
+ }
1413
+ steps.push({
1414
+ kind: "write",
1415
+ 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" : ""})`,
1416
+ file: client.file,
1417
+ before: raw,
1418
+ after,
1419
+ });
1420
+ }
1421
+ else {
1422
+ notes.push(`${client.label} already has the Atbash MCP server — left untouched.`);
1423
+ }
1424
+ }
1425
+ if (found.some((f) => f !== "OpenClaw" && f !== "Hermes")) {
1426
+ notes.push("Restart any MCP client that was changed — clients read their server list at startup.");
1427
+ // A client whose first launch of the server takes ~12s can report a startup
1428
+ // timeout that looks like a broken config. Say so, so the first thing an
1429
+ // operator does is retry rather than undo the wiring.
1430
+ 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.");
1431
+ }
591
1432
  }
592
1433
  if (!found.length) {
593
1434
  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 +1612,27 @@ function applyPlan(plan) {
771
1612
  continue;
772
1613
  const label = `${step.command} ${step.args.join(" ")}`;
773
1614
  const run = (0, child_process_1.spawnSync)(step.command, step.args, { stdio: "inherit" });
774
- if (run.status === 0)
1615
+ // Three distinct outcomes, and they used to collapse into one misleading
1616
+ // message. `spawnSync` reports a binary it could not launch via `.error` with
1617
+ // `status` left null — so an ENOENT printed "exited on a signal", which reads
1618
+ // like the plugin installer crashed rather than "that command is not here".
1619
+ // The distinction matters because only one of them is the operator's to fix,
1620
+ // and the fix is to run it somewhere the CLI exists.
1621
+ if (run.error) {
1622
+ const missing = run.error.code === "ENOENT";
1623
+ result.failures.push(missing
1624
+ ? `${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.`
1625
+ : `${label} could not start: ${run.error.message}`);
1626
+ }
1627
+ else if (run.status === 0) {
775
1628
  result.ran.push(label);
776
- else
777
- result.failures.push(`${label} exited ${run.status ?? "on a signal"}`);
1629
+ }
1630
+ else if (run.signal) {
1631
+ result.failures.push(`${label} was killed by ${run.signal}`);
1632
+ }
1633
+ else {
1634
+ result.failures.push(`${label} exited with code ${run.status}`);
1635
+ }
778
1636
  }
779
1637
  return result;
780
1638
  }
@@ -795,12 +1653,19 @@ async function verifyRegistration(privkey, endpoint) {
795
1653
  return { state: "unknown", reason: err instanceof Error ? err.message : String(err) };
796
1654
  }
797
1655
  }
798
- /** y/N confirmation. Anything but an explicit yes is a no. */
799
- async function confirm(question) {
1656
+ /**
1657
+ * Confirmation prompt. Defaults to NO — anything but an explicit yes is a no —
1658
+ * except where `defaultYes` is set, which is only for questions where the safe
1659
+ * answer is also the common one (reusing the key already on this machine).
1660
+ */
1661
+ async function confirm(question, defaultYes = false) {
800
1662
  const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
801
1663
  const rl = createInterface({ input: process.stdin, output: process.stdout });
802
1664
  const answer = await new Promise((r) => rl.question(question, (a) => { rl.close(); r(a); }));
803
- return /^y(es)?$/i.test(answer.trim());
1665
+ const trimmed = answer.trim();
1666
+ if (!trimmed)
1667
+ return defaultYes;
1668
+ return /^y(es)?$/i.test(trimmed);
804
1669
  }
805
1670
  // ── The command ─────────────────────────────────────────────────────────────
806
1671
  function registerSetupCommand(program) {
@@ -812,6 +1677,7 @@ function registerSetupCommand(program) {
812
1677
  .option("--keys-dir <dir>", "Directory holding the key file, e.g. ~/Downloads")
813
1678
  .option("--host <url>", "Atbash deployment to check the agent's registration against")
814
1679
  .option("--runtime <ids...>", "Only configure these runtimes (currently: openclaw)")
1680
+ .option("--org-name <name>", "Organization the agent was onboarded under, exactly as the dashboard shows it — the plugin resolves which chain to query from this")
815
1681
  .option("--dry-run", "Show exactly which files would change, and the diffs, then exit WITHOUT writing anything")
816
1682
  .option("-y, --yes", "Do not ask for confirmation before writing")
817
1683
  .option("--no-install", "Do not install any package; write the key file and configs only")
@@ -852,7 +1718,7 @@ function registerSetupCommand(program) {
852
1718
  console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
853
1719
  // ── Registration check. Only the public key crosses the network.
854
1720
  if (!opts.skipVerify) {
855
- const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || connect_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
1721
+ const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
856
1722
  let hostname = "";
857
1723
  try {
858
1724
  hostname = new URL(endpoint).hostname.toLowerCase();
@@ -864,7 +1730,8 @@ function registerSetupCommand(program) {
864
1730
  // An unrecognized host could answer "registered" for any key, which is
865
1731
  // exactly the confirmation this check exists to provide. Exact hostname
866
1732
  // match, never a suffix — "atbash.ai.evil.com" must not pass.
867
- if (!connect_1.KNOWN_HOSTS.has(hostname) && !opts.allowUnrecognizedHost) {
1733
+ const recognizedHost = atbash_targets_1.KNOWN_HOSTS.has(hostname);
1734
+ if (!recognizedHost && !opts.allowUnrecognizedHost) {
868
1735
  console.error(chalk_1.default.red(`\n ${hostname} is not a recognized Atbash deployment.`) +
869
1736
  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
1737
  process.exit(1);
@@ -872,7 +1739,21 @@ function registerSetupCommand(program) {
872
1739
  const verdict = await verifyRegistration(privkey, endpoint);
873
1740
  if (verdict.state === "unregistered") {
874
1741
  console.error(chalk_1.default.red("\n That agent is not registered on this deployment.") +
875
- chalk_1.default.dim(`\n Public key: ${pubkey}\n Finish onboarding first — wiring a runtime to an unregistered agent leaves it\n looking governed while the plugin can never get a verdict.\n`));
1742
+ chalk_1.default.dim(`\n Public key: ${pubkey}\n Key came from: ${keySource.from}\n`));
1743
+ // The likeliest cause is not "you skipped onboarding" — it is "this is a
1744
+ // key you did not choose". Say so when the key was found rather than
1745
+ // supplied, because the fix is completely different.
1746
+ if (/existing key file|atbash config/.test(keySource.from)) {
1747
+ console.error(chalk_1.default.yellow(" This is a key that was already on this machine, not one you supplied.") +
1748
+ chalk_1.default.dim("\n If you are onboarding a DIFFERENT agent, pass its key explicitly:" +
1749
+ "\n --key <64-hex> the key shown in the browser" +
1750
+ "\n --keys-dir ~/Downloads the key file you saved" +
1751
+ "\n Or re-run and answer 'n' when asked whether to use the existing key.\n"));
1752
+ }
1753
+ else {
1754
+ console.error(chalk_1.default.dim(" Finish onboarding first — wiring a runtime to an unregistered agent leaves it\n" +
1755
+ " looking governed while the plugin can never get a verdict.\n"));
1756
+ }
876
1757
  process.exit(1);
877
1758
  }
878
1759
  if (verdict.state === "unknown") {
@@ -883,9 +1764,22 @@ function registerSetupCommand(program) {
883
1764
  return;
884
1765
  }
885
1766
  }
886
- else {
1767
+ else if (recognizedHost) {
887
1768
  console.log(chalk_1.default.green(` Agent is registered on ${hostname}.`));
888
1769
  }
1770
+ else {
1771
+ // --allow-unrecognized-host is a real bypass, and its most dangerous
1772
+ // property is that the check still PRINTS a reassuring answer. A host
1773
+ // chosen by an attacker returns "registered" for any key at all, so a
1774
+ // "✓ registered" line here would be the attacker's own claim wearing
1775
+ // Atbash's voice. Never let that line stand unqualified: say the answer
1776
+ // came from an unvouched-for server, so a talked-into-it operator sees
1777
+ // the one thing that would tell them something is wrong.
1778
+ console.log(chalk_1.default.yellow(` ${hostname} answered "registered" — but this is NOT a recognized Atbash deployment.`));
1779
+ console.log(chalk_1.default.yellow(" A registration check against an unrecognized host proves nothing: any server") +
1780
+ chalk_1.default.yellow("\n can answer \"registered\" for any key. Treat this as UNVERIFIED."));
1781
+ console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
1782
+ }
889
1783
  }
890
1784
  // ── Plan, show, then (maybe) apply.
891
1785
  const plan = buildPlan({
@@ -894,7 +1788,18 @@ function registerSetupCommand(program) {
894
1788
  pubkey,
895
1789
  noInstall: opts.install === false,
896
1790
  only: opts.runtime ?? [],
1791
+ orgName: opts.orgName,
1792
+ // The same host the registration check used, so the plugin build and the
1793
+ // chain the agent lives on cannot disagree.
1794
+ endpoint: opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST,
897
1795
  });
1796
+ // Private-chain orgs need it and it cannot be derived here, so say so
1797
+ // rather than writing a config that resolves the wrong chain in silence.
1798
+ if (!opts.orgName?.trim() && plan.found.includes("OpenClaw")) {
1799
+ console.log(chalk_1.default.dim("\n No --org-name given. The OpenClaw plugin resolves which chain to query from it,") +
1800
+ chalk_1.default.dim("\n so add it yourself if your org is on a private chain (harmless otherwise):") +
1801
+ chalk_1.default.dim("\n plugins.entries.atbash-openclaw.config.orgName\n"));
1802
+ }
898
1803
  renderPlan(plan, pubkey);
899
1804
  const changes = plan.steps.filter((s) => s.kind === "write" || s.kind === "exec");
900
1805
  if (dryRun) {
@@ -933,6 +1838,36 @@ function registerSetupCommand(program) {
933
1838
  process.exitCode = 1;
934
1839
  return;
935
1840
  }
1841
+ // ── Check, do not assume.
1842
+ //
1843
+ // Every step reporting success is not the same as the runtime having
1844
+ // picked the plugin up, and this command used to print "Done" on the
1845
+ // strength of the former. On a real machine the install succeeded, the
1846
+ // entry point was correct, and Hermes still listed nothing — so the agent
1847
+ // was ungoverned behind a green summary. Where a runtime can be asked, ask.
1848
+ if (plan.found.includes("Hermes") && wantedRuntime("hermes", opts.runtime ?? [])) {
1849
+ const state = hermesPluginState(home);
1850
+ if (state === "configured") {
1851
+ console.log(chalk_1.default.green("\n Hermes is configured to load the plugin.") +
1852
+ chalk_1.default.dim("\n It takes effect on the next Hermes start. Confirm the hook registered:") +
1853
+ chalk_1.default.dim("\n tail -n 50 ~/.hermes/logs/agent.log | grep -i atbash") +
1854
+ chalk_1.default.dim("\n (`hermes plugins list` will NOT show it — that listing only walks plugin") +
1855
+ chalk_1.default.dim("\n directories and cannot see a pip-installed one.)\n"));
1856
+ }
1857
+ else if (state === "not-enabled") {
1858
+ console.log(chalk_1.default.yellow("\n The plugin is installed but NOT on Hermes' enabled allow-list — this agent is not governed.") +
1859
+ chalk_1.default.dim("\n Hermes loads only what is listed. Add it to ~/.hermes/config.yaml:") +
1860
+ chalk_1.default.dim("\n plugins:\n enabled:\n - " + HERMES_PLUGIN_ENTRY + "\n"));
1861
+ process.exitCode = 1;
1862
+ return;
1863
+ }
1864
+ else if (state === "not-installed") {
1865
+ console.log(chalk_1.default.yellow("\n The plugin is not installed in the interpreter that runs Hermes — this agent is not governed.\n"));
1866
+ process.exitCode = 1;
1867
+ return;
1868
+ }
1869
+ // "unknown": nothing readable to judge by, so claim nothing.
1870
+ }
936
1871
  console.log(chalk_1.default.green("\n Done.") + chalk_1.default.dim(" Restart the runtime so it loads the hook, then re-scan this machine"));
937
1872
  console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
938
1873
  });