@atbash/cli 0.6.0-dev.1 → 0.6.1-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.
Files changed (42) hide show
  1. package/README.md +57 -8
  2. package/dist/bin/atbash.js +6 -3
  3. package/dist/bin/atbash.js.map +1 -1
  4. package/dist/commands/config-cmd.js +15 -15
  5. package/dist/commands/config-cmd.js.map +1 -1
  6. package/dist/commands/connect.d.ts +84 -24
  7. package/dist/commands/connect.js +289 -73
  8. package/dist/commands/connect.js.map +1 -1
  9. package/dist/commands/held.js +2 -2
  10. package/dist/commands/held.js.map +1 -1
  11. package/dist/commands/history.js +1 -1
  12. package/dist/commands/history.js.map +1 -1
  13. package/dist/commands/judge.js +2 -2
  14. package/dist/commands/judge.js.map +1 -1
  15. package/dist/commands/mcp-cmd.d.ts +13 -0
  16. package/dist/commands/mcp-cmd.js +216 -0
  17. package/dist/commands/mcp-cmd.js.map +1 -0
  18. package/dist/commands/policy.js +1 -1
  19. package/dist/commands/policy.js.map +1 -1
  20. package/dist/commands/setup.d.ts +390 -8
  21. package/dist/commands/setup.js +1873 -164
  22. package/dist/commands/setup.js.map +1 -1
  23. package/dist/commands/stats.js +1 -1
  24. package/dist/commands/stats.js.map +1 -1
  25. package/dist/commands/status.js +1 -1
  26. package/dist/commands/status.js.map +1 -1
  27. package/dist/commands/tier.js +1 -1
  28. package/dist/commands/tier.js.map +1 -1
  29. package/dist/commands/tools.js +3 -3
  30. package/dist/commands/tools.js.map +1 -1
  31. package/dist/commands/whoami.js +1 -1
  32. package/dist/commands/whoami.js.map +1 -1
  33. package/dist/shared/atbash-targets.d.ts +49 -0
  34. package/dist/shared/atbash-targets.js +63 -0
  35. package/dist/shared/atbash-targets.js.map +1 -0
  36. package/dist/shared/openclaw-runtime.d.ts +221 -0
  37. package/dist/shared/openclaw-runtime.js +476 -0
  38. package/dist/shared/openclaw-runtime.js.map +1 -0
  39. package/dist/shared/win-exec.d.ts +63 -0
  40. package/dist/shared/win-exec.js +147 -0
  41. package/dist/shared/win-exec.js.map +1 -0
  42. package/package.json +3 -2
@@ -42,7 +42,22 @@ exports.keyCandidatesInDir = keyCandidatesInDir;
42
42
  exports.resolveKeySource = resolveKeySource;
43
43
  exports.keyFileContents = keyFileContents;
44
44
  exports.isJsonc = isJsonc;
45
+ exports.openclawEntryDecision = openclawEntryDecision;
45
46
  exports.mergeOpenclawConfig = mergeOpenclawConfig;
47
+ exports.openclawInstallState = openclawInstallState;
48
+ exports.detectIndent = detectIndent;
49
+ exports.serializeLike = serializeLike;
50
+ exports.detectMcpClients = detectMcpClients;
51
+ exports.findHermesPython = findHermesPython;
52
+ exports.wantedRuntime = wantedRuntime;
53
+ exports.mergeHermesEnabledPlugins = mergeHermesEnabledPlugins;
54
+ exports.hermesGatewayRunning = hermesGatewayRunning;
55
+ exports.hermesPluginState = hermesPluginState;
56
+ exports.pythonInstallStrategy = pythonInstallStrategy;
57
+ exports.mergeHermesEnv = mergeHermesEnv;
58
+ exports.hadInlineKey = hadInlineKey;
59
+ exports.mergeMcpServer = mergeMcpServer;
60
+ exports.openclawPatchPayload = openclawPatchPayload;
46
61
  exports.buildPlan = buildPlan;
47
62
  exports.lineDiff = lineDiff;
48
63
  exports.renderPlan = renderPlan;
@@ -53,10 +68,13 @@ const fs = __importStar(require("fs"));
53
68
  const os = __importStar(require("os"));
54
69
  const path = __importStar(require("path"));
55
70
  const child_process_1 = require("child_process");
71
+ const win_exec_1 = require("../shared/win-exec");
56
72
  const chalk_1 = __importDefault(require("chalk"));
57
73
  const jsonc = __importStar(require("jsonc-parser"));
74
+ const yaml_1 = require("yaml");
58
75
  const sdk_1 = require("@atbash/sdk");
59
- const connect_1 = require("./connect");
76
+ const atbash_targets_1 = require("../shared/atbash-targets");
77
+ const openclaw_runtime_1 = require("../shared/openclaw-runtime");
60
78
  /**
61
79
  * `atbash setup` — the write half of onboarding.
62
80
  *
@@ -84,9 +102,11 @@ const connect_1 = require("./connect");
84
102
  * 2. It never writes the private key into an MCP client config. The documented
85
103
  * `@atbash/mcp` wiring passes the key as an `ATBASH_AGENT_PRIVKEY` env value
86
104
  * 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.
105
+ * and share, and that package has no key-file fallback (verified against the
106
+ * published 0.1.3). So the entry setup writes points at `atbash mcp`, which
107
+ * reads the 0600 key file and passes the key to the server through the child
108
+ * environment. The config file itself gets NO credential — and an entry that
109
+ * was hand-wired with one has it removed.
90
110
  * 3. It never edits application source. Code-level integrations (LangChain,
91
111
  * LangGraph, AutoGen, Eliza, the SDK boundary) are the owner's to write.
92
112
  * 4. It never rewrites a config file that uses comments or trailing commas.
@@ -109,17 +129,160 @@ const OPENCLAW_CONFIG_REL = [".openclaw", "openclaw.json"];
109
129
  const OPENCLAW_EXTENSIONS_REL = [".openclaw", "extensions"];
110
130
  const HERMES_AGENT_REL = [".hermes", "hermes-agent"];
111
131
  /**
112
- * The OpenClaw plugin, and the entry key it registers itself as.
132
+ * The OpenClaw plugin package.
113
133
  *
114
- * The entry key really is `openclaw` that is what `@atbash/atbash-openclaw`
115
- * registers as, not a copy-paste slip. Installs of the earlier
116
- * `@atbash/atbash-plugin` register under `atbash-plugin`, and the dashboard's
117
- * capability scan recognizes BOTH. So an existing config carrying the legacy key
118
- * is already governed and must not be given a second, duplicate entry.
134
+ * The doc comment here used to assert "the entry key really is `openclaw` not
135
+ * a copy-paste slip", which directly contradicts OPENCLAW_ENTRY three lines
136
+ * below and was the belief that made every config an earlier release wrote inert.
137
+ * Installs of the earlier `@atbash/atbash-plugin` register under
138
+ * `atbash-plugin`, and the dashboard's capability scan recognizes both that and
139
+ * `atbash-openclaw` — so an existing config carrying the legacy key is already
140
+ * governed and must not be given a second, duplicate entry.
119
141
  */
120
142
  const OPENCLAW_PKG = "@atbash/atbash-openclaw";
121
- const OPENCLAW_ENTRY = "openclaw";
143
+ /**
144
+ * The entry key `@atbash/atbash-openclaw` registers as.
145
+ *
146
+ * ⚠️ It is `atbash-openclaw`, NOT `openclaw`. `openclaw` is the id of the RETIRED
147
+ * `@atbash-plugin/openclaw` package, and the published docs are explicit about
148
+ * what happens if you use it: "the plugin id here is `atbash-openclaw`, so a
149
+ * leftover `openclaw` entry silently configures nothing." Earlier releases of
150
+ * this command wrote `openclaw`, so every config they produced was inert — no
151
+ * error, no warning, just a plugin reading a config block nobody looks at.
152
+ */
153
+ const OPENCLAW_ENTRY = "atbash-openclaw";
154
+ /** Installs of the earlier `@atbash/atbash-plugin` register under this. */
122
155
  const OPENCLAW_LEGACY_ENTRY = "atbash-plugin";
156
+ /**
157
+ * Drop the `hooks` block from a plugin entry.
158
+ *
159
+ * ⚠️ WHETHER `hooks` IS VALID DEPENDS ON THE OPENCLAW VERSION, and this note
160
+ * used to state the 2026.2.x answer as a fact about OpenClaw itself.
161
+ *
162
+ * On 2026.2.1 it is not a key at all, and an unrecognized entry key is fatal —
163
+ * verified by feeding a generated config through OPENCLAW_CONFIG_PATH:
164
+ *
165
+ * - plugins.entries.atbash-openclaw: Unrecognized key: "hooks"
166
+ *
167
+ * Rejecting means refusing to load ANY config, which also makes
168
+ * `openclaw plugins install` exit 1. But the key was ADDED later. Straight out
169
+ * of 2026.6.6's own `openclaw config schema`, an entry accepts
170
+ * `enabled | hooks | subagent | llm | config`, and `hooks` accepts
171
+ * `allowPromptInjection | allowConversationAccess | timeoutMs | timeouts`.
172
+ *
173
+ * We still strip it and never write it, now on evidence rather than on the claim
174
+ * above:
175
+ *
176
+ * 1. Nothing under `hooks` is required — the schema's `required` list is empty.
177
+ * 2. It gates hook FAMILIES this plugin does not use. `allowConversationAccess`
178
+ * covers `before_agent_run`, `llm_input`, `llm_output`, `agent_end` and
179
+ * friends; `allowPromptInjection` covers `before_prompt_build`. The plugin
180
+ * registers exactly one hook, `before_tool_call`, which is in neither
181
+ * (verified by grepping the installed plugin, not by reading its README).
182
+ * 3. Writing it would break every 2026.2.x machine for no gain, and those are
183
+ * real — the reference box for these attestations is one.
184
+ *
185
+ * ⚠️ RE-CHECK IF THE PLUGIN EVER ADDS A SECOND HOOK. The schema says
186
+ * "Non-bundled plugins must opt in explicitly", so a new conversation-reading
187
+ * hook would be silently denied on 2026.6.6+ with no config error to notice: the
188
+ * plugin would load, still gate tool calls, and quietly see nothing else.
189
+ */
190
+ function stripInvalidHooks(entry, supported = false) {
191
+ // ⚠️ ONLY WHERE THE KEY IS ACTUALLY INVALID. This used to delete `hooks`
192
+ // unconditionally, which is right on 2026.2.x — where an unrecognized entry key
193
+ // makes OpenClaw refuse the whole config — and destructive on 2026.6.6+, where
194
+ // the key is valid, optional and undeprecated.
195
+ //
196
+ // `hooks.timeoutMs` bounds this plugin's hook latency "without changing plugin
197
+ // code". An operator who sets it has made a deliberate choice, and silently
198
+ // removing it on the next run is not repair — it also contradicts the rule
199
+ // stated further down this file: an existing entry is touched as little as
200
+ // possible.
201
+ //
202
+ // Defaults to `false` (strip), so a caller that cannot determine support gets
203
+ // the fail-safe behaviour. A lost timeout value is a far smaller harm than a
204
+ // config OpenClaw will not load at all.
205
+ if (!supported && "hooks" in entry)
206
+ delete entry.hooks;
207
+ return entry;
208
+ }
209
+ /**
210
+ * The one hook permission worth WRITING: deny this plugin prompt mutation.
211
+ *
212
+ * WHY THIS IS NOT DECORATION. `allowPromptInjection` DEFAULTS TO ALLOWED for
213
+ * non-bundled plugins — OpenClaw's plugin-hooks docs say so in as many words
214
+ * ("It defaults to allowed") — so without this key the guard holds permission to
215
+ * mutate prompts via `before_prompt_build`, `agent_turn_prepare`,
216
+ * `heartbeat_prompt_contribution` and durable next-turn injections. It uses none
217
+ * of them: it registers exactly one hook, `before_tool_call`.
218
+ *
219
+ * The gate is enforced by OPENCLAW, not by the plugin, so the denial holds even
220
+ * if the plugin or one of its dependencies is compromised — and it bundles a
221
+ * fair few (`@atbash/sdk`, the whole `@opentelemetry` tree). A guard being
222
+ * visibly constrained by the runtime it guards is the right posture, and it is
223
+ * auditable: someone reading the config can see the boundary stops at tool calls.
224
+ *
225
+ * ⚠️ It does NOT affect `before_tool_call` — "No conversation-access opt-in is
226
+ * needed for `before_tool_call`", and this gate covers prompt-mutation hooks
227
+ * only. There is no functional risk to what the plugin actually does.
228
+ *
229
+ * ⚠️ AND WE DELIBERATELY DO NOT WRITE `allowConversationAccess: false`. That one
230
+ * already defaults to denied ("Non-bundled plugins must opt in explicitly"), so
231
+ * writing it changes nothing — and writing keys that change nothing is exactly
232
+ * the habit that produced this file's worst bugs: the entry schema is
233
+ * `additionalProperties: false`, so every key we write is a liability we then own
234
+ * on every OpenClaw version. It would also silently deny a future
235
+ * conversation-reading hook, with an explicit `false` in the config to mislead
236
+ * whoever debugs it.
237
+ */
238
+ const HOOK_PERMISSIONS = { allowPromptInjection: false };
239
+ /**
240
+ * Add the denial without disturbing anything the operator set.
241
+ *
242
+ * Fills the key in only when it is ABSENT. An operator who wrote
243
+ * `allowPromptInjection: true` meant it, and overriding a deliberate choice is
244
+ * not this command's business — "touch an existing entry as little as possible"
245
+ * applies to permissions too. Their `timeoutMs`/`timeouts` are untouched either
246
+ * way.
247
+ */
248
+ function withHookPermissions(entry, supported) {
249
+ if (!supported)
250
+ return entry;
251
+ const existing = isRecord(entry.hooks) ? entry.hooks : {};
252
+ return { ...entry, hooks: { ...HOOK_PERMISSIONS, ...existing } };
253
+ }
254
+ /** The retired `@atbash-plugin/openclaw` id — inert if left in a config. */
255
+ const OPENCLAW_RETIRED_ENTRY = "openclaw";
256
+ /**
257
+ * The npm tag to install, chosen from the deployment the agent was onboarded on.
258
+ *
259
+ * The judge endpoint and chain ids are NOT configurable at runtime — they come
260
+ * from the SDK the plugin bundles, so the tag is an environment choice. Verified
261
+ * on the registry: the two tarballs' plugin code is byte-identical and only
262
+ * `package.json` differs —
263
+ *
264
+ * 0.1.14 → @atbash/sdk@0.7.0 → https://atbash.ai BRID 0163241D…
265
+ * 0.1.14-dev.0 → @atbash/sdk@0.10.5-dev.0 → …dev-two.vercel.app BRID 02668C52…
266
+ *
267
+ * Install the wrong one and it loads cleanly, fires its hook, and fails every
268
+ * judge call because the agent does not exist on the chain that build targets.
269
+ * The docs warn that a resolving org name is not proof the build is right, since
270
+ * org names are not unique across environments — so this is derived from --host
271
+ * and nothing else.
272
+ */
273
+ function openclawPackageForHost(endpoint) {
274
+ if (!endpoint)
275
+ return OPENCLAW_PKG; // no host given: production is the safe default
276
+ let hostname = "";
277
+ try {
278
+ hostname = new URL(endpoint).hostname.toLowerCase();
279
+ }
280
+ catch {
281
+ return OPENCLAW_PKG;
282
+ }
283
+ const isProduction = hostname === "atbash.ai" || hostname === "www.atbash.ai";
284
+ return isProduction ? OPENCLAW_PKG : `${OPENCLAW_PKG}@dev`;
285
+ }
123
286
  /** File modes: 0700 for the key directory, 0600 for the key itself. */
124
287
  const DIR_MODE = 0o700;
125
288
  const KEY_MODE = 0o600;
@@ -208,7 +371,45 @@ function normalizePrivkey(raw) {
208
371
  const clean = raw.replace(/^0x/i, "").trim().toLowerCase();
209
372
  return (0, sdk_1.isValidPrivateKey)(clean) ? clean : "";
210
373
  }
211
- /** Files in a directory that plausibly hold an Atbash agent key, newest first. */
374
+ /** Only sniff the contents of small files a key file is a few hundred bytes. */
375
+ const MAX_SNIFF_BYTES = 8 * 1024;
376
+ /** Bound the content-sniff so `--keys-dir ~` cannot turn into a directory crawl. */
377
+ const MAX_SNIFF_FILES = 60;
378
+ /** Newest-first, so a freshly downloaded key wins over one from last month. */
379
+ function newestFirst(files) {
380
+ return files
381
+ .map((file) => {
382
+ let mtime = 0;
383
+ try {
384
+ mtime = fs.statSync(file).mtimeMs;
385
+ }
386
+ catch { /* unreadable — sorts last */ }
387
+ return { file, mtime };
388
+ })
389
+ .sort((a, b) => b.mtime - a.mtime)
390
+ .map((e) => e.file);
391
+ }
392
+ /**
393
+ * Files in a directory that plausibly hold an Atbash agent key, newest first.
394
+ *
395
+ * TWO PASSES, and the second one is the point.
396
+ *
397
+ * By name first — `guard-client-key`, `agent-keys-*.txt` and friends — because
398
+ * matching the name is cheap and unambiguous. But a name-only match is a cliff:
399
+ * rename the download, or export from a wallet UI that picks its own filename,
400
+ * and the operator gets "no key file found" while the key sits right there in the
401
+ * directory they explicitly pointed at.
402
+ *
403
+ * So if no name matches, read the small files and keep the ones that actually
404
+ * PARSE as key material. That is a narrow test — `privkey=`, the documented JSON
405
+ * shape, or a file that is nothing but a 64-hex key — not "contains something
406
+ * hex-looking", so an unrelated file does not get mistaken for an identity.
407
+ *
408
+ * Reading files the operator did not name individually is justified by the flag
409
+ * itself: `--keys-dir` is an explicit instruction to look in that directory. It
410
+ * is bounded to small regular files and a file count, nothing is transmitted, and
411
+ * the caller prints WHICH file it used before doing anything with it.
412
+ */
212
413
  function keyCandidatesInDir(dir) {
213
414
  let names;
214
415
  try {
@@ -217,21 +418,31 @@ function keyCandidatesInDir(dir) {
217
418
  catch {
218
419
  return [];
219
420
  }
220
- const matches = names.filter((n) => n === "guard-client-key" ||
421
+ const byName = names.filter((n) => n === "guard-client-key" ||
221
422
  /^agent-keys-.*\.txt$/i.test(n) ||
222
423
  /^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;
424
+ if (byName.length)
425
+ return newestFirst(byName.map((n) => path.join(dir, n)));
426
+ const byContent = [];
427
+ let examined = 0;
428
+ for (const name of names) {
429
+ if (examined >= MAX_SNIFF_FILES)
430
+ break;
431
+ const file = path.join(dir, name);
227
432
  try {
228
- mtime = fs.statSync(file).mtimeMs;
433
+ const stat = fs.statSync(file);
434
+ if (!stat.isFile() || stat.size === 0 || stat.size > MAX_SNIFF_BYTES)
435
+ continue;
229
436
  }
230
- catch { /* unreadable — sorts last */ }
231
- return { file, mtime };
232
- })
233
- .sort((a, b) => b.mtime - a.mtime)
234
- .map((e) => e.file);
437
+ catch {
438
+ continue;
439
+ }
440
+ examined++;
441
+ const text = readTextFile(file);
442
+ if (text !== null && parseKeyMaterial(text))
443
+ byContent.push(file);
444
+ }
445
+ return newestFirst(byContent);
235
446
  }
236
447
  /**
237
448
  * Read a secret from the terminal without echoing it.
@@ -247,23 +458,56 @@ function keyCandidatesInDir(dir) {
247
458
  async function promptForKeyOrPath() {
248
459
  const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
249
460
  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
461
  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
462
+ // Write the prompt ourselves, THEN suppress every subsequent write.
463
+ //
464
+ // The obvious implementation compares each write against the prompt text and
465
+ // lets that one through but a pasted value containing the prompt as a
466
+ // substring would then be echoed to the terminal, which is exactly the
467
+ // failure this mute exists to prevent. Emitting the prompt up front means the
468
+ // suppressor never has to decide what a write IS: after this point, nothing
469
+ // is echoed, unconditionally.
470
+ process.stdout.write(prompt);
471
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
472
+ rl._writeToOutput = () => {
473
+ /* nothing typed after the prompt is ever echoed */
263
474
  };
264
- rl.question(prompt, (answer) => { rl.close(); resolveP(answer.trim()); });
475
+ rl.question("", (answer) => {
476
+ rl.close();
477
+ // readline's own newline was suppressed along with everything else, so the
478
+ // next line of output would otherwise land on the prompt line.
479
+ process.stdout.write("\n");
480
+ resolveP(answer.trim());
481
+ });
265
482
  });
266
483
  }
484
+ /**
485
+ * Interpret whatever someone typed at the prompt: a raw key, a file, or a
486
+ * directory to search. Shared by the "no key found" prompt and the "use the
487
+ * existing key?" prompt so both accept the same things — an operator who can
488
+ * paste a path in one place should not find it rejected in the other.
489
+ */
490
+ async function keyFromAnswer(answer, home) {
491
+ const direct = parseKeyMaterial(answer);
492
+ if (direct)
493
+ return { material: direct, from: "key entered at the prompt" };
494
+ const asPath = expandHome(answer, home);
495
+ if (!exists(asPath))
496
+ return null;
497
+ let isDir = false;
498
+ try {
499
+ isDir = fs.statSync(asPath).isDirectory();
500
+ }
501
+ catch {
502
+ return null;
503
+ }
504
+ const file = isDir ? keyCandidatesInDir(asPath)[0] : asPath;
505
+ if (!file)
506
+ return null;
507
+ const text = readTextFile(file);
508
+ const material = text === null ? null : parseKeyMaterial(text);
509
+ return material ? { material, from: `${file} (given at the prompt)` } : null;
510
+ }
267
511
  /**
268
512
  * Find the agent key, trying every way an owner could plausibly have it.
269
513
  *
@@ -297,7 +541,15 @@ async function resolveKeySource(opts) {
297
541
  const dir = expandHome(opts.keysDir, home);
298
542
  const candidates = keyCandidatesInDir(dir);
299
543
  if (!candidates.length) {
300
- return { error: `No agent key file found in ${dir} (looked for guard-client-key and agent-keys-*.txt).` };
544
+ return {
545
+ error: [
546
+ `No agent key found in ${dir}.`,
547
+ "Looked for guard-client-key / agent-keys-*.txt by name, then read the small",
548
+ "files there to see if any parsed as an agent key. Neither found one.",
549
+ "Point at the file directly with --key-file, or paste the key with no flags",
550
+ "at all and setup will prompt for it.",
551
+ ].join("\n"),
552
+ };
301
553
  }
302
554
  return fromFile(candidates[0], "--keys-dir");
303
555
  }
@@ -309,11 +561,39 @@ async function resolveKeySource(opts) {
309
561
  return { material, from: "ATBASH_AGENT_KEY" };
310
562
  }
311
563
  // 5. Already in the canonical place — a re-run, or a machine set up before.
564
+ //
565
+ // This used to be taken SILENTLY, and that was a trap. A machine that has ever
566
+ // governed one agent already has a key here, so onboarding a SECOND agent
567
+ // picked up the first one's key and then failed registration against a pubkey
568
+ // the operator never chose:
569
+ //
570
+ // Agent key source: existing key file (~/.config/atbash/guard-client-key)
571
+ // That agent is not registered on this deployment.
572
+ //
573
+ // which reads as "onboarding is broken" rather than "I used a different key
574
+ // than you meant". So when there is someone to ask, ask — and make the existing
575
+ // key the easy answer for the common case (a genuine re-run) without making it
576
+ // the only answer.
312
577
  const keyFile = path.join(home, ...KEY_FILE_REL);
313
578
  if (exists(keyFile)) {
314
579
  const found = fromFile(keyFile, "existing key file");
315
- if (!("error" in found))
316
- return found;
580
+ if (!("error" in found)) {
581
+ if (!opts.allowPrompt)
582
+ return found; // non-interactive: same as before
583
+ const existingPub = (0, sdk_1.derivePublicKey)(found.material.privkey);
584
+ process.stdout.write(`\n This machine already has an agent key at ${keyFile}\n` +
585
+ ` Public key: ${existingPub}\n`);
586
+ const useExisting = await confirm(" Use that key? [Y/n] ", true);
587
+ if (useExisting)
588
+ return found;
589
+ const answer = await promptForKeyOrPath();
590
+ if (!answer)
591
+ return { error: "No key provided." };
592
+ const supplied = await keyFromAnswer(answer, home);
593
+ if (supplied)
594
+ return supplied;
595
+ return { error: "That is neither a 64-hex private key nor a path that exists." };
596
+ }
317
597
  }
318
598
  // 6. The CLI's own config, populated by `atbash set agent-key`.
319
599
  const fromConfig = (0, sdk_1.resolve)("agentKey");
@@ -327,20 +607,9 @@ async function resolveKeySource(opts) {
327
607
  const answer = await promptForKeyOrPath();
328
608
  if (!answer)
329
609
  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
- }
610
+ const supplied = await keyFromAnswer(answer, home);
611
+ if (supplied)
612
+ return supplied;
344
613
  return { error: "That is neither a 64-hex private key nor a path that exists." };
345
614
  }
346
615
  return {
@@ -383,62 +652,865 @@ function isJsonc(text) {
383
652
  *
384
653
  * A MERGE, not a replacement — that distinction is the whole reason this command
385
654
  * exists. Other plugins already in `allow`, `load.paths` and `entries` are
386
- * preserved, and an entry from the legacy `@atbash/atbash-plugin` install is
387
- * updated where it stands rather than being shadowed by a duplicate: the
388
- * dashboard scan recognizes both keys, so two entries would mean two hooks.
655
+ * preserved.
656
+ *
657
+ * WHICH entry is governed depends on what is being installed. When this run
658
+ * installs `@atbash/atbash-openclaw` — every run that is not `--no-install` —
659
+ * the plugin that ends up on disk registers as `atbash-openclaw`, so that is the
660
+ * entry that gets the config, and a legacy `atbash-plugin` entry is STOOD DOWN
661
+ * rather than configured. Only a `--no-install` run on a box whose sole entry is
662
+ * the legacy one keeps writing to the legacy key: there, nothing is arriving to
663
+ * replace it, and switching it off would leave the machine unguarded.
389
664
  *
390
665
  * `load.paths` gets the real absolute extension path. The published docs show a
391
666
  * `<your-username>` placeholder that people paste verbatim, producing a path that
392
667
  * does not exist and a plugin that never loads.
393
668
  */
394
- function mergeOpenclawConfig(config, home) {
669
+ /**
670
+ * WHICH entry this run governs, and whether a legacy one is being stood down.
671
+ *
672
+ * Extracted so the hand-merge and the `openclaw config patch` payload cannot
673
+ * disagree. Two implementations of this decision is exactly how a build ended up
674
+ * writing the modern plugin's `orgName` onto the LEGACY entry — whose schema is
675
+ * closed and has no such field — so OpenClaw rejected the whole file and the run
676
+ * broke its own install step.
677
+ *
678
+ * An entry is not the only way the legacy plugin is present: `plugins.installs`
679
+ * records it independently, and an installed, allowed plugin loads with its
680
+ * defaults whether or not anyone wrote an entry for it.
681
+ */
682
+ function openclawEntryDecision(config, opts = {}) {
683
+ const installsModern = opts.installsModern !== false;
684
+ const plugins = isRecord(config.plugins) ? config.plugins : {};
685
+ const entries = isRecord(plugins.entries) ? plugins.entries : {};
686
+ const installs = isRecord(plugins.installs) ? plugins.installs : {};
687
+ const legacy = OPENCLAW_LEGACY_ENTRY in entries || OPENCLAW_LEGACY_ENTRY in installs;
688
+ const modern = OPENCLAW_ENTRY in entries;
689
+ // Only a --no-install run on a box whose sole entry is the legacy one keeps
690
+ // writing to the legacy key: there, nothing is arriving to replace it, and
691
+ // switching it off would leave the machine unguarded.
692
+ const useLegacy = !installsModern && legacy && !modern;
693
+ return {
694
+ entryKey: useLegacy ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY,
695
+ useLegacy,
696
+ legacy,
697
+ standDownLegacy: !useLegacy && legacy,
698
+ };
699
+ }
700
+ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
701
+ const installsModern = opts.installsModern !== false;
702
+ // Whether THIS machine's OpenClaw accepts `hooks` on a plugin entry. Probed
703
+ // from `openclaw config schema` by the caller; absent means "assume not", which
704
+ // is the fail-safe direction. See stripInvalidHooks.
705
+ const hooksSupported = opts.hooksSupported === true;
395
706
  const out = { ...config };
396
707
  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
708
  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;
403
- const allow = Array.isArray(plugins.allow) ? [...plugins.allow] : [];
404
- if (!allow.includes(entryKey))
405
- allow.push(entryKey);
406
- plugins.allow = allow;
407
- const load = { ...(isRecord(plugins.load) ? plugins.load : {}) };
408
- const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY);
409
- const paths = Array.isArray(load.paths) ? [...load.paths] : [];
410
- if (!paths.includes(extensionPath))
411
- paths.push(extensionPath);
412
- load.paths = paths;
413
- 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
- entries[entryKey] = {
420
- ...existing,
709
+ // Which entry is this install governed under, and does one already exist?
710
+ //
711
+ // ⚠️ `legacy && !modern` used to be enough to pick the legacy key, and it broke
712
+ // a real machine. Setup installs `@atbash/atbash-openclaw`, then wrote that
713
+ // plugin's config — `orgName` included — onto the LEGACY `atbash-plugin`
714
+ // entry. The legacy plugin's manifest declares `"additionalProperties": false`
715
+ // and knows no `orgName`, so OpenClaw rejected the whole file —
716
+ //
717
+ // Invalid config at ~/.openclaw/openclaw.json:
718
+ // - plugins.entries.atbash-plugin.config: invalid config: <root>:
719
+ // must NOT have additional properties
720
+ //
721
+ // — refused to load any config, and `openclaw plugins install` then exited 1.
722
+ // The run broke its own install step and half-applied, while the plugin it did
723
+ // install sat there with no config at all.
724
+ //
725
+ // An entry is not the only way the legacy plugin is present: `plugins.installs`
726
+ // records it independently, and a plugin that is installed and allowed loads
727
+ // with its defaults whether or not anyone wrote an entry for it.
728
+ const { entryKey, useLegacy, legacy } = openclawEntryDecision(config, { installsModern });
729
+ const existing = isRecord(entries[entryKey]) ? entries[entryKey] : null;
730
+ // Stand the legacy plugin down when the modern one is taking over. Two live
731
+ // entries means two hooks, so every tool call would be judged twice — against
732
+ // two different SDK versions.
733
+ //
734
+ // The entry is reduced to `{ enabled: false }`. Leaving its `config` behind as
735
+ // a way back looks considerate and is not: OpenClaw then warns
736
+ //
737
+ // - plugins.entries.atbash-plugin: plugin disabled (not in allowlist) but
738
+ // config is present
739
+ //
740
+ // on EVERY config load, and it loads many times per command — a real machine
741
+ // printed that line hundreds of times for a single `openclaw plugins install`,
742
+ // burying the output the operator was reading. The way back is the timestamped
743
+ // `.atbash-bak` this command already writes beside the file.
744
+ if (!useLegacy && legacy) {
745
+ entries[OPENCLAW_LEGACY_ENTRY] = { enabled: false };
746
+ }
747
+ // ⚠️ AN EXISTING ENTRY IS TOUCHED AS LITTLE AS POSSIBLE.
748
+ //
749
+ // This function used to assert the full documented shape onto whatever it
750
+ // found, which broke a real machine: a legacy `atbash-plugin` install accepts
751
+ // only `config` and `enabled`, so writing the `hooks` block that the NEW
752
+ // `openclaw` entry documents made the whole file invalid —
753
+ //
754
+ // Invalid config at ~/.openclaw/openclaw.json:
755
+ // - plugins.entries.atbash-plugin: Unrecognized key: "hooks"
756
+ //
757
+ // and OpenClaw then refused to load any config at all. It also rewrote an
758
+ // absolute `chromiaSecretPath` to a `~` one for no gain.
759
+ //
760
+ // A plugin that is already installed and registered does not need `load`
761
+ // re-declared — it is working. The only thing setup has any business changing
762
+ // is that it is switched on, allowed, and pointed at the right key.
763
+ if (existing) {
764
+ const existingConfig = isRecord(existing.config) ? existing.config : {};
765
+ // The exception to leaving an existing entry alone: strip `hooks`. "Touch as
766
+ // little as possible" is the right rule, but it must not mean stepping
767
+ // politely around damage this tool caused — a later run would leave the
768
+ // machine broken forever and report nothing wrong.
769
+ const repaired = withHookPermissions(stripInvalidHooks({ ...existing }, hooksSupported), hooksSupported);
770
+ entries[entryKey] = {
771
+ ...repaired,
772
+ enabled: true,
773
+ config: {
774
+ ...existingConfig,
775
+ enabled: true,
776
+ enforceDecision: true,
777
+ ...keyPathUpdate(existingConfig, home),
778
+ // `orgName` exists only in the modern plugin's configSchema. The legacy
779
+ // one closes with `"additionalProperties": false`, so writing it there
780
+ // invalidates the entire file — the failure this whole branch guards.
781
+ ...(entryKey === OPENCLAW_LEGACY_ENTRY ? {} : orgNameUpdate(existingConfig, orgName)),
782
+ },
783
+ };
784
+ plugins.entries = entries;
785
+ // An allowlist that does not name this entry means the plugin never loads,
786
+ // however well configured it is — the modern entry arriving on a box whose
787
+ // `allow` still says only `atbash-plugin` is exactly that. Only ever an
788
+ // adjustment to a list that already exists: conjuring one where there was
789
+ // none would newly restrict every other plugin on the machine.
790
+ const reconciled = reconcileAllow(plugins.allow, entryKey, !useLegacy && legacy);
791
+ if (reconciled)
792
+ plugins.allow = reconciled;
793
+ const prunedLoad = pruneRetiredLoadPath(plugins.load, home);
794
+ if (prunedLoad)
795
+ plugins.load = prunedLoad;
796
+ out.plugins = plugins;
797
+ return out;
798
+ }
799
+ // No entry yet — configure the ENTRY, and nothing else.
800
+ //
801
+ // ⚠️ THIS IS THE FRESH-INSTALL PATH, AND IT USED TO CREATE BOTH
802
+ // `plugins.allow` AND `plugins.load.paths`. That is the config a reporter got
803
+ // on a clean OpenClaw 2026.6.6, which then refused to load it:
804
+ //
805
+ // - plugins.load.paths: plugin path not found:
806
+ // /Users/me/.openclaw/extensions/atbash-openclaw
807
+ // - plugins.allow: plugins.allow now gates bundled provider discovery by
808
+ // default; run "openclaw doctor --fix" …
809
+ //
810
+ // `load.paths` was the fatal half, and note WHY it could never have worked: it
811
+ // named `~/.openclaw/extensions/<id>`, which is the 2026.2.x plugin store.
812
+ // 2026.6.x installs plugins as npm dependencies of `~/.openclaw` — into
813
+ // `~/.openclaw/node_modules` — and leaves `extensions/` empty. So we wrote a
814
+ // path OpenClaw was never going to create, and a `load.paths` entry naming a
815
+ // missing directory fails the ENTIRE config, which then takes down the
816
+ // `openclaw plugins install` in this very same run. The command broke its own
817
+ // install step and left behind a dangling entry that `openclaw doctor --fix`
818
+ // then offers to delete — silently un-governing the agent.
819
+ //
820
+ // Neither key is ours to write:
821
+ //
822
+ // - `openclaw plugins install` records where it put the plugin and loads from
823
+ // that record. The location has moved twice in six months across four
824
+ // channels; we cannot know it and must not guess it.
825
+ // - `allow` only matters if the operator KEEPS such a list, and newer builds
826
+ // have repurposed it to gate bundled provider discovery. Creating one where
827
+ // there was none newly restricts every other plugin on the machine — a side
828
+ // effect nobody asked this command for.
829
+ //
830
+ // Existing lists are still MAINTAINED, here and in the branch above, because an
831
+ // `allow` list that omits this entry means the plugin never loads however well
832
+ // configured it is. Adjust what is there; conjure nothing.
833
+ const reconciledAllow = reconcileAllow(plugins.allow, entryKey, !useLegacy && legacy);
834
+ if (reconciledAllow)
835
+ plugins.allow = reconciledAllow;
836
+ // A retired path is still pruned when present: that entry is fatal on every
837
+ // version, so removing it is repair rather than assertion.
838
+ const prunedLoad = pruneRetiredLoadPath(plugins.load, home);
839
+ if (prunedLoad)
840
+ plugins.load = prunedLoad;
841
+ // `orgName` is the modern plugin's field. This branch can still land on the
842
+ // legacy key — a `--no-install` run against a box where the old plugin is in
843
+ // `plugins.installs` but nobody ever wrote it an entry — and its configSchema
844
+ // is closed, so the field would invalidate the file.
845
+ //
846
+ // No `hooks` block: see OPENCLAW_ENTRY_KEYS. It is not a key OpenClaw has.
847
+ entries[entryKey] = withHookPermissions({
421
848
  enabled: true,
422
849
  config: {
423
- ...existingConfig,
424
850
  enabled: true,
425
851
  enforceDecision: true,
426
- // The path, not the key. This is the whole point of the canonical location.
427
852
  chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}`,
853
+ ...(entryKey !== OPENCLAW_LEGACY_ENTRY && orgName?.trim() ? { orgName: orgName.trim() } : {}),
428
854
  },
429
- hooks: {
430
- ...existingHooks,
431
- allowConversationAccess: true,
432
- allowPromptInjection: true,
433
- },
434
- };
855
+ }, hooksSupported);
435
856
  plugins.entries = entries;
436
857
  out.plugins = plugins;
437
858
  return out;
438
859
  }
860
+ /**
861
+ * The key-path field, but only when it actually needs changing.
862
+ *
863
+ * `/Users/me/.config/atbash/guard-client-key` and `~/.config/atbash/guard-client-key`
864
+ * are the same file, and the plugin expands `~`. Rewriting one into the other is
865
+ * a diff the operator has to read and approve for no behavioural change, so an
866
+ * existing value that already resolves to the canonical key file is left exactly
867
+ * as they wrote it.
868
+ */
869
+ /**
870
+ * `orgName`, but only when we have one and it differs from what is there.
871
+ *
872
+ * The plugin resolves WHICH CHAIN to query from this, so it is required for orgs
873
+ * on a private chain and harmless otherwise. It cannot be derived on the machine
874
+ * — it has to match the dashboard exactly — so it arrives via --org-name, which
875
+ * the post-deploy screen fills in because the browser already knows it.
876
+ *
877
+ * Never overwrites an existing value with nothing: a run without the flag must
878
+ * not silently strip an orgName someone already has working.
879
+ */
880
+ function orgNameUpdate(existingConfig, orgName) {
881
+ const wanted = orgName?.trim();
882
+ if (!wanted)
883
+ return {};
884
+ return existingConfig.orgName === wanted ? {} : { orgName: wanted };
885
+ }
886
+ /**
887
+ * `plugins.allow`, adjusted — or `undefined` when there is no list to adjust.
888
+ *
889
+ * OpenClaw loads a plugin only if the allowlist names it, so an entry that is
890
+ * perfectly configured under a name the list does not carry does nothing at all.
891
+ * That is the state a legacy box lands in the moment the modern entry appears:
892
+ * `allow` still reads `["atbash-plugin"]`.
893
+ *
894
+ * Returns `undefined` rather than a fresh array when no list exists, because an
895
+ * allowlist conjured out of nothing does not just permit this plugin — it newly
896
+ * restricts every other plugin on the machine.
897
+ */
898
+ function reconcileAllow(current, entryKey, dropLegacy) {
899
+ if (!Array.isArray(current))
900
+ return undefined;
901
+ const allow = current.filter((name) => !(dropLegacy && String(name) === OPENCLAW_LEGACY_ENTRY));
902
+ if (!allow.some((name) => String(name) === entryKey))
903
+ allow.push(entryKey);
904
+ return allow;
905
+ }
906
+ /**
907
+ * `plugins.load`, with a path pointing at the retired plugin id dropped.
908
+ *
909
+ * `@atbash-plugin/openclaw` registered as `openclaw` and no longer publishes, so
910
+ * `<home>/.openclaw/extensions/openclaw` is a directory that cannot exist. A
911
+ * `load.paths` entry naming a missing directory is not inert — OpenClaw fails
912
+ * the whole config on it:
913
+ *
914
+ * - plugins.load.paths: plugin: plugin path not found:
915
+ * /Users/me/.openclaw/extensions/openclaw
916
+ *
917
+ * which then takes down `openclaw plugins install` along with everything else.
918
+ * Only ever removes that one exact path; any other path is the operator's.
919
+ */
920
+ function pruneRetiredLoadPath(current, home) {
921
+ if (!isRecord(current))
922
+ return undefined;
923
+ if (!Array.isArray(current.paths))
924
+ return current;
925
+ const retired = path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_RETIRED_ENTRY);
926
+ const paths = current.paths.filter((p) => typeof p !== "string" || path.resolve(expandHome(p.trim(), home)) !== path.resolve(retired));
927
+ return paths.length === current.paths.length ? current : { ...current, paths };
928
+ }
929
+ /**
930
+ * What `openclaw plugins install` would actually do with this spec.
931
+ *
932
+ * ⚠️ That command cannot overwrite. If the extension directory is there it
933
+ * aborts —
934
+ *
935
+ * plugin already exists: ~/.openclaw/extensions/atbash-openclaw
936
+ * (delete it first)
937
+ *
938
+ * — and exits 1. A plan that lists the install unconditionally therefore fails
939
+ * on its SECOND run against the same machine, and setup then reports "this
940
+ * machine is NOT fully wired" about a machine that is completely wired. Setup is
941
+ * meant to be re-runnable; a step that can only ever succeed once is not.
942
+ *
943
+ * There is no `plugins uninstall` in OpenClaw 2026.2.1 — the subcommands are
944
+ * list/info/enable/disable/install/update/doctor — so the way to refresh an
945
+ * existing install is `plugins update <id>`, which re-resolves the recorded spec
946
+ * and reports "up to date" when there is nothing to do.
947
+ */
948
+ /**
949
+ * The plugin version recorded by a 2026.7.x npm-project install, if there is one.
950
+ *
951
+ * `openclaw plugins install` creates `~/.openclaw/npm/projects/<mangled>/` whose
952
+ * package.json pins the plugin exactly:
953
+ *
954
+ * dependencies: { "@atbash/atbash-openclaw": "0.1.14-dev.0" }
955
+ *
956
+ * The directory name is derived from the package name (`@atbash/atbash-openclaw`
957
+ * → `atbash-atbash-openclaw-<hash>`), which is how the plugin is told apart from
958
+ * `@opentelemetry/api`, a dependency OpenClaw adds to every one of these projects.
959
+ */
960
+ function openclawNpmProjectVersion(home) {
961
+ const mangle = (name) => name.replace(/^@/, "").replace(/[/]/g, "-");
962
+ const projectsDir = path.join(home, ".openclaw", "npm", "projects");
963
+ let projects = [];
964
+ try {
965
+ projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
966
+ }
967
+ catch {
968
+ return undefined;
969
+ }
970
+ for (const project of projects) {
971
+ const raw = readTextFile(path.join(projectsDir, project, "package.json"));
972
+ if (raw === null)
973
+ continue;
974
+ let deps = {};
975
+ try {
976
+ const parsed = JSON.parse(raw);
977
+ deps = isRecord(parsed.dependencies) ? parsed.dependencies : {};
978
+ }
979
+ catch {
980
+ continue;
981
+ }
982
+ for (const [name, version] of Object.entries(deps)) {
983
+ if (!project.startsWith(mangle(name)))
984
+ continue;
985
+ if (name !== OPENCLAW_PKG && name !== OPENCLAW_ENTRY)
986
+ continue;
987
+ if (typeof version === "string")
988
+ return version;
989
+ }
990
+ }
991
+ return undefined;
992
+ }
993
+ function openclawInstallState(home, spec) {
994
+ // ── The 2026.7.x layout FIRST: it is what a current install produces, and it
995
+ // is authoritative whenever it is present.
996
+ //
997
+ // ⚠️ THIS FUNCTION USED TO READ ONLY `extensions/` + `plugins.installs`, and on
998
+ // a real 2026.7.1 machine that produced actively harmful advice. That machine
999
+ // carried BOTH a stale `extensions/atbash-openclaw` left from the 2026.2.x era
1000
+ // AND the real install under `npm/projects/`. The old code saw the stale
1001
+ // directory (installed = true), found no `plugins.installs` record — 2026.7.x
1002
+ // keeps that in `~/.openclaw/state/openclaw.sqlite` — and concluded "a
1003
+ // different, unidentifiable build is installed". Setup then told the operator
1004
+ // to `rm -rf ~/.openclaw/extensions/atbash-openclaw` and reinstall, on a
1005
+ // machine whose plugin was installed, correct, and loading. The release
1006
+ // immediately before had planned a plain `plugins update` for the same box.
1007
+ const projectVersion = openclawNpmProjectVersion(home);
1008
+ if (projectVersion) {
1009
+ // The npm project records a VERSION, not the `@dev`/plain TAG that was asked
1010
+ // for, so compare lineage the way verifyOpenclawPlugin does: a `-dev.` build
1011
+ // satisfies `@dev` and only that. Comparing the literal spec string here
1012
+ // would report every correct install as a mismatch.
1013
+ const wantsDev = /@dev$/.test(spec);
1014
+ return { installed: true, sameSpec: wantsDev === /-dev\./.test(projectVersion), version: projectVersion };
1015
+ }
1016
+ if (!exists(path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY))) {
1017
+ return { installed: false, sameSpec: false };
1018
+ }
1019
+ const raw = readTextFile(path.join(home, ...OPENCLAW_CONFIG_REL));
1020
+ let record = null;
1021
+ if (raw !== null) {
1022
+ const parsed = jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false });
1023
+ const installs = isRecord(parsed) && isRecord(parsed.plugins) ? parsed.plugins.installs : null;
1024
+ if (isRecord(installs) && isRecord(installs[OPENCLAW_ENTRY])) {
1025
+ record = installs[OPENCLAW_ENTRY];
1026
+ }
1027
+ }
1028
+ return {
1029
+ installed: true,
1030
+ // No record means an install this command did not make and cannot identify.
1031
+ // Treated as a mismatch: telling someone to `update` a plugin whose origin
1032
+ // is unknown could quietly keep them on a build for the wrong chain.
1033
+ sameSpec: typeof record?.spec === "string" && record.spec === spec,
1034
+ version: typeof record?.version === "string" ? record.version : undefined,
1035
+ };
1036
+ }
1037
+ function keyPathUpdate(existingConfig, home) {
1038
+ const canonical = path.join(home, ...KEY_FILE_REL);
1039
+ const current = existingConfig.chromiaSecretPath;
1040
+ if (typeof current === "string" && path.resolve(expandHome(current.trim(), home)) === path.resolve(canonical)) {
1041
+ return {};
1042
+ }
1043
+ return { chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}` };
1044
+ }
439
1045
  function isRecord(v) {
440
1046
  return !!v && typeof v === "object" && !Array.isArray(v);
441
1047
  }
1048
+ /**
1049
+ * Detect the indentation a JSON file already uses, so a merge does not reformat
1050
+ * the parts it did not touch.
1051
+ *
1052
+ * Without this, `JSON.stringify(obj, null, 2)` re-indents a tab-indented or
1053
+ * 4-space config from top to bottom. The RESULT is still correct, but the diff
1054
+ * shown for approval becomes every line in the file, which buries the two lines
1055
+ * that actually changed — and the operator's own formatting choice is collateral
1056
+ * damage in a file we were asked to make one addition to.
1057
+ *
1058
+ * Falls back to two spaces, which is what the published docs show.
1059
+ */
1060
+ function detectIndent(text) {
1061
+ if (!text)
1062
+ return 2;
1063
+ // First line that is indented under an opening brace/bracket tells us the unit.
1064
+ const match = text.match(/\n([ \t]+)\S/);
1065
+ if (!match)
1066
+ return 2;
1067
+ const indent = match[1];
1068
+ return indent.startsWith("\t") ? "\t" : indent.length;
1069
+ }
1070
+ /**
1071
+ * Serialize a merged config the way the file was already written: same
1072
+ * indentation, and a trailing newline only if the original had one.
1073
+ */
1074
+ function serializeLike(original, value) {
1075
+ const body = JSON.stringify(value, null, detectIndent(original));
1076
+ // A file that ended without a newline keeps ending without one. Trivial, but it
1077
+ // is one more line of unexplained diff for someone reviewing the change.
1078
+ const trailing = original === null || original.endsWith("\n") ? "\n" : "";
1079
+ return body + trailing;
1080
+ }
1081
+ /**
1082
+ * The MCP server entry setup writes into a client's config.
1083
+ *
1084
+ * Note what is NOT here: an `env` block. The published `@atbash/mcp` wiring
1085
+ * carries the agent's private key in one, because that package reads only
1086
+ * ATBASH_AGENT_PRIVKEY. Going through `atbash mcp` instead means the launcher
1087
+ * reads the 0600 key file and passes the key to the server in the child process
1088
+ * environment, so this entry holds no credential and the client's config file is
1089
+ * no more sensitive after setup runs than it was before.
1090
+ *
1091
+ * Deliberately NOT pinned to an exact CLI version: unlike the one-shot connector
1092
+ * command, this entry persists in the operator's config and is re-executed every
1093
+ * time the client starts. Pinning here would freeze their MCP server at whatever
1094
+ * version happened to be current on the day they ran setup.
1095
+ */
1096
+ const MCP_SERVER_ENTRY = { command: "npx", args: ["--yes", "@atbash/cli", "mcp"] };
1097
+ const MCP_SERVER_NAME = "atbash";
1098
+ /**
1099
+ * MCP client configs present under this home directory.
1100
+ *
1101
+ * Paths come from the shared MCP_CONFIGS so the writer and the scanner cannot
1102
+ * drift: a client the scan reports but setup cannot find would look like a bug in
1103
+ * whichever of the two the operator happened to trust.
1104
+ */
1105
+ function detectMcpClients(home) {
1106
+ const out = [];
1107
+ const seen = new Set();
1108
+ for (const { label, segs } of atbash_targets_1.MCP_CONFIGS) {
1109
+ if (seen.has(label))
1110
+ continue;
1111
+ const file = path.join(home, ...segs);
1112
+ if (!exists(file))
1113
+ continue;
1114
+ seen.add(label);
1115
+ // Read which key this file already uses rather than assuming. VS Code's
1116
+ // mcp.json uses `servers`; writing `mcpServers` into it would be ignored.
1117
+ const existing = readJsonLoose(file);
1118
+ const serversKey = existing && isRecord(existing.servers) && !isRecord(existing.mcpServers) ? "servers" : "mcpServers";
1119
+ out.push({ label, file, format: "json", serversKey });
1120
+ }
1121
+ // Claude Code and Codex are special-cased in the scanner too — same paths.
1122
+ const claudeCode = path.join(home, ".claude.json");
1123
+ if (exists(claudeCode))
1124
+ out.push({ label: "Claude Code", file: claudeCode, format: "json", serversKey: "mcpServers" });
1125
+ const codex = path.join(home, ".codex", "config.toml");
1126
+ if (exists(codex))
1127
+ out.push({ label: "Codex", file: codex, format: "toml", serversKey: "mcpServers" });
1128
+ return out;
1129
+ }
1130
+ /** Tolerant read used only to sniff an existing file's shape. */
1131
+ function readJsonLoose(file) {
1132
+ const text = readTextFile(file);
1133
+ if (text === null)
1134
+ return null;
1135
+ const value = jsonc.parse(text, [], { allowTrailingComma: true, disallowComments: false });
1136
+ return isRecord(value) ? value : null;
1137
+ }
1138
+ /**
1139
+ * Add the Atbash server to a client config's server map, in place.
1140
+ *
1141
+ * A merge, like the OpenClaw one: every server the operator already configured
1142
+ * stays exactly as it is. An existing `atbash` entry is REPLACED rather than
1143
+ * merged field-by-field — a stale `env` block carrying a private key from the old
1144
+ * hand-written wiring is precisely what we want gone, and preserving it would
1145
+ * defeat the point of routing through the launcher.
1146
+ */
1147
+ /** The Hermes plugin, and the exact version the wiring is written against. */
1148
+ const HERMES_PKG = "atbash-hermes-plugin";
1149
+ const HERMES_VERSION = "0.4.5";
1150
+ /** The name Hermes lists this plugin under, and the allow-list entry. */
1151
+ const HERMES_PLUGIN_ENTRY = "atbash-hermes-plugin";
1152
+ /**
1153
+ * Find the Python interpreter that actually runs Hermes.
1154
+ *
1155
+ * This is the difference between installing the plugin and only appearing to.
1156
+ * `pip install atbash-hermes-plugin` puts the package wherever the *shell's*
1157
+ * `pip` points — commonly a system or conda Python — while Hermes typically runs
1158
+ * from its own virtualenv. The install succeeds, prints nothing alarming, and the
1159
+ * plugin is invisible to Hermes forever. Nobody can debug that from the output.
1160
+ *
1161
+ * The launcher knows the answer. A pip-installed console script begins with a
1162
+ * shebang naming the interpreter that created it:
1163
+ *
1164
+ * $ head -1 $(command -v hermes)
1165
+ * #!/Users/me/.hermes/hermes-agent/venv/bin/python3
1166
+ *
1167
+ * So resolve `hermes`, read its first line, and use that interpreter directly via
1168
+ * `-m pip`. Falls back to the conventional venv location under ~/.hermes, then to
1169
+ * null — and a null becomes a printed command rather than a guess, because a
1170
+ * wrong guess here is the silent failure this whole function exists to avoid.
1171
+ */
1172
+ function findHermesPython(home) {
1173
+ const viable = (candidate) => {
1174
+ try {
1175
+ return fs.statSync(candidate).isFile();
1176
+ }
1177
+ catch {
1178
+ return false;
1179
+ }
1180
+ };
1181
+ // 1. The launcher's own shebang — authoritative, but only for a real run.
1182
+ //
1183
+ // `--home <dir>` exists so a dry run can be hermetic (the release checklist
1184
+ // depends on it). A PATH lookup ignores it entirely: under `--home /tmp/fake`
1185
+ // this would find the operator's ACTUAL hermes and plan an install into their
1186
+ // real virtualenv. So the shebang route is skipped whenever `home` is not the
1187
+ // machine's own home — the caller then falls through to the venv path under the
1188
+ // given home, which is correctly scoped.
1189
+ const realHome = process.env.HOME || os.homedir();
1190
+ const scoped = path.resolve(home) !== path.resolve(realHome);
1191
+ const which = scoped
1192
+ ? { status: 1, stdout: "" }
1193
+ : (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["hermes"], { encoding: "utf8" });
1194
+ const launcher = which.status === 0 ? which.stdout.split(/\r?\n/)[0]?.trim() : "";
1195
+ if (launcher && viable(launcher)) {
1196
+ const firstLine = (readTextFile(launcher) ?? "").split(/\r?\n/)[0] ?? "";
1197
+ const shebang = firstLine.startsWith("#!") ? firstLine.slice(2).trim() : "";
1198
+ // `#!/usr/bin/env python3` names no path; anything else should be absolute.
1199
+ const interpreter = shebang.split(/\s+/).filter((part) => !part.endsWith("/env"))[0] ?? "";
1200
+ if (/python[0-9.]*$/.test(interpreter) && viable(interpreter)) {
1201
+ return { python: interpreter, how: `shebang of ${launcher}` };
1202
+ }
1203
+ }
1204
+ // 2. The conventional venv Hermes ships with.
1205
+ for (const name of ["python3", "python"]) {
1206
+ const candidate = path.join(home, ".hermes", "hermes-agent", "venv", "bin", name);
1207
+ if (viable(candidate))
1208
+ return { python: candidate, how: "Hermes virtualenv under ~/.hermes" };
1209
+ }
1210
+ return null;
1211
+ }
1212
+ /** Same rule as buildPlan's `wanted`: an empty --runtime list means everything. */
1213
+ function wantedRuntime(id, only) {
1214
+ return only.length === 0 || only.includes(id);
1215
+ }
1216
+ /**
1217
+ * Add the plugin to Hermes' opt-in allow-list at `plugins.enabled` in
1218
+ * `~/.hermes/config.yaml`.
1219
+ *
1220
+ * WHY NOT `hermes plugins enable`: that command cannot accept this plugin, on
1221
+ * this version, ever. `_plugin_exists` (hermes_cli/plugins_cmd.py) looks only for
1222
+ * a DIRECTORY in the user plugins dir or a bundled dir — it never consults entry
1223
+ * points. Meanwhile the runtime loader (`plugins.py:_scan_entry_points`) does
1224
+ * discover them. So Hermes will happily LOAD a pip-installed plugin but refuses
1225
+ * to put one on the allow-list it requires, and since plugins are opt-in, a
1226
+ * package that cannot get onto the list can never load. Running that command
1227
+ * exits 1 with "not installed or bundled". Writing the list entry directly is the
1228
+ * only route that works.
1229
+ *
1230
+ * A LINE MERGE, not a parse-and-reserialize. config.yaml is tens of kilobytes of
1231
+ * heavily commented configuration; round-tripping it through a YAML emitter would
1232
+ * strip every comment and reflow the file. So this inserts the one line needed and
1233
+ * leaves every other byte alone — the same discipline as the .env merge.
1234
+ */
1235
+ function mergeHermesEnabledPlugins(existing, plugin = HERMES_PLUGIN_ENTRY) {
1236
+ const lines = existing === null ? [] : existing.split("\n");
1237
+ // Already on the list? Then there is nothing to do.
1238
+ const pluginsAt = lines.findIndex((l) => /^plugins:\s*(#.*)?$/.test(l));
1239
+ if (pluginsAt !== -1) {
1240
+ // Walk the plugins block (indented lines) looking for `enabled:` and its items.
1241
+ let enabledAt = -1;
1242
+ let end = lines.length;
1243
+ for (let i = pluginsAt + 1; i < lines.length; i++) {
1244
+ const line = lines[i];
1245
+ if (line.trim() === "" || line.startsWith("#"))
1246
+ continue;
1247
+ if (!/^\s/.test(line)) {
1248
+ end = i;
1249
+ break;
1250
+ } // dedented — block over
1251
+ if (/^\s+enabled:\s*(#.*)?$/.test(line))
1252
+ enabledAt = i;
1253
+ }
1254
+ if (enabledAt !== -1) {
1255
+ // Collect the list items under `enabled:` and bail if ours is there.
1256
+ const itemIndent = (lines[enabledAt].match(/^\s*/)?.[0] ?? " ") + " ";
1257
+ let last = enabledAt;
1258
+ for (let i = enabledAt + 1; i < end; i++) {
1259
+ if (/^\s*-\s+/.test(lines[i])) {
1260
+ if (lines[i].replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "") === plugin)
1261
+ return existing;
1262
+ last = i;
1263
+ }
1264
+ else if (lines[i].trim() !== "")
1265
+ break;
1266
+ }
1267
+ lines.splice(last + 1, 0, `${itemIndent}- ${plugin}`);
1268
+ return lines.join("\n");
1269
+ }
1270
+ // A plugins block with no `enabled:` key — add one inside it.
1271
+ lines.splice(pluginsAt + 1, 0, " enabled:", ` - ${plugin}`);
1272
+ return lines.join("\n");
1273
+ }
1274
+ // No plugins block at all — which is the default, and means nothing loads.
1275
+ const out = existing === null ? [] : [...lines];
1276
+ if (out.length && out[out.length - 1].trim() !== "")
1277
+ out.push("");
1278
+ out.push("# Added by `atbash setup` - Hermes loads only plugins on this allow-list");
1279
+ out.push("plugins:", " enabled:", ` - ${plugin}`);
1280
+ let text = out.join("\n");
1281
+ if (!text.endsWith("\n"))
1282
+ text += "\n";
1283
+ return text;
1284
+ }
1285
+ /**
1286
+ * Is a Hermes gateway service running, and therefore restartable?
1287
+ *
1288
+ * "Restart Hermes" only means something when there is a service to bounce.
1289
+ * Hermes has two shapes: `hermes` starts an interactive chat session, and
1290
+ * `hermes gateway install` registers a launchd/systemd background service. A
1291
+ * setup command must not conflate them —
1292
+ *
1293
+ * - with a service running, `hermes gateway restart` picks up the new config and
1294
+ * is worth doing for the operator;
1295
+ * - without one, there is nothing to restart. The plugin loads the next time
1296
+ * they run `hermes`, and anything we "restarted" would either be a no-op or,
1297
+ * worse, an interactive session someone is in the middle of using.
1298
+ *
1299
+ * `gateway status` exits 0 either way, so the state comes from its text.
1300
+ */
1301
+ function hermesGatewayRunning() {
1302
+ const probe = (0, win_exec_1.spawnPortable)("hermes", ["gateway", "status"], { encoding: "utf8" });
1303
+ if (probe.status !== 0 || typeof probe.stdout !== "string")
1304
+ return false;
1305
+ // "✗ Gateway is not running" vs a running report.
1306
+ return !/not\s+running/i.test(probe.stdout);
1307
+ }
1308
+ /**
1309
+ * Is Hermes CONFIGURED to load the plugin?
1310
+ *
1311
+ * This used to shell out to `hermes plugins list` and look for an atbash row —
1312
+ * a signal that can never be true. `_discover_all_plugins` (plugins_cmd.py) walks
1313
+ * plugin DIRECTORIES only: bundled, user, project. It never scans entry points,
1314
+ * exactly like the `_plugin_exists` gate behind `plugins enable`. So a
1315
+ * pip-installed plugin is invisible to both, and the check reported "not picked
1316
+ * up" while everything was in fact correct — then told the operator to run the
1317
+ * enable command that cannot work. Advising a known-impossible fix is worse than
1318
+ * saying nothing.
1319
+ *
1320
+ * The two facts that actually decide it are both on disk:
1321
+ *
1322
+ * 1. the package is importable by the interpreter that runs Hermes, and
1323
+ * 2. its name is on the `plugins.enabled` allow-list in config.yaml, which is
1324
+ * what the RUNTIME loader (`plugins.py:_scan_entry_points`) honours.
1325
+ *
1326
+ * "configured" is as far as a setup command can honestly go. Proof of loading is
1327
+ * a line in the agent log after Hermes next starts, which is why the caller points
1328
+ * at that rather than claiming enforcement.
1329
+ */
1330
+ function hermesPluginState(home) {
1331
+ const venvBase = path.join(home, ".hermes", "hermes-agent", "venv", "lib");
1332
+ let installed = false;
1333
+ try {
1334
+ installed = fs.readdirSync(venvBase, { withFileTypes: true })
1335
+ .filter((e) => e.isDirectory())
1336
+ .some((e) => exists(venvBase, e.name, "site-packages", "atbash_hermes_plugin"));
1337
+ }
1338
+ catch {
1339
+ return "unknown";
1340
+ }
1341
+ const configYaml = readTextFile(path.join(home, ".hermes", "config.yaml"));
1342
+ if (configYaml === null)
1343
+ return installed ? "not-enabled" : "not-installed";
1344
+ let enabled = false;
1345
+ try {
1346
+ const parsed = (0, yaml_1.parse)(configYaml);
1347
+ const list = parsed?.plugins?.enabled;
1348
+ enabled = Array.isArray(list) && list.some((n) => String(n).trim() === HERMES_PLUGIN_ENTRY);
1349
+ }
1350
+ catch {
1351
+ return "unknown";
1352
+ }
1353
+ if (!installed)
1354
+ return "not-installed";
1355
+ return enabled ? "configured" : "not-enabled";
1356
+ }
1357
+ /**
1358
+ * How to install a Python package into a specific interpreter on THIS machine.
1359
+ *
1360
+ * `<python> -m pip install` is the obvious answer and it is frequently wrong: a
1361
+ * venv created by `uv venv` has no pip at all (that is uv's default), so the
1362
+ * command fails with
1363
+ *
1364
+ * /path/venv/bin/python3: No module named pip
1365
+ *
1366
+ * after setup has already written every config file — which is exactly what
1367
+ * happened on a real Hermes box. So probe, in order of what suits the venv:
1368
+ *
1369
+ * 1. `uv pip install --python <python>` when uv is present. Correct for a
1370
+ * uv-created venv and fast; uv is also what created most pip-less venvs.
1371
+ * 2. `<python> -m pip install` when pip actually answers.
1372
+ * 3. Neither — hand it over, with `ensurepip` named, rather than planning a
1373
+ * command that is known in advance to fail.
1374
+ */
1375
+ function pythonInstallStrategy(python, pkg) {
1376
+ const uv = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["uv"], { stdio: "ignore" });
1377
+ if (uv.status === 0) {
1378
+ return {
1379
+ kind: "exec",
1380
+ command: "uv",
1381
+ args: ["pip", "install", "--python", python, pkg],
1382
+ how: "uv, targeting the interpreter that runs Hermes",
1383
+ };
1384
+ }
1385
+ const pip = (0, child_process_1.spawnSync)(python, ["-m", "pip", "--version"], { stdio: "ignore" });
1386
+ if (pip.status === 0) {
1387
+ return { kind: "exec", command: python, args: ["-m", "pip", "install", pkg], how: "pip in the interpreter that runs Hermes" };
1388
+ }
1389
+ return {
1390
+ kind: "manual",
1391
+ 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:`,
1392
+ snippet: [`${python} -m ensurepip --upgrade`, `${python} -m pip install ${pkg}`].join("\n"),
1393
+ };
1394
+ }
1395
+ /**
1396
+ * The env vars the Hermes plugin documents, merged into an existing `.env`.
1397
+ *
1398
+ * A `.env` is line-oriented and hand-maintained, so this is a line merge rather
1399
+ * than a parse-and-reserialize: keys Atbash owns are replaced in place (keeping
1400
+ * their position), keys it does not own are never touched, and anything else in
1401
+ * the file — comments, blank lines, unrelated settings, ordering — survives
1402
+ * exactly as written. Reformatting someone's .env to add four lines would be a
1403
+ * poor trade.
1404
+ *
1405
+ * Values are from the published plugin README (PyPI atbash-hermes-plugin 0.4.5).
1406
+ * `ATBASH_ORG_NAME` is deliberately NOT written: its value is the operator's org,
1407
+ * which this command has no reliable way to know, and a wrong org sends the SDK
1408
+ * at the wrong chain. It is called out in the manual step instead.
1409
+ */
1410
+ function mergeHermesEnv(existing) {
1411
+ const desired = {
1412
+ ATBASH_KEY_PATH: "$HOME/.config/atbash/guard-client-key",
1413
+ ATBASH_ENFORCE_DECISION: "true",
1414
+ };
1415
+ const lines = existing === null ? [] : existing.split("\n");
1416
+ const seen = new Set();
1417
+ const out = lines.map((line) => {
1418
+ const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/);
1419
+ const key = match?.[1];
1420
+ if (!key || !(key in desired) || seen.has(key))
1421
+ return line;
1422
+ seen.add(key);
1423
+ // Already correct — keep the operator's own formatting rather than rewriting.
1424
+ if (line.trim() === `${key}=${desired[key]}`)
1425
+ return line;
1426
+ return `${key}=${desired[key]}`;
1427
+ });
1428
+ const missing = Object.entries(desired).filter(([key]) => !seen.has(key));
1429
+ if (missing.length) {
1430
+ // Separate the block we add from whatever came before it.
1431
+ if (out.length && out[out.length - 1].trim() !== "")
1432
+ out.push("");
1433
+ // ASCII-only comment on purpose: .env files are read by many different
1434
+ // parsers and a stray multi-byte dash is a free way to trip a strict one.
1435
+ if (existing !== null)
1436
+ out.push("# Added by `atbash setup` - Atbash Hermes plugin");
1437
+ for (const [key, value] of missing)
1438
+ out.push(`${key}=${value}`);
1439
+ }
1440
+ let text = out.join("\n");
1441
+ if (!text.endsWith("\n"))
1442
+ text += "\n";
1443
+ return text;
1444
+ }
1445
+ /**
1446
+ * Does this config's existing Atbash entry carry a key in its `env` block?
1447
+ *
1448
+ * True means the operator hand-wired it from the published docs and their private
1449
+ * key is sitting in that file today. Setup takes it out, but the backup it writes
1450
+ * first still has it — so this exists to make that sayable rather than silently
1451
+ * relocating the leak.
1452
+ */
1453
+ function hadInlineKey(config, serversKey = "mcpServers") {
1454
+ const servers = isRecord(config[serversKey]) ? config[serversKey] : undefined;
1455
+ const entry = servers && isRecord(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : undefined;
1456
+ const env = entry && isRecord(entry.env) ? entry.env : undefined;
1457
+ if (!env)
1458
+ return false;
1459
+ // Any 64-hex value, under any key name — not just the documented one, since a
1460
+ // hand-edited config may well have renamed it.
1461
+ return Object.values(env).some((v) => typeof v === "string" && /^(0x)?[0-9a-fA-F]{64}$/.test(v.trim()));
1462
+ }
1463
+ function mergeMcpServer(config, serversKey = "mcpServers") {
1464
+ const out = { ...config };
1465
+ const servers = { ...(isRecord(out[serversKey]) ? out[serversKey] : {}) };
1466
+ servers[MCP_SERVER_NAME] = { ...MCP_SERVER_ENTRY, args: [...MCP_SERVER_ENTRY.args] };
1467
+ out[serversKey] = servers;
1468
+ return out;
1469
+ }
1470
+ /** The exec-step id the OpenClaw config write depends on. See Step.requires. */
1471
+ const OPENCLAW_INSTALL_ID = "openclaw-install";
1472
+ /**
1473
+ * The change we want, expressed as a PATCH rather than a whole file.
1474
+ *
1475
+ * `openclaw config patch` merges objects recursively and validates in one write,
1476
+ * so this is intent — "this entry should exist and look like this" — instead of
1477
+ * "here are the complete new bytes of your config". That difference is what makes
1478
+ * it survive the things that kept defeating us from outside: OpenClaw's own
1479
+ * format changes, key renames, comment preservation, file permissions, its
1480
+ * migrations, and the fact that it rewrites `openclaw.json` on its own (a machine
1481
+ * in the field carried three `openclaw.json.clobbered.<timestamp>` files).
1482
+ *
1483
+ * ⚠️ DO NOT call `config patch --dry-run` from buildPlan to preview this. It is
1484
+ * NOT side-effect free: running it triggers OpenClaw's state migrations, which
1485
+ * wrote `~/.openclaw/update-check.json.migrated` and moved config-health state
1486
+ * into SQLite on the machine this was developed on. `atbash setup --dry-run`
1487
+ * promises that nothing is written, and that promise has to hold for OpenClaw's
1488
+ * housekeeping too. The preview stays a merge we compute ourselves.
1489
+ */
1490
+ function openclawPatchPayload(args) {
1491
+ const entries = {
1492
+ [args.entryKey]: {
1493
+ enabled: true,
1494
+ // See HOOK_PERMISSIONS: `allowPromptInjection` defaults to ALLOWED, so
1495
+ // without this the guard holds a prompt-mutation permission it never uses.
1496
+ // `config patch` merges recursively, so an operator's own `hooks` keys
1497
+ // (timeoutMs/timeouts) survive alongside it.
1498
+ ...(args.hooksSupported ? { hooks: { ...HOOK_PERMISSIONS } } : {}),
1499
+ config: {
1500
+ enabled: true,
1501
+ enforceDecision: true,
1502
+ chromiaSecretPath: args.keyPath,
1503
+ ...(args.entryKey !== OPENCLAW_LEGACY_ENTRY && args.orgName?.trim() ? { orgName: args.orgName.trim() } : {}),
1504
+ },
1505
+ },
1506
+ };
1507
+ // Two live entries means two hooks and every tool call judged twice against two
1508
+ // different SDKs, so the handover happens in the SAME patch as the new entry —
1509
+ // never as a second write that could land on its own.
1510
+ if (args.standDownLegacy)
1511
+ entries[OPENCLAW_LEGACY_ENTRY] = { enabled: false };
1512
+ return `${JSON.stringify({ plugins: { entries } }, null, 2)}\n`;
1513
+ }
442
1514
  /** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
443
1515
  function hasExecutable(command) {
444
1516
  const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
@@ -453,7 +1525,13 @@ function hasExecutable(command) {
453
1525
  * writes a config for a plugin they do not have.
454
1526
  */
455
1527
  function buildPlan(args) {
456
- const { home, privkey, pubkey, noInstall, only } = args;
1528
+ const { home, privkey, pubkey, noInstall, only, orgName, endpoint } = args;
1529
+ // Capability, never a version comparison. A version table is what went stale
1530
+ // and produced a config that broke a fresh machine.
1531
+ const canPatch = Boolean(args.openclaw?.caps.patch);
1532
+ // Probed, never inferred: `hooks` is unknown on 2026.2.1 and valid on 2026.6.6+,
1533
+ // so the same key is fatal on one build and legitimate on the other.
1534
+ const hooksSupported = args.openclaw?.hooksSupported === true;
457
1535
  const steps = [];
458
1536
  const notes = [];
459
1537
  const found = [];
@@ -466,7 +1544,33 @@ function buildPlan(args) {
466
1544
  const alreadyThisKey = currentKeyFile !== null && parseKeyMaterial(currentKeyFile)?.privkey === privkey;
467
1545
  if (!alreadyThisKey) {
468
1546
  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.`);
1547
+ // The outgoing key belongs to a real agent that this machine may still be
1548
+ // governing. A `.atbash-bak` preserves the bytes but not the identity — six
1549
+ // months later nobody knows which agent `guard-client-key.atbash-bak` was.
1550
+ // So archive it under its own public key: recoverable, self-identifying,
1551
+ // and a path a runtime config can point at directly if this box needs to
1552
+ // run two agents (both OpenClaw's `chromiaSecretPath` and Hermes'
1553
+ // `ATBASH_KEY_PATH` take an explicit path).
1554
+ const outgoing = parseKeyMaterial(currentKeyFile);
1555
+ const outgoingPub = outgoing ? (0, sdk_1.derivePublicKey)(outgoing.privkey) : null;
1556
+ if (outgoingPub) {
1557
+ const archive = path.join(home, ".config", "atbash", "keys", `${outgoingPub}.key`);
1558
+ if (!exists(archive)) {
1559
+ steps.push({
1560
+ kind: "write",
1561
+ label: `Archive the agent key already on this machine (${outgoingPub.slice(0, 12)}…)`,
1562
+ file: archive,
1563
+ mode: KEY_MODE,
1564
+ before: null,
1565
+ after: currentKeyFile,
1566
+ secret: true,
1567
+ });
1568
+ }
1569
+ 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.`);
1570
+ }
1571
+ else {
1572
+ notes.push(`${keyFile} holds something this command could not parse as an agent key. It will be backed up before being replaced.`);
1573
+ }
470
1574
  }
471
1575
  steps.push({
472
1576
  kind: "write",
@@ -489,26 +1593,118 @@ function buildPlan(args) {
489
1593
  if (wanted("openclaw")) {
490
1594
  if (!noInstall) {
491
1595
  if (hasExecutable("openclaw")) {
492
- steps.push({ kind: "exec", label: `Install ${OPENCLAW_PKG}`, command: "openclaw", args: ["plugins", "install", OPENCLAW_PKG] });
1596
+ const spec = openclawPackageForHost(endpoint);
1597
+ const installed = openclawInstallState(home, spec);
1598
+ if (!installed.installed) {
1599
+ steps.push({
1600
+ kind: "exec",
1601
+ label: spec.endsWith("@dev")
1602
+ ? `Install ${spec} — the development build, matching the deployment this agent was onboarded on`
1603
+ : `Install ${spec} — the production build`,
1604
+ command: "openclaw",
1605
+ args: ["plugins", "install", spec],
1606
+ id: OPENCLAW_INSTALL_ID,
1607
+ });
1608
+ }
1609
+ else if (installed.sameSpec) {
1610
+ // Already the right package: refresh it rather than re-installing.
1611
+ // `plugins install` would abort with "plugin already exists" and take
1612
+ // the whole run down with it — see openclawInstallState.
1613
+ steps.push({
1614
+ kind: "exec",
1615
+ label: `Update ${spec} to the newest build under its tag (installed: ${installed.version ?? "unknown"})`,
1616
+ command: "openclaw",
1617
+ // Deliberately NOT tagged with OPENCLAW_INSTALL_ID: the plugin is
1618
+ // already on disk here, so the config entry refers to something real
1619
+ // whether or not this refresh succeeds. Gating the config on an
1620
+ // update would withhold a correct config over a failed download.
1621
+ args: ["plugins", "update", OPENCLAW_ENTRY],
1622
+ });
1623
+ }
1624
+ else {
1625
+ // A DIFFERENT spec is installed — typically the production build on a
1626
+ // machine onboarded against a dev deployment, or the reverse. This is
1627
+ // not cosmetic: the judge endpoint and chain ids come from the SDK the
1628
+ // plugin bundles, so the wrong build fails every judge call against a
1629
+ // chain this agent is not on. It cannot be fixed by `update`, which
1630
+ // re-resolves the spec already recorded, and 2026.2.1 has no
1631
+ // `plugins uninstall`, so the directory has to go by hand.
1632
+ steps.push({
1633
+ kind: "manual",
1634
+ label: `Replace the installed OpenClaw plugin with ${spec}`,
1635
+ detail: `A different build is installed${installed.version ? ` (${installed.version})` : ""} and OpenClaw cannot overwrite it — \`plugins install\` aborts with "plugin already exists", and \`plugins update\` would only re-resolve the spec it already has. The plugin's judge endpoint and chain ids come from the SDK it bundles, so the wrong build fails every judge call. Remove it and install the right one:`,
1636
+ snippet: `rm -rf ${path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY)}\nopenclaw plugins install ${spec}`,
1637
+ });
1638
+ }
1639
+ if (spec.endsWith("@dev")) {
1640
+ 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.");
1641
+ }
493
1642
  }
494
1643
  else {
495
1644
  steps.push({
496
1645
  kind: "manual",
497
1646
  label: "Install the OpenClaw plugin",
498
1647
  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}`,
1648
+ snippet: `openclaw plugins install ${openclawPackageForHost(endpoint)}`,
500
1649
  });
501
1650
  }
502
1651
  }
503
1652
  const raw = readTextFile(openclawConfigFile);
1653
+ // The config entry must not be written unless the plugin it names is going
1654
+ // to exist. `requires` is set only for a FRESH install — see Step.requires
1655
+ // for what a dangling entry costs.
1656
+ const requires = steps.some((s) => s.kind === "exec" && s.id === OPENCLAW_INSTALL_ID)
1657
+ ? OPENCLAW_INSTALL_ID
1658
+ : undefined;
1659
+ // Hand the merge to OpenClaw where it can do it: one validated write, in
1660
+ // its own current format, whatever that has become.
1661
+ //
1662
+ // The entry decision comes from the SAME function the hand-merge uses, so
1663
+ // the two paths cannot pick different keys — see openclawEntryDecision.
1664
+ let parsedForDecision = {};
1665
+ if (raw !== null) {
1666
+ try {
1667
+ const p = isJsonc(raw)
1668
+ ? jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false })
1669
+ : JSON.parse(raw);
1670
+ if (isRecord(p))
1671
+ parsedForDecision = p;
1672
+ }
1673
+ catch {
1674
+ /* an unparseable config decides nothing; treat it as empty */
1675
+ }
1676
+ }
1677
+ const decision = openclawEntryDecision(parsedForDecision, { installsModern: !noInstall });
1678
+ const patchPayload = canPatch
1679
+ ? openclawPatchPayload({
1680
+ entryKey: decision.entryKey,
1681
+ orgName,
1682
+ keyPath: `~/${KEY_FILE_REL.join("/")}`,
1683
+ standDownLegacy: decision.standDownLegacy,
1684
+ hooksSupported,
1685
+ })
1686
+ : undefined;
504
1687
  if (raw !== null && isJsonc(raw)) {
505
- // Rewriting this would delete the owner's comments. Print instead.
506
- steps.push({
507
- kind: "manual",
508
- label: `Enable the plugin in ${openclawConfigFile}`,
509
- 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),
511
- });
1688
+ // A config with comments or trailing commas. Rewriting it as strict JSON
1689
+ // deletes the operator's comments, so we never do that.
1690
+ //
1691
+ // But `openclaw config patch` can edit this file WITHOUT losing them —
1692
+ // it is OpenClaw's own JSON5-aware writer. So where that exists, the
1693
+ // operator gets one command instead of a hand-merge, and this stops
1694
+ // being the awkward case.
1695
+ steps.push(patchPayload
1696
+ ? {
1697
+ kind: "manual",
1698
+ label: `Enable the plugin in ${openclawConfigFile}`,
1699
+ detail: "That file uses comments or trailing commas, so Atbash will not rewrite it — doing so as strict JSON would delete them. OpenClaw can apply the change itself and keep your comments. Run this (it validates before writing, and changes nothing else):",
1700
+ snippet: `openclaw config patch --stdin <<'JSON'\n${patchPayload.trimEnd()}\nJSON`,
1701
+ }
1702
+ : {
1703
+ kind: "manual",
1704
+ label: `Enable the plugin in ${openclawConfigFile}`,
1705
+ 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 `entries`:",
1706
+ snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home, orgName, { installsModern: !noInstall, hooksSupported }), null, 2),
1707
+ });
512
1708
  }
513
1709
  else {
514
1710
  let current = {};
@@ -522,16 +1718,37 @@ function buildPlan(args) {
522
1718
  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
1719
  }
524
1720
  }
525
- const after = JSON.stringify(mergeOpenclawConfig(current, home), null, 2) + "\n";
1721
+ const after = serializeLike(raw, mergeOpenclawConfig(current, home, orgName, { installsModern: !noInstall, hooksSupported }));
526
1722
  if (raw !== after) {
527
1723
  steps.push({
528
1724
  kind: "write",
529
1725
  label: raw === null
530
1726
  ? "Create ~/.openclaw/openclaw.json with the plugin enabled"
531
- : "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
1727
+ : patchPayload
1728
+ ? "Enable the plugin in ~/.openclaw/openclaw.json (OpenClaw applies and validates the merge itself)"
1729
+ : "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
532
1730
  file: openclawConfigFile,
533
1731
  before: raw,
534
1732
  after,
1733
+ ...(requires ? { requires } : {}),
1734
+ // The diff above is the merge WE compute, and it is what the operator
1735
+ // approves. Where OpenClaw can apply the change itself we send it the
1736
+ // equivalent patch instead of these bytes, so the result is validated
1737
+ // and written in OpenClaw's own current format. The two agree on
1738
+ // content; OpenClaw may differ on formatting, and its formatting is
1739
+ // the correct one.
1740
+ ...(patchPayload
1741
+ ? {
1742
+ applyVia: {
1743
+ command: "openclaw",
1744
+ args: ["config", "patch", "--stdin"],
1745
+ payload: patchPayload,
1746
+ // Pin the target so the patch cannot land on a different
1747
+ // config than the one this step names — see applyVia.env.
1748
+ env: { OPENCLAW_CONFIG_PATH: openclawConfigFile },
1749
+ },
1750
+ }
1751
+ : {}),
535
1752
  });
536
1753
  }
537
1754
  else {
@@ -547,47 +1764,188 @@ function buildPlan(args) {
547
1764
  // judged. Setup places the key file and says so; it does not pretend to wire it.
548
1765
  if (exists(home, ...HERMES_AGENT_REL)) {
549
1766
  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
- });
1767
+ if (wanted("hermes")) {
1768
+ const envFile = path.join(home, ".hermes", ".env");
1769
+ const raw = readTextFile(envFile);
1770
+ const merged = mergeHermesEnv(raw);
1771
+ if (merged !== raw) {
1772
+ steps.push({
1773
+ kind: "write",
1774
+ label: raw === null
1775
+ ? "Create ~/.hermes/.env pointing the Hermes plugin at the agent key"
1776
+ : "Point the Hermes plugin at the agent key in ~/.hermes/.env (a merge — your other settings are kept)",
1777
+ file: envFile,
1778
+ before: raw,
1779
+ after: merged,
1780
+ });
1781
+ }
1782
+ else {
1783
+ notes.push(`${envFile} already points the Hermes plugin at this key — left untouched.`);
1784
+ }
1785
+ const hermesConfig = path.join(home, ".hermes", "config.yaml");
1786
+ const rawConfig = readTextFile(hermesConfig);
1787
+ const withPlugin = mergeHermesEnabledPlugins(rawConfig);
1788
+ if (withPlugin !== rawConfig) {
1789
+ steps.push({
1790
+ kind: "write",
1791
+ label: rawConfig === null
1792
+ ? "Create ~/.hermes/config.yaml enabling the plugin"
1793
+ : "Add the plugin to Hermes' enabled allow-list in ~/.hermes/config.yaml",
1794
+ file: hermesConfig,
1795
+ before: rawConfig,
1796
+ after: withPlugin,
1797
+ });
1798
+ 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.");
1799
+ }
1800
+ else if (rawConfig !== null) {
1801
+ notes.push(`${hermesConfig} already has the plugin on its enabled list — left untouched.`);
1802
+ }
1803
+ // Only a running gateway service can be restarted for them. An interactive
1804
+ // session cannot, and must not be see hermesGatewayRunning.
1805
+ const restartable = hasExecutable("hermes") && hermesGatewayRunning();
1806
+ // The Python package must land in the interpreter that RUNS Hermes, not
1807
+ // whichever pip the shell happens to resolve. When we can identify that
1808
+ // interpreter we install into it directly; when we cannot, we hand the
1809
+ // command over rather than guess, because guessing wrong installs
1810
+ // successfully and governs nothing.
1811
+ if (!noInstall) {
1812
+ const hermesPython = findHermesPython(home);
1813
+ if (hermesPython) {
1814
+ const spec = `${HERMES_PKG}==${HERMES_VERSION}`;
1815
+ const strategy = pythonInstallStrategy(hermesPython.python, spec);
1816
+ if (strategy.kind === "exec") {
1817
+ steps.push({
1818
+ kind: "exec",
1819
+ label: `Install ${HERMES_PKG} via ${strategy.how} (interpreter found via ${hermesPython.how})`,
1820
+ command: strategy.command,
1821
+ args: strategy.args,
1822
+ });
1823
+ }
1824
+ else {
1825
+ steps.push({
1826
+ kind: "manual",
1827
+ label: "Install the Hermes plugin",
1828
+ detail: strategy.why,
1829
+ snippet: strategy.snippet,
1830
+ });
1831
+ }
1832
+ // The plugin pins atbash-sdk==0.4.5, which constrains cryptography — on
1833
+ // a venv with a newer one this install is a DOWNGRADE of a shared
1834
+ // dependency. Say so; it is the kind of thing that breaks the host app
1835
+ // and is invisible in a list of file writes.
1836
+ 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.`);
1837
+ // Installing is not enabling. Hermes keeps plugins off until told
1838
+ // otherwise, so without this the hook is never registered and the
1839
+ // agent is not governed — while every file on disk says it is.
1840
+ // Ordered after the install because `enable` needs the package present.
1841
+ }
1842
+ else {
1843
+ steps.push({
1844
+ kind: "manual",
1845
+ label: "Install the Hermes plugin",
1846
+ detail: [
1847
+ "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.",
1848
+ "",
1849
+ "Run this with the interpreter Hermes uses (if it runs in a virtualenv, that venv's python):",
1850
+ ].join("\n"),
1851
+ snippet: `/path/to/hermes/venv/bin/python -m pip install ${HERMES_PKG}==${HERMES_VERSION}`,
1852
+ });
1853
+ }
1854
+ }
1855
+ if (restartable) {
1856
+ steps.push({
1857
+ kind: "exec",
1858
+ label: "Restart the Hermes gateway so it loads the plugin",
1859
+ command: "hermes",
1860
+ args: ["gateway", "restart"],
1861
+ });
1862
+ }
1863
+ else {
1864
+ 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`.)");
1865
+ }
1866
+ 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.");
1867
+ notes.push("ATBASH_ENFORCE_DECISION=true is fail-closed: if Atbash cannot be reached, the Hermes tool call is blocked rather than allowed.");
1868
+ 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.");
1869
+ }
1870
+ }
1871
+ // ── 4. MCP clients.
1872
+ //
1873
+ // This used to be a manual step, and the reason was specific: `@atbash/mcp`
1874
+ // reads its identity from ATBASH_AGENT_PRIVKEY with no key-file fallback, so
1875
+ // the documented wiring puts a raw private key inside the client's own config —
1876
+ // `claude_desktop_config.json` and friends, files that get synced between
1877
+ // machines and pasted into help requests. Automating that would have meant the
1878
+ // automation's whole job was planting a secret somewhere worse.
1879
+ //
1880
+ // `atbash mcp` removes the reason. The client spawns the launcher, which reads
1881
+ // the key from the 0600 file and hands it to the server through the child
1882
+ // environment only. The config entry carries NO credential, so it is safe to
1883
+ // write — and a config with no secret in it is strictly better than the one the
1884
+ // operator would have hand-written from the docs.
1885
+ if (wanted("mcp")) {
1886
+ for (const client of detectMcpClients(home)) {
1887
+ found.push(client.label);
1888
+ if (client.format !== "json") {
1889
+ // TOML (Codex) — @iarna/toml can round-trip values but not comments, and
1890
+ // a config.toml is usually hand-maintained. Print it instead.
1891
+ steps.push({
1892
+ kind: "manual",
1893
+ label: `Add Atbash to ${client.label}`,
1894
+ detail: `${client.file} is TOML, and rewriting it would drop any comments in it. Add this table by hand:`,
1895
+ snippet: ["[mcp_servers.atbash]", 'command = "npx"', 'args = ["--yes", "@atbash/cli", "mcp"]'].join("\n"),
1896
+ });
1897
+ continue;
1898
+ }
1899
+ const raw = readTextFile(client.file);
1900
+ if (raw !== null && isJsonc(raw)) {
1901
+ steps.push({
1902
+ kind: "manual",
1903
+ label: `Add Atbash to ${client.label}`,
1904
+ 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:`,
1905
+ snippet: JSON.stringify({ mcpServers: { atbash: MCP_SERVER_ENTRY } }, null, 2),
1906
+ });
1907
+ continue;
1908
+ }
1909
+ let current = {};
1910
+ if (raw !== null) {
1911
+ try {
1912
+ const parsed = JSON.parse(raw);
1913
+ if (isRecord(parsed))
1914
+ current = parsed;
1915
+ }
1916
+ catch {
1917
+ notes.push(`${client.file} is not valid JSON, so it was left alone. Fix the file and re-run to wire ${client.label}.`);
1918
+ continue;
1919
+ }
1920
+ }
1921
+ const after = serializeLike(raw, mergeMcpServer(current, client.serversKey));
1922
+ if (raw !== after) {
1923
+ // A hand-wired entry from the old documented shape carries the private key
1924
+ // in an `env` block. Replacing it REMOVES that secret from the live config
1925
+ // — good — but the backup we are about to take still contains it, and an
1926
+ // operator who does not know that has simply moved the leak to a new file.
1927
+ if (hadInlineKey(current, client.serversKey)) {
1928
+ 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.`);
1929
+ }
1930
+ steps.push({
1931
+ kind: "write",
1932
+ 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" : ""})`,
1933
+ file: client.file,
1934
+ before: raw,
1935
+ after,
1936
+ });
1937
+ }
1938
+ else {
1939
+ notes.push(`${client.label} already has the Atbash MCP server — left untouched.`);
1940
+ }
1941
+ }
1942
+ if (found.some((f) => f !== "OpenClaw" && f !== "Hermes")) {
1943
+ notes.push("Restart any MCP client that was changed — clients read their server list at startup.");
1944
+ // A client whose first launch of the server takes ~12s can report a startup
1945
+ // timeout that looks like a broken config. Say so, so the first thing an
1946
+ // operator does is retry rather than undo the wiring.
1947
+ 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.");
1948
+ }
591
1949
  }
592
1950
  if (!found.length) {
593
1951
  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.");
@@ -743,17 +2101,57 @@ function backupFile(file) {
743
2101
  fs.copyFileSync(file, target);
744
2102
  return target;
745
2103
  }
746
- /** Execute the plan. Writes first, then commands, so a failed install still
747
- * leaves a correct config and key file behind for a manual retry. */
748
- function applyPlan(plan) {
749
- const result = { written: [], backups: [], ran: [], failures: [] };
750
- for (const step of plan.steps) {
751
- if (step.kind !== "write")
752
- continue;
2104
+ /**
2105
+ * Execute the plan, in the order that cannot leave a half-wired machine.
2106
+ *
2107
+ * 1. Independent writes the key file above all. Safe on their own, useful
2108
+ * even if everything after fails, and required by the plugin at load.
2109
+ * 2. Commands — the plugin install, so the plugin id EXISTS on disk.
2110
+ * 3. Dependent writes — the plugin config, now that it refers to something
2111
+ * real. Skipped outright if its install failed (see Step.requires).
2112
+ * 4. Verify, and roll back anything we broke (see verify).
2113
+ *
2114
+ * ⚠️ THE ORDER IS THE FIX, and it is the reverse of what this function used to
2115
+ * do. "Writes first, then commands, so a failed install still leaves a correct
2116
+ * config behind" sounds prudent and produces the single worst outcome available:
2117
+ * a config entry for a plugin that is not installed, which OpenClaw reports as a
2118
+ * stale reference and offers to delete via `doctor --fix` — silently un-governing
2119
+ * the agent — and which on 2026.2.x helped make the config unloadable, killing
2120
+ * the install that would have fixed it.
2121
+ */
2122
+ function applyPlan(plan, opts = {}) {
2123
+ const result = { written: [], backups: [], ran: [], failures: [], skipped: [], rolledBack: [] };
2124
+ /** Backup path per file, so a verify failure can put the original back. */
2125
+ const backupOf = new Map();
2126
+ const writeStep = (step) => {
753
2127
  try {
754
2128
  const backup = backupFile(step.file);
755
- if (backup)
2129
+ if (backup) {
756
2130
  result.backups.push(backup);
2131
+ backupOf.set(step.file, backup);
2132
+ }
2133
+ // Hand the change to the runtime where the plan says to — it merges and
2134
+ // validates in its own format, which is the whole point.
2135
+ if (step.applyVia) {
2136
+ const run = (0, win_exec_1.spawnPortable)(step.applyVia.command, step.applyVia.args, {
2137
+ input: step.applyVia.payload,
2138
+ encoding: "utf8",
2139
+ timeout: 120000,
2140
+ env: { ...process.env, ...(step.applyVia.env ?? {}) },
2141
+ });
2142
+ const out = `${run.stdout ?? ""}${run.stderr ?? ""}`.trim();
2143
+ if (run.error || run.status !== 0) {
2144
+ // Do NOT silently fall back to writing the file ourselves. The runtime
2145
+ // refusing the change is information — usually that the config is
2146
+ // invalid for a reason we did not cause — and overwriting the file by
2147
+ // hand would bury it and destroy whatever the runtime was protecting.
2148
+ result.failures.push(`${step.applyVia.command} ${step.applyVia.args.join(" ")} did not apply the change to ${step.file}` +
2149
+ (out ? `:\n ${out.split("\n").join("\n ")}` : `: ${run.error?.message ?? `exit ${run.status}`}`));
2150
+ return;
2151
+ }
2152
+ result.written.push(step.file);
2153
+ return;
2154
+ }
757
2155
  fs.mkdirSync(path.dirname(step.file), { recursive: true, mode: step.mode === KEY_MODE ? DIR_MODE : undefined });
758
2156
  fs.writeFileSync(step.file, step.after, step.mode ? { mode: step.mode } : {});
759
2157
  // writeFileSync's mode is ignored for a file that already existed, so
@@ -765,16 +2163,88 @@ function applyPlan(plan) {
765
2163
  catch (err) {
766
2164
  result.failures.push(`${step.file}: ${err instanceof Error ? err.message : String(err)}`);
767
2165
  }
2166
+ };
2167
+ // ── 1. Writes that depend on nothing.
2168
+ for (const step of plan.steps) {
2169
+ if (step.kind === "write" && !step.requires)
2170
+ writeStep(step);
768
2171
  }
2172
+ // ── 2. Commands, recording which ones a dependent write may rely on.
2173
+ const succeeded = new Set();
769
2174
  for (const step of plan.steps) {
770
2175
  if (step.kind !== "exec")
771
2176
  continue;
772
2177
  const label = `${step.command} ${step.args.join(" ")}`;
773
- const run = (0, child_process_1.spawnSync)(step.command, step.args, { stdio: "inherit" });
774
- if (run.status === 0)
2178
+ const run = (0, win_exec_1.spawnPortable)(step.command, step.args, { stdio: "inherit" });
2179
+ // Three distinct outcomes, and they used to collapse into one misleading
2180
+ // message. `spawnSync` reports a binary it could not launch via `.error` with
2181
+ // `status` left null — so an ENOENT printed "exited on a signal", which reads
2182
+ // like the plugin installer crashed rather than "that command is not here".
2183
+ // The distinction matters because only one of them is the operator's to fix,
2184
+ // and the fix is to run it somewhere the CLI exists.
2185
+ if (run.error) {
2186
+ const missing = run.error.code === "ENOENT";
2187
+ result.failures.push(missing
2188
+ ? `${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.`
2189
+ : `${label} could not start: ${run.error.message}`);
2190
+ }
2191
+ else if (run.status === 0) {
775
2192
  result.ran.push(label);
776
- else
777
- result.failures.push(`${label} exited ${run.status ?? "on a signal"}`);
2193
+ if (step.id)
2194
+ succeeded.add(step.id);
2195
+ }
2196
+ else if (run.signal) {
2197
+ result.failures.push(`${label} was killed by ${run.signal}`);
2198
+ }
2199
+ else {
2200
+ result.failures.push(`${label} exited with code ${run.status}`);
2201
+ }
2202
+ }
2203
+ // ── 3. Writes that needed one of those commands to have worked.
2204
+ for (const step of plan.steps) {
2205
+ if (step.kind !== "write" || !step.requires)
2206
+ continue;
2207
+ if (!succeeded.has(step.requires)) {
2208
+ // The whole point: no config for a plugin that is not there. Say what was
2209
+ // NOT done and why, because silence here reads as success.
2210
+ result.skipped.push(`${step.file} was left unchanged: it configures something the step above did not manage to install, ` +
2211
+ `and a config entry for a missing plugin is worse than none — the runtime reports it as stale and ` +
2212
+ `offers to delete it. Fix the failure above and re-run; nothing needs undoing first.`);
2213
+ continue;
2214
+ }
2215
+ writeStep(step);
2216
+ }
2217
+ // ── 4. Verify, and undo what we broke.
2218
+ //
2219
+ // Only files WE wrote in this run, and only when the runtime can actually
2220
+ // answer. A `valid: false` on a file we did not touch is someone else's
2221
+ // problem to fix and not ours to revert.
2222
+ if (opts.verify) {
2223
+ for (const file of [...new Set(result.written)]) {
2224
+ const verdict = opts.verify(file);
2225
+ if (!verdict || verdict.valid)
2226
+ continue;
2227
+ const backup = backupOf.get(file);
2228
+ if (!backup) {
2229
+ // Nothing to restore to — the file did not exist before this run. Deleting
2230
+ // it would be the honest inverse, but a config we created and a config the
2231
+ // runtime created are indistinguishable by now, so say so instead of
2232
+ // guessing. Leaving a file we cannot vouch for, unmentioned, is the one
2233
+ // outcome that is not allowed.
2234
+ result.failures.push(`${file} did not validate after being written, and there was no previous version to restore:\n ${verdict.problems}`);
2235
+ continue;
2236
+ }
2237
+ try {
2238
+ fs.copyFileSync(backup, file);
2239
+ result.rolledBack.push(file);
2240
+ result.written = result.written.filter((f) => f !== file);
2241
+ result.failures.push(`${file} did not validate after being written, so the original was restored from ${backup}:\n ${verdict.problems}`);
2242
+ }
2243
+ catch (err) {
2244
+ result.failures.push(`${file} did not validate AND could not be restored from ${backup} (${err instanceof Error ? err.message : String(err)}). ` +
2245
+ `The backup is still on disk — put it back by hand before starting the runtime.`);
2246
+ }
2247
+ }
778
2248
  }
779
2249
  return result;
780
2250
  }
@@ -790,7 +2260,7 @@ async function verifyRegistration(privkey, endpoint) {
790
2260
  try {
791
2261
  const atbash = new sdk_1.Atbash(privkey, {
792
2262
  endpoint,
793
- blockchainRid: (0, sdk_1.resolve)("blockchainRid") || undefined,
2263
+ network: (0, sdk_1.resolve)("defaultChainNetwork") || undefined,
794
2264
  orgName: (0, sdk_1.resolve)("orgName") || undefined,
795
2265
  });
796
2266
  return (await atbash.checkAgentExists()) ? { state: "registered" } : { state: "unregistered" };
@@ -799,12 +2269,19 @@ async function verifyRegistration(privkey, endpoint) {
799
2269
  return { state: "unknown", reason: err instanceof Error ? err.message : String(err) };
800
2270
  }
801
2271
  }
802
- /** y/N confirmation. Anything but an explicit yes is a no. */
803
- async function confirm(question) {
2272
+ /**
2273
+ * Confirmation prompt. Defaults to NO — anything but an explicit yes is a no —
2274
+ * except where `defaultYes` is set, which is only for questions where the safe
2275
+ * answer is also the common one (reusing the key already on this machine).
2276
+ */
2277
+ async function confirm(question, defaultYes = false) {
804
2278
  const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
805
2279
  const rl = createInterface({ input: process.stdin, output: process.stdout });
806
2280
  const answer = await new Promise((r) => rl.question(question, (a) => { rl.close(); r(a); }));
807
- return /^y(es)?$/i.test(answer.trim());
2281
+ const trimmed = answer.trim();
2282
+ if (!trimmed)
2283
+ return defaultYes;
2284
+ return /^y(es)?$/i.test(trimmed);
808
2285
  }
809
2286
  // ── The command ─────────────────────────────────────────────────────────────
810
2287
  function registerSetupCommand(program) {
@@ -816,12 +2293,14 @@ function registerSetupCommand(program) {
816
2293
  .option("--keys-dir <dir>", "Directory holding the key file, e.g. ~/Downloads")
817
2294
  .option("--host <url>", "Atbash deployment to check the agent's registration against")
818
2295
  .option("--runtime <ids...>", "Only configure these runtimes (currently: openclaw)")
2296
+ .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")
819
2297
  .option("--dry-run", "Show exactly which files would change, and the diffs, then exit WITHOUT writing anything")
820
2298
  .option("-y, --yes", "Do not ask for confirmation before writing")
821
2299
  .option("--no-install", "Do not install any package; write the key file and configs only")
822
2300
  .option("--skip-verify", "Do not check the agent's registration (no network calls at all)")
823
2301
  .option("--allow-unrecognized-host", "Permit a --host that is not a known Atbash deployment")
824
2302
  .option("--home <dir>", "Home directory to configure (for testing)")
2303
+ .option("--replace-key", "Consent to changing which agent this machine signs as, when --key names a different agent than the one already here (needed for non-interactive runs)")
825
2304
  .action(async (opts) => {
826
2305
  const home = opts.home || process.env.HOME || os.homedir();
827
2306
  const dryRun = !!opts.dryRun;
@@ -854,9 +2333,62 @@ function registerSetupCommand(program) {
854
2333
  process.exit(1);
855
2334
  }
856
2335
  console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
2336
+ // ── Replacing this machine's AGENT IDENTITY is its own decision.
2337
+ //
2338
+ // Everything else this command does is additive: a config entry, a plugin,
2339
+ // a key file where there was none. This one is a substitution, and it is
2340
+ // the only change here that alters WHO the machine is. After it, every
2341
+ // integration reading the default key path signs as a different on-chain
2342
+ // agent, so tool calls are attributed to someone else — including tool
2343
+ // calls made by work that was already running.
2344
+ //
2345
+ // It used to be folded into the single "Apply N changes to this machine?"
2346
+ // prompt, listed as one write among several. An operator concentrating on
2347
+ // the plugin config could accept it without registering that their agent
2348
+ // identity changed, and `--yes` — which reasonably means "don't ask me
2349
+ // about the file writes" — skipped it entirely. So it gets asked
2350
+ // separately, and a blanket `--yes` does not answer it: saying yes to
2351
+ // writing files is not the same as saying yes to becoming a different
2352
+ // agent. `--replace-key` is how a script says it deliberately.
2353
+ //
2354
+ // Declining ABORTS rather than continuing, because continuing cannot honour
2355
+ // what was asked. The operator ran `--key <NEW>` meaning "wire this machine
2356
+ // to NEW"; with the key left alone the machine keeps signing as OLD, so
2357
+ // proceeding would configure a runtime that reports one agent while the
2358
+ // dashboard shows another — the exact silent mismatch the rest of this work
2359
+ // exists to remove.
2360
+ const canonicalKeyFile = path.join(home, ...KEY_FILE_REL);
2361
+ const existingRaw = readTextFile(canonicalKeyFile);
2362
+ const existingMaterial = existingRaw === null ? null : parseKeyMaterial(existingRaw);
2363
+ const existingPubkey = existingMaterial ? (0, sdk_1.derivePublicKey)(existingMaterial.privkey) : undefined;
2364
+ if (existingPubkey && existingPubkey.toLowerCase() !== pubkey.toLowerCase()) {
2365
+ const archive = path.join(home, ".config", "atbash", "keys", `${existingPubkey}.key`);
2366
+ console.log(chalk_1.default.yellow("\n This machine is about to change which agent it signs as.") +
2367
+ chalk_1.default.dim(`\n now: ${existingPubkey}`) +
2368
+ chalk_1.default.dim(`\n new: ${pubkey}`) +
2369
+ chalk_1.default.dim(`\n Every integration on this machine reading ${canonicalKeyFile}`) +
2370
+ chalk_1.default.dim("\n will switch to the new agent. The current key is archived first, to") +
2371
+ chalk_1.default.dim(`\n ${archive}, so the old agent stays recoverable.\n`));
2372
+ const approved = opts.replaceKey
2373
+ ? true
2374
+ : dryRun
2375
+ ? true // a preview decides nothing; the real run will still ask
2376
+ : process.stdin.isTTY
2377
+ ? await confirm(" Replace the agent this machine signs as? [y/N] ")
2378
+ : false;
2379
+ if (!approved) {
2380
+ console.log(chalk_1.default.dim("\n Nothing was changed.") +
2381
+ chalk_1.default.dim(`\n This machine still signs as ${existingPubkey}.`) +
2382
+ (process.stdin.isTTY
2383
+ ? chalk_1.default.dim("\n Re-run without --key to configure it for the agent already here.\n")
2384
+ : chalk_1.default.dim("\n Re-run with --replace-key to switch agents non-interactively,") +
2385
+ chalk_1.default.dim("\n or without --key to configure it for the agent already here.\n")));
2386
+ return;
2387
+ }
2388
+ }
857
2389
  // ── Registration check. Only the public key crosses the network.
858
2390
  if (!opts.skipVerify) {
859
- const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || connect_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
2391
+ const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
860
2392
  let hostname = "";
861
2393
  try {
862
2394
  hostname = new URL(endpoint).hostname.toLowerCase();
@@ -868,7 +2400,8 @@ function registerSetupCommand(program) {
868
2400
  // An unrecognized host could answer "registered" for any key, which is
869
2401
  // exactly the confirmation this check exists to provide. Exact hostname
870
2402
  // match, never a suffix — "atbash.ai.evil.com" must not pass.
871
- if (!connect_1.KNOWN_HOSTS.has(hostname) && !opts.allowUnrecognizedHost) {
2403
+ const recognizedHost = atbash_targets_1.KNOWN_HOSTS.has(hostname);
2404
+ if (!recognizedHost && !opts.allowUnrecognizedHost) {
872
2405
  console.error(chalk_1.default.red(`\n ${hostname} is not a recognized Atbash deployment.`) +
873
2406
  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"));
874
2407
  process.exit(1);
@@ -876,7 +2409,21 @@ function registerSetupCommand(program) {
876
2409
  const verdict = await verifyRegistration(privkey, endpoint);
877
2410
  if (verdict.state === "unregistered") {
878
2411
  console.error(chalk_1.default.red("\n That agent is not registered on this deployment.") +
879
- 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`));
2412
+ chalk_1.default.dim(`\n Public key: ${pubkey}\n Key came from: ${keySource.from}\n`));
2413
+ // The likeliest cause is not "you skipped onboarding" — it is "this is a
2414
+ // key you did not choose". Say so when the key was found rather than
2415
+ // supplied, because the fix is completely different.
2416
+ if (/existing key file|atbash config/.test(keySource.from)) {
2417
+ console.error(chalk_1.default.yellow(" This is a key that was already on this machine, not one you supplied.") +
2418
+ chalk_1.default.dim("\n If you are onboarding a DIFFERENT agent, pass its key explicitly:" +
2419
+ "\n --key <64-hex> the key shown in the browser" +
2420
+ "\n --keys-dir ~/Downloads the key file you saved" +
2421
+ "\n Or re-run and answer 'n' when asked whether to use the existing key.\n"));
2422
+ }
2423
+ else {
2424
+ console.error(chalk_1.default.dim(" Finish onboarding first — wiring a runtime to an unregistered agent leaves it\n" +
2425
+ " looking governed while the plugin can never get a verdict.\n"));
2426
+ }
880
2427
  process.exit(1);
881
2428
  }
882
2429
  if (verdict.state === "unknown") {
@@ -887,9 +2434,74 @@ function registerSetupCommand(program) {
887
2434
  return;
888
2435
  }
889
2436
  }
890
- else {
2437
+ else if (recognizedHost) {
891
2438
  console.log(chalk_1.default.green(` Agent is registered on ${hostname}.`));
892
2439
  }
2440
+ else {
2441
+ // --allow-unrecognized-host is a real bypass, and its most dangerous
2442
+ // property is that the check still PRINTS a reassuring answer. A host
2443
+ // chosen by an attacker returns "registered" for any key at all, so a
2444
+ // "✓ registered" line here would be the attacker's own claim wearing
2445
+ // Atbash's voice. Never let that line stand unqualified: say the answer
2446
+ // came from an unvouched-for server, so a talked-into-it operator sees
2447
+ // the one thing that would tell them something is wrong.
2448
+ console.log(chalk_1.default.yellow(` ${hostname} answered "registered" — but this is NOT a recognized Atbash deployment.`));
2449
+ console.log(chalk_1.default.yellow(" A registration check against an unrecognized host proves nothing: any server") +
2450
+ chalk_1.default.yellow("\n can answer \"registered\" for any key. Treat this as UNVERIFIED."));
2451
+ console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
2452
+ }
2453
+ }
2454
+ // ── What OpenClaw is on this machine, and can we ask it to do the work?
2455
+ //
2456
+ // This runs BEFORE the plan, and before anything is written, because both
2457
+ // answers change what we do:
2458
+ //
2459
+ // - No OpenClaw at all → say so and stop touching OpenClaw. The likeliest
2460
+ // cause is the operator running this on the wrong computer, and the
2461
+ // previous behavior (write config for a runtime that is not here) left
2462
+ // a machine carrying governance for an agent it does not run.
2463
+ // - An OLD OpenClaw → still supported, still wired. It just cannot
2464
+ // validate the change for us, so we say that once, offer the upgrade
2465
+ // command FOR ITS OWN CHANNEL, and continue. Refusing here would break
2466
+ // 2026.2.x boxes that demonstrably work.
2467
+ //
2468
+ // Nothing below compares version numbers to decide anything: capability is
2469
+ // probed from the binary. A version table is exactly what went stale and
2470
+ // produced the config that broke the reporter's machine.
2471
+ const wantsOpenclaw = wantedRuntime("openclaw", opts.runtime ?? []);
2472
+ const openclaw = wantsOpenclaw ? (0, openclaw_runtime_1.detectOpenclaw)() : undefined;
2473
+ if (openclaw) {
2474
+ const status = (0, openclaw_runtime_1.supportStatus)(openclaw);
2475
+ console.log(status.supported ? chalk_1.default.dim(`\n ${status.message}`) : chalk_1.default.yellow(`\n ${status.message}`));
2476
+ if (status.upgrade) {
2477
+ console.log(chalk_1.default.dim(" To upgrade it: ") + chalk_1.default.cyan(status.upgrade));
2478
+ }
2479
+ // ── PREFLIGHT. The step this command never had.
2480
+ //
2481
+ // Setup merged into ~/.openclaw/openclaw.json without ever checking
2482
+ // whether that file currently loads. On a machine whose config was
2483
+ // ALREADY broken it added its entry to an unloadable file and then its
2484
+ // own `openclaw plugins install` step died on the pre-existing breakage —
2485
+ // reported to the operator as an Atbash failure, which it was not.
2486
+ //
2487
+ // A `valid: false` here is NOT a reason to refuse: the operator may well
2488
+ // be running setup precisely because their config is broken, and some of
2489
+ // what we do (pruning a retired `load.paths`) is the repair. So report
2490
+ // the problems in OpenClaw's own words and carry on — the point is that
2491
+ // nobody is left debugging our step for someone else's breakage.
2492
+ if (openclaw.caps.validate) {
2493
+ const configPath = path.join(home, ...OPENCLAW_CONFIG_REL);
2494
+ if (fs.existsSync(configPath)) {
2495
+ const verdict = (0, openclaw_runtime_1.validateConfig)(openclaw, configPath, "openclaw", home);
2496
+ if (verdict && !verdict.valid) {
2497
+ console.log(chalk_1.default.yellow("\n ⚠ OpenClaw reports your CURRENT config is invalid, before Atbash changes anything:") +
2498
+ chalk_1.default.dim(`\n${verdict.problems.split("\n").map((l) => ` ${l}`).join("\n")}`) +
2499
+ chalk_1.default.dim("\n Atbash will still wire its entry, and will back the file up first. If the") +
2500
+ chalk_1.default.dim("\n problem above is not one Atbash introduced, it needs fixing separately —") +
2501
+ chalk_1.default.dim("\n OpenClaw will not load ANY config while it stands, so the plugin cannot run.\n"));
2502
+ }
2503
+ }
2504
+ }
893
2505
  }
894
2506
  // ── Plan, show, then (maybe) apply.
895
2507
  const plan = buildPlan({
@@ -898,7 +2510,24 @@ function registerSetupCommand(program) {
898
2510
  pubkey,
899
2511
  noInstall: opts.install === false,
900
2512
  only: opts.runtime ?? [],
2513
+ orgName: opts.orgName,
2514
+ // Lets the plan hand the config merge to OpenClaw where it can do it, and
2515
+ // tells it whether THIS build accepts `hooks` on a plugin entry — asked of
2516
+ // `openclaw config schema`, since the same key is fatal on 2026.2.x and
2517
+ // legitimate on 2026.6.6+. Unknown resolves to "not supported", which is
2518
+ // the fail-safe direction.
2519
+ ...(openclaw ? { openclaw: { ...openclaw, hooksSupported: (0, openclaw_runtime_1.pluginEntryAcceptsHooks)(openclaw) === true } } : {}),
2520
+ // The same host the registration check used, so the plugin build and the
2521
+ // chain the agent lives on cannot disagree.
2522
+ endpoint: opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST,
901
2523
  });
2524
+ // Private-chain orgs need it and it cannot be derived here, so say so
2525
+ // rather than writing a config that resolves the wrong chain in silence.
2526
+ if (!opts.orgName?.trim() && plan.found.includes("OpenClaw")) {
2527
+ console.log(chalk_1.default.dim("\n No --org-name given. The OpenClaw plugin resolves which chain to query from it,") +
2528
+ chalk_1.default.dim("\n so add it yourself if your org is on a private chain (harmless otherwise):") +
2529
+ chalk_1.default.dim("\n plugins.entries.atbash-openclaw.config.orgName\n"));
2530
+ }
902
2531
  renderPlan(plan, pubkey);
903
2532
  const changes = plan.steps.filter((s) => s.kind === "write" || s.kind === "exec");
904
2533
  if (dryRun) {
@@ -921,7 +2550,16 @@ function registerSetupCommand(program) {
921
2550
  return;
922
2551
  }
923
2552
  }
924
- const result = applyPlan(plan);
2553
+ // Verification is OpenClaw's own answer, not ours — and only for the file
2554
+ // it is the authority on. Setup used to report success based purely on
2555
+ // having written what it intended to write, which is how a machine with
2556
+ // nothing installed was told it was "fully wired".
2557
+ const openclawConfigPath = path.join(home, ...OPENCLAW_CONFIG_REL);
2558
+ const result = applyPlan(plan, {
2559
+ verify: openclaw?.caps.validate
2560
+ ? (file) => (path.resolve(file) === path.resolve(openclawConfigPath) ? (0, openclaw_runtime_1.validateConfig)(openclaw, file, "openclaw", home) : undefined)
2561
+ : undefined,
2562
+ });
925
2563
  console.log();
926
2564
  for (const file of result.written)
927
2565
  console.log(chalk_1.default.green(` ✓ wrote ${file}`));
@@ -929,14 +2567,85 @@ function registerSetupCommand(program) {
929
2567
  console.log(chalk_1.default.dim(` backup: ${file}`));
930
2568
  for (const cmd of result.ran)
931
2569
  console.log(chalk_1.default.green(` ✓ ran ${cmd}`));
2570
+ for (const file of result.rolledBack)
2571
+ console.log(chalk_1.default.yellow(` ↩ restored ${file} — the change did not validate`));
2572
+ // A write we deliberately did not make is not a silent non-event: it is the
2573
+ // difference between "no config" and "config for a plugin that isn't there",
2574
+ // and the operator has to know which one they have.
2575
+ for (const skip of result.skipped)
2576
+ console.log(chalk_1.default.yellow(` ⊘ ${skip}`));
932
2577
  for (const failure of result.failures)
933
2578
  console.log(chalk_1.default.red(` ✗ ${failure}`));
934
- if (result.failures.length) {
2579
+ if (result.failures.length || result.skipped.length) {
935
2580
  console.log(chalk_1.default.yellow("\n Finished with failures — this machine is NOT fully wired.") +
936
2581
  chalk_1.default.dim("\n Everything that did succeed is listed above; the steps that failed can be re-run.\n"));
937
2582
  process.exitCode = 1;
938
2583
  return;
939
2584
  }
2585
+ // ── Check, do not assume.
2586
+ //
2587
+ // Every step reporting success is not the same as the runtime having
2588
+ // picked the plugin up, and this command used to print "Done" on the
2589
+ // strength of the former. On a real machine the install succeeded, the
2590
+ // entry point was correct, and Hermes still listed nothing — so the agent
2591
+ // was ungoverned behind a green summary. Where a runtime can be asked, ask.
2592
+ if (plan.found.includes("Hermes") && wantedRuntime("hermes", opts.runtime ?? [])) {
2593
+ const state = hermesPluginState(home);
2594
+ if (state === "configured") {
2595
+ console.log(chalk_1.default.green("\n Hermes is configured to load the plugin.") +
2596
+ chalk_1.default.dim("\n It takes effect on the next Hermes start. Confirm the hook registered:") +
2597
+ chalk_1.default.dim("\n tail -n 50 ~/.hermes/logs/agent.log | grep -i atbash") +
2598
+ chalk_1.default.dim("\n (`hermes plugins list` will NOT show it — that listing only walks plugin") +
2599
+ chalk_1.default.dim("\n directories and cannot see a pip-installed one.)\n"));
2600
+ }
2601
+ else if (state === "not-enabled") {
2602
+ console.log(chalk_1.default.yellow("\n The plugin is installed but NOT on Hermes' enabled allow-list — this agent is not governed.") +
2603
+ chalk_1.default.dim("\n Hermes loads only what is listed. Add it to ~/.hermes/config.yaml:") +
2604
+ chalk_1.default.dim("\n plugins:\n enabled:\n - " + HERMES_PLUGIN_ENTRY + "\n"));
2605
+ process.exitCode = 1;
2606
+ return;
2607
+ }
2608
+ else if (state === "not-installed") {
2609
+ console.log(chalk_1.default.yellow("\n The plugin is not installed in the interpreter that runs Hermes — this agent is not governed.\n"));
2610
+ process.exitCode = 1;
2611
+ return;
2612
+ }
2613
+ // "unknown": nothing readable to judge by, so claim nothing.
2614
+ }
2615
+ // ── OpenClaw gets the last word about OpenClaw.
2616
+ //
2617
+ // Everything above reports what setup DID. This reports what OpenClaw now
2618
+ // SEES, which is the only thing that answers "is this agent governed?".
2619
+ // The reporter's machine had a config entry, no plugin, and a green
2620
+ // summary — a false green on a control boundary is the most expensive
2621
+ // failure this command can produce.
2622
+ if (openclaw?.present && plan.found.includes("OpenClaw") && wantedRuntime("openclaw", opts.runtime ?? [])) {
2623
+ const verdict = (0, openclaw_runtime_1.verifyOpenclawPlugin)(openclaw, {
2624
+ entryIds: [OPENCLAW_ENTRY, OPENCLAW_LEGACY_ENTRY],
2625
+ configPath: openclawConfigPath,
2626
+ // Pin HOME too: plugin discovery walks $HOME/.openclaw, so without it a
2627
+ // --home run reports the operator's real machine instead of this one.
2628
+ home,
2629
+ expectedSpec: openclawPackageForHost(opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST),
2630
+ });
2631
+ if (verdict.state === "loaded") {
2632
+ console.log(chalk_1.default.green(`\n OpenClaw loads the plugin — entry \`${verdict.entry}\`${verdict.version ? `, build ${verdict.version}` : ""}.`) +
2633
+ chalk_1.default.dim("\n Its hook registers when the gateway starts, so restart the gateway to enforce.\n"));
2634
+ }
2635
+ else if (verdict.state === "unknown") {
2636
+ // Say plainly that this is unverified rather than papering over it.
2637
+ console.log(chalk_1.default.yellow("\n Could not confirm with OpenClaw whether the plugin loaded.") +
2638
+ chalk_1.default.dim(`\n ${verdict.detail ?? ""}\n Check it yourself with: `) + chalk_1.default.cyan("openclaw plugins list") + "\n");
2639
+ }
2640
+ else {
2641
+ console.log(chalk_1.default.yellow(`\n This agent is NOT governed yet — OpenClaw does not report the plugin as loaded.`) +
2642
+ chalk_1.default.dim(`\n ${verdict.detail ?? ""}`) +
2643
+ chalk_1.default.dim(`\n Inspect it with: `) + chalk_1.default.cyan("openclaw plugins list") + chalk_1.default.dim(" and ") + chalk_1.default.cyan("openclaw doctor") +
2644
+ chalk_1.default.dim("\n ⚠ `openclaw doctor --fix` will DELETE an Atbash entry it considers stale, so read its plan before accepting.\n"));
2645
+ process.exitCode = 1;
2646
+ return;
2647
+ }
2648
+ }
940
2649
  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"));
941
2650
  console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
942
2651
  });