@atbash/cli 0.5.15-dev.12 → 0.5.15-dev.15

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.
@@ -42,7 +42,9 @@ 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;
46
48
  exports.detectIndent = detectIndent;
47
49
  exports.serializeLike = serializeLike;
48
50
  exports.detectMcpClients = detectMcpClients;
@@ -55,6 +57,7 @@ exports.pythonInstallStrategy = pythonInstallStrategy;
55
57
  exports.mergeHermesEnv = mergeHermesEnv;
56
58
  exports.hadInlineKey = hadInlineKey;
57
59
  exports.mergeMcpServer = mergeMcpServer;
60
+ exports.openclawPatchPayload = openclawPatchPayload;
58
61
  exports.buildPlan = buildPlan;
59
62
  exports.lineDiff = lineDiff;
60
63
  exports.renderPlan = renderPlan;
@@ -70,6 +73,7 @@ const jsonc = __importStar(require("jsonc-parser"));
70
73
  const yaml_1 = require("yaml");
71
74
  const sdk_1 = require("@atbash/sdk");
72
75
  const atbash_targets_1 = require("../shared/atbash-targets");
76
+ const openclaw_runtime_1 = require("../shared/openclaw-runtime");
73
77
  /**
74
78
  * `atbash setup` — the write half of onboarding.
75
79
  *
@@ -124,13 +128,15 @@ const OPENCLAW_CONFIG_REL = [".openclaw", "openclaw.json"];
124
128
  const OPENCLAW_EXTENSIONS_REL = [".openclaw", "extensions"];
125
129
  const HERMES_AGENT_REL = [".hermes", "hermes-agent"];
126
130
  /**
127
- * The OpenClaw plugin, and the entry key it registers itself as.
131
+ * The OpenClaw plugin package.
128
132
  *
129
- * The entry key really is `openclaw` that is what `@atbash/atbash-openclaw`
130
- * registers as, not a copy-paste slip. Installs of the earlier
131
- * `@atbash/atbash-plugin` register under `atbash-plugin`, and the dashboard's
132
- * capability scan recognizes BOTH. So an existing config carrying the legacy key
133
- * is already governed and must not be given a second, duplicate entry.
133
+ * The doc comment here used to assert "the entry key really is `openclaw` not
134
+ * a copy-paste slip", which directly contradicts OPENCLAW_ENTRY three lines
135
+ * below and was the belief that made every config an earlier release wrote inert.
136
+ * Installs of the earlier `@atbash/atbash-plugin` register under
137
+ * `atbash-plugin`, and the dashboard's capability scan recognizes both that and
138
+ * `atbash-openclaw` — so an existing config carrying the legacy key is already
139
+ * governed and must not be given a second, duplicate entry.
134
140
  */
135
141
  const OPENCLAW_PKG = "@atbash/atbash-openclaw";
136
142
  /**
@@ -147,19 +153,38 @@ const OPENCLAW_ENTRY = "atbash-openclaw";
147
153
  /** Installs of the earlier `@atbash/atbash-plugin` register under this. */
148
154
  const OPENCLAW_LEGACY_ENTRY = "atbash-plugin";
149
155
  /**
150
- * Drop the `hooks` block from a plugin entry. ⚠️ It is not a key OpenClaw has.
156
+ * Drop the `hooks` block from a plugin entry.
151
157
  *
152
- * Earlier releases wrote one the published plugin docs show it and OpenClaw
153
- * rejects it. Verified against 2026.2.1 by feeding a generated config through
154
- * OPENCLAW_CONFIG_PATH:
158
+ * ⚠️ WHETHER `hooks` IS VALID DEPENDS ON THE OPENCLAW VERSION, and this note
159
+ * used to state the 2026.2.x answer as a fact about OpenClaw itself.
160
+ *
161
+ * On 2026.2.1 it is not a key at all, and an unrecognized entry key is fatal —
162
+ * verified by feeding a generated config through OPENCLAW_CONFIG_PATH:
155
163
  *
156
164
  * - plugins.entries.atbash-openclaw: Unrecognized key: "hooks"
157
165
  *
158
- * The first repair scoped removal to the LEGACY entry, on the stated belief that
159
- * the modern entry documented the key. It does not: OpenClaw rejects it on
160
- * either, and rejecting means refusing to load ANY config, which also makes
161
- * `openclaw plugins install` exit 1. So it is stripped wherever it is found, and
162
- * never written.
166
+ * Rejecting means refusing to load ANY config, which also makes
167
+ * `openclaw plugins install` exit 1. But the key was ADDED later. Straight out
168
+ * of 2026.6.6's own `openclaw config schema`, an entry accepts
169
+ * `enabled | hooks | subagent | llm | config`, and `hooks` accepts
170
+ * `allowPromptInjection | allowConversationAccess | timeoutMs | timeouts`.
171
+ *
172
+ * We still strip it and never write it, now on evidence rather than on the claim
173
+ * above:
174
+ *
175
+ * 1. Nothing under `hooks` is required — the schema's `required` list is empty.
176
+ * 2. It gates hook FAMILIES this plugin does not use. `allowConversationAccess`
177
+ * covers `before_agent_run`, `llm_input`, `llm_output`, `agent_end` and
178
+ * friends; `allowPromptInjection` covers `before_prompt_build`. The plugin
179
+ * registers exactly one hook, `before_tool_call`, which is in neither
180
+ * (verified by grepping the installed plugin, not by reading its README).
181
+ * 3. Writing it would break every 2026.2.x machine for no gain, and those are
182
+ * real — the reference box for these attestations is one.
183
+ *
184
+ * ⚠️ RE-CHECK IF THE PLUGIN EVER ADDS A SECOND HOOK. The schema says
185
+ * "Non-bundled plugins must opt in explicitly", so a new conversation-reading
186
+ * hook would be silently denied on 2026.6.6+ with no config error to notice: the
187
+ * plugin would load, still gate tool calls, and quietly see nothing else.
163
188
  */
164
189
  function stripInvalidHooks(entry) {
165
190
  if ("hooks" in entry)
@@ -581,6 +606,37 @@ function isJsonc(text) {
581
606
  * `<your-username>` placeholder that people paste verbatim, producing a path that
582
607
  * does not exist and a plugin that never loads.
583
608
  */
609
+ /**
610
+ * WHICH entry this run governs, and whether a legacy one is being stood down.
611
+ *
612
+ * Extracted so the hand-merge and the `openclaw config patch` payload cannot
613
+ * disagree. Two implementations of this decision is exactly how a build ended up
614
+ * writing the modern plugin's `orgName` onto the LEGACY entry — whose schema is
615
+ * closed and has no such field — so OpenClaw rejected the whole file and the run
616
+ * broke its own install step.
617
+ *
618
+ * An entry is not the only way the legacy plugin is present: `plugins.installs`
619
+ * records it independently, and an installed, allowed plugin loads with its
620
+ * defaults whether or not anyone wrote an entry for it.
621
+ */
622
+ function openclawEntryDecision(config, opts = {}) {
623
+ const installsModern = opts.installsModern !== false;
624
+ const plugins = isRecord(config.plugins) ? config.plugins : {};
625
+ const entries = isRecord(plugins.entries) ? plugins.entries : {};
626
+ const installs = isRecord(plugins.installs) ? plugins.installs : {};
627
+ const legacy = OPENCLAW_LEGACY_ENTRY in entries || OPENCLAW_LEGACY_ENTRY in installs;
628
+ const modern = OPENCLAW_ENTRY in entries;
629
+ // Only a --no-install run on a box whose sole entry is the legacy one keeps
630
+ // writing to the legacy key: there, nothing is arriving to replace it, and
631
+ // switching it off would leave the machine unguarded.
632
+ const useLegacy = !installsModern && legacy && !modern;
633
+ return {
634
+ entryKey: useLegacy ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY,
635
+ useLegacy,
636
+ legacy,
637
+ standDownLegacy: !useLegacy && legacy,
638
+ };
639
+ }
584
640
  function mergeOpenclawConfig(config, home, orgName, opts = {}) {
585
641
  const installsModern = opts.installsModern !== false;
586
642
  const out = { ...config };
@@ -605,12 +661,7 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
605
661
  // An entry is not the only way the legacy plugin is present: `plugins.installs`
606
662
  // records it independently, and a plugin that is installed and allowed loads
607
663
  // with its defaults whether or not anyone wrote an entry for it.
608
- const legacyEntry = OPENCLAW_LEGACY_ENTRY in entries;
609
- const installs = isRecord(plugins.installs) ? plugins.installs : {};
610
- const legacy = legacyEntry || OPENCLAW_LEGACY_ENTRY in installs;
611
- const modern = OPENCLAW_ENTRY in entries;
612
- const useLegacy = !installsModern && legacy && !modern;
613
- const entryKey = useLegacy ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY;
664
+ const { entryKey, useLegacy, legacy } = openclawEntryDecision(config, { installsModern });
614
665
  const existing = isRecord(entries[entryKey]) ? entries[entryKey] : null;
615
666
  // Stand the legacy plugin down when the modern one is taking over. Two live
616
667
  // entries means two hooks, so every tool call would be judged twice — against
@@ -681,16 +732,48 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
681
732
  out.plugins = plugins;
682
733
  return out;
683
734
  }
684
- // No entry yet — write the shape the published plugin documents, in full.
685
- const allow = reconcileAllow(plugins.allow, entryKey, !useLegacy && legacy) ?? [entryKey];
686
- plugins.allow = allow;
687
- const load = pruneRetiredLoadPath(plugins.load, home) ?? {};
688
- const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, entryKey);
689
- const paths = Array.isArray(load.paths) ? [...load.paths] : [];
690
- if (!paths.includes(extensionPath))
691
- paths.push(extensionPath);
692
- load.paths = paths;
693
- plugins.load = load;
735
+ // No entry yet — configure the ENTRY, and nothing else.
736
+ //
737
+ // ⚠️ THIS IS THE FRESH-INSTALL PATH, AND IT USED TO CREATE BOTH
738
+ // `plugins.allow` AND `plugins.load.paths`. That is the config a reporter got
739
+ // on a clean OpenClaw 2026.6.6, which then refused to load it:
740
+ //
741
+ // - plugins.load.paths: plugin path not found:
742
+ // /Users/me/.openclaw/extensions/atbash-openclaw
743
+ // - plugins.allow: plugins.allow now gates bundled provider discovery by
744
+ // default; run "openclaw doctor --fix" …
745
+ //
746
+ // `load.paths` was the fatal half, and note WHY it could never have worked: it
747
+ // named `~/.openclaw/extensions/<id>`, which is the 2026.2.x plugin store.
748
+ // 2026.6.x installs plugins as npm dependencies of `~/.openclaw` — into
749
+ // `~/.openclaw/node_modules` — and leaves `extensions/` empty. So we wrote a
750
+ // path OpenClaw was never going to create, and a `load.paths` entry naming a
751
+ // missing directory fails the ENTIRE config, which then takes down the
752
+ // `openclaw plugins install` in this very same run. The command broke its own
753
+ // install step and left behind a dangling entry that `openclaw doctor --fix`
754
+ // then offers to delete — silently un-governing the agent.
755
+ //
756
+ // Neither key is ours to write:
757
+ //
758
+ // - `openclaw plugins install` records where it put the plugin and loads from
759
+ // that record. The location has moved twice in six months across four
760
+ // channels; we cannot know it and must not guess it.
761
+ // - `allow` only matters if the operator KEEPS such a list, and newer builds
762
+ // have repurposed it to gate bundled provider discovery. Creating one where
763
+ // there was none newly restricts every other plugin on the machine — a side
764
+ // effect nobody asked this command for.
765
+ //
766
+ // Existing lists are still MAINTAINED, here and in the branch above, because an
767
+ // `allow` list that omits this entry means the plugin never loads however well
768
+ // configured it is. Adjust what is there; conjure nothing.
769
+ const reconciledAllow = reconcileAllow(plugins.allow, entryKey, !useLegacy && legacy);
770
+ if (reconciledAllow)
771
+ plugins.allow = reconciledAllow;
772
+ // A retired path is still pruned when present: that entry is fatal on every
773
+ // version, so removing it is repair rather than assertion.
774
+ const prunedLoad = pruneRetiredLoadPath(plugins.load, home);
775
+ if (prunedLoad)
776
+ plugins.load = prunedLoad;
694
777
  // `orgName` is the modern plugin's field. This branch can still land on the
695
778
  // legacy key — a `--no-install` run against a box where the old plugin is in
696
779
  // `plugins.installs` but nobody ever wrote it an entry — and its configSchema
@@ -779,6 +862,114 @@ function pruneRetiredLoadPath(current, home) {
779
862
  const paths = current.paths.filter((p) => typeof p !== "string" || path.resolve(expandHome(p.trim(), home)) !== path.resolve(retired));
780
863
  return paths.length === current.paths.length ? current : { ...current, paths };
781
864
  }
865
+ /**
866
+ * What `openclaw plugins install` would actually do with this spec.
867
+ *
868
+ * ⚠️ That command cannot overwrite. If the extension directory is there it
869
+ * aborts —
870
+ *
871
+ * plugin already exists: ~/.openclaw/extensions/atbash-openclaw
872
+ * (delete it first)
873
+ *
874
+ * — and exits 1. A plan that lists the install unconditionally therefore fails
875
+ * on its SECOND run against the same machine, and setup then reports "this
876
+ * machine is NOT fully wired" about a machine that is completely wired. Setup is
877
+ * meant to be re-runnable; a step that can only ever succeed once is not.
878
+ *
879
+ * There is no `plugins uninstall` in OpenClaw 2026.2.1 — the subcommands are
880
+ * list/info/enable/disable/install/update/doctor — so the way to refresh an
881
+ * existing install is `plugins update <id>`, which re-resolves the recorded spec
882
+ * and reports "up to date" when there is nothing to do.
883
+ */
884
+ /**
885
+ * The plugin version recorded by a 2026.7.x npm-project install, if there is one.
886
+ *
887
+ * `openclaw plugins install` creates `~/.openclaw/npm/projects/<mangled>/` whose
888
+ * package.json pins the plugin exactly:
889
+ *
890
+ * dependencies: { "@atbash/atbash-openclaw": "0.1.14-dev.0" }
891
+ *
892
+ * The directory name is derived from the package name (`@atbash/atbash-openclaw`
893
+ * → `atbash-atbash-openclaw-<hash>`), which is how the plugin is told apart from
894
+ * `@opentelemetry/api`, a dependency OpenClaw adds to every one of these projects.
895
+ */
896
+ function openclawNpmProjectVersion(home) {
897
+ const mangle = (name) => name.replace(/^@/, "").replace(/[/]/g, "-");
898
+ const projectsDir = path.join(home, ".openclaw", "npm", "projects");
899
+ let projects = [];
900
+ try {
901
+ projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
902
+ }
903
+ catch {
904
+ return undefined;
905
+ }
906
+ for (const project of projects) {
907
+ const raw = readTextFile(path.join(projectsDir, project, "package.json"));
908
+ if (raw === null)
909
+ continue;
910
+ let deps = {};
911
+ try {
912
+ const parsed = JSON.parse(raw);
913
+ deps = isRecord(parsed.dependencies) ? parsed.dependencies : {};
914
+ }
915
+ catch {
916
+ continue;
917
+ }
918
+ for (const [name, version] of Object.entries(deps)) {
919
+ if (!project.startsWith(mangle(name)))
920
+ continue;
921
+ if (name !== OPENCLAW_PKG && name !== OPENCLAW_ENTRY)
922
+ continue;
923
+ if (typeof version === "string")
924
+ return version;
925
+ }
926
+ }
927
+ return undefined;
928
+ }
929
+ function openclawInstallState(home, spec) {
930
+ // ── The 2026.7.x layout FIRST: it is what a current install produces, and it
931
+ // is authoritative whenever it is present.
932
+ //
933
+ // ⚠️ THIS FUNCTION USED TO READ ONLY `extensions/` + `plugins.installs`, and on
934
+ // a real 2026.7.1 machine that produced actively harmful advice. That machine
935
+ // carried BOTH a stale `extensions/atbash-openclaw` left from the 2026.2.x era
936
+ // AND the real install under `npm/projects/`. The old code saw the stale
937
+ // directory (installed = true), found no `plugins.installs` record — 2026.7.x
938
+ // keeps that in `~/.openclaw/state/openclaw.sqlite` — and concluded "a
939
+ // different, unidentifiable build is installed". Setup then told the operator
940
+ // to `rm -rf ~/.openclaw/extensions/atbash-openclaw` and reinstall, on a
941
+ // machine whose plugin was installed, correct, and loading. The release
942
+ // immediately before had planned a plain `plugins update` for the same box.
943
+ const projectVersion = openclawNpmProjectVersion(home);
944
+ if (projectVersion) {
945
+ // The npm project records a VERSION, not the `@dev`/plain TAG that was asked
946
+ // for, so compare lineage the way verifyOpenclawPlugin does: a `-dev.` build
947
+ // satisfies `@dev` and only that. Comparing the literal spec string here
948
+ // would report every correct install as a mismatch.
949
+ const wantsDev = /@dev$/.test(spec);
950
+ return { installed: true, sameSpec: wantsDev === /-dev\./.test(projectVersion), version: projectVersion };
951
+ }
952
+ if (!exists(path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY))) {
953
+ return { installed: false, sameSpec: false };
954
+ }
955
+ const raw = readTextFile(path.join(home, ...OPENCLAW_CONFIG_REL));
956
+ let record = null;
957
+ if (raw !== null) {
958
+ const parsed = jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false });
959
+ const installs = isRecord(parsed) && isRecord(parsed.plugins) ? parsed.plugins.installs : null;
960
+ if (isRecord(installs) && isRecord(installs[OPENCLAW_ENTRY])) {
961
+ record = installs[OPENCLAW_ENTRY];
962
+ }
963
+ }
964
+ return {
965
+ installed: true,
966
+ // No record means an install this command did not make and cannot identify.
967
+ // Treated as a mismatch: telling someone to `update` a plugin whose origin
968
+ // is unknown could quietly keep them on a build for the wrong chain.
969
+ sameSpec: typeof record?.spec === "string" && record.spec === spec,
970
+ version: typeof record?.version === "string" ? record.version : undefined,
971
+ };
972
+ }
782
973
  function keyPathUpdate(existingConfig, home) {
783
974
  const canonical = path.join(home, ...KEY_FILE_REL);
784
975
  const current = existingConfig.chromiaSecretPath;
@@ -1212,6 +1403,45 @@ function mergeMcpServer(config, serversKey = "mcpServers") {
1212
1403
  out[serversKey] = servers;
1213
1404
  return out;
1214
1405
  }
1406
+ /** The exec-step id the OpenClaw config write depends on. See Step.requires. */
1407
+ const OPENCLAW_INSTALL_ID = "openclaw-install";
1408
+ /**
1409
+ * The change we want, expressed as a PATCH rather than a whole file.
1410
+ *
1411
+ * `openclaw config patch` merges objects recursively and validates in one write,
1412
+ * so this is intent — "this entry should exist and look like this" — instead of
1413
+ * "here are the complete new bytes of your config". That difference is what makes
1414
+ * it survive the things that kept defeating us from outside: OpenClaw's own
1415
+ * format changes, key renames, comment preservation, file permissions, its
1416
+ * migrations, and the fact that it rewrites `openclaw.json` on its own (a machine
1417
+ * in the field carried three `openclaw.json.clobbered.<timestamp>` files).
1418
+ *
1419
+ * ⚠️ DO NOT call `config patch --dry-run` from buildPlan to preview this. It is
1420
+ * NOT side-effect free: running it triggers OpenClaw's state migrations, which
1421
+ * wrote `~/.openclaw/update-check.json.migrated` and moved config-health state
1422
+ * into SQLite on the machine this was developed on. `atbash setup --dry-run`
1423
+ * promises that nothing is written, and that promise has to hold for OpenClaw's
1424
+ * housekeeping too. The preview stays a merge we compute ourselves.
1425
+ */
1426
+ function openclawPatchPayload(args) {
1427
+ const entries = {
1428
+ [args.entryKey]: {
1429
+ enabled: true,
1430
+ config: {
1431
+ enabled: true,
1432
+ enforceDecision: true,
1433
+ chromiaSecretPath: args.keyPath,
1434
+ ...(args.entryKey !== OPENCLAW_LEGACY_ENTRY && args.orgName?.trim() ? { orgName: args.orgName.trim() } : {}),
1435
+ },
1436
+ },
1437
+ };
1438
+ // Two live entries means two hooks and every tool call judged twice against two
1439
+ // different SDKs, so the handover happens in the SAME patch as the new entry —
1440
+ // never as a second write that could land on its own.
1441
+ if (args.standDownLegacy)
1442
+ entries[OPENCLAW_LEGACY_ENTRY] = { enabled: false };
1443
+ return `${JSON.stringify({ plugins: { entries } }, null, 2)}\n`;
1444
+ }
1215
1445
  /** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
1216
1446
  function hasExecutable(command) {
1217
1447
  const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
@@ -1227,6 +1457,9 @@ function hasExecutable(command) {
1227
1457
  */
1228
1458
  function buildPlan(args) {
1229
1459
  const { home, privkey, pubkey, noInstall, only, orgName, endpoint } = args;
1460
+ // Capability, never a version comparison. A version table is what went stale
1461
+ // and produced a config that broke a fresh machine.
1462
+ const canPatch = Boolean(args.openclaw?.caps.patch);
1230
1463
  const steps = [];
1231
1464
  const notes = [];
1232
1465
  const found = [];
@@ -1289,14 +1522,48 @@ function buildPlan(args) {
1289
1522
  if (!noInstall) {
1290
1523
  if (hasExecutable("openclaw")) {
1291
1524
  const spec = openclawPackageForHost(endpoint);
1292
- steps.push({
1293
- kind: "exec",
1294
- label: spec.endsWith("@dev")
1295
- ? `Install ${spec} — the development build, matching the deployment this agent was onboarded on`
1296
- : `Install ${spec} — the production build`,
1297
- command: "openclaw",
1298
- args: ["plugins", "install", spec],
1299
- });
1525
+ const installed = openclawInstallState(home, spec);
1526
+ if (!installed.installed) {
1527
+ steps.push({
1528
+ kind: "exec",
1529
+ label: spec.endsWith("@dev")
1530
+ ? `Install ${spec} — the development build, matching the deployment this agent was onboarded on`
1531
+ : `Install ${spec} — the production build`,
1532
+ command: "openclaw",
1533
+ args: ["plugins", "install", spec],
1534
+ id: OPENCLAW_INSTALL_ID,
1535
+ });
1536
+ }
1537
+ else if (installed.sameSpec) {
1538
+ // Already the right package: refresh it rather than re-installing.
1539
+ // `plugins install` would abort with "plugin already exists" and take
1540
+ // the whole run down with it — see openclawInstallState.
1541
+ steps.push({
1542
+ kind: "exec",
1543
+ label: `Update ${spec} to the newest build under its tag (installed: ${installed.version ?? "unknown"})`,
1544
+ command: "openclaw",
1545
+ // Deliberately NOT tagged with OPENCLAW_INSTALL_ID: the plugin is
1546
+ // already on disk here, so the config entry refers to something real
1547
+ // whether or not this refresh succeeds. Gating the config on an
1548
+ // update would withhold a correct config over a failed download.
1549
+ args: ["plugins", "update", OPENCLAW_ENTRY],
1550
+ });
1551
+ }
1552
+ else {
1553
+ // A DIFFERENT spec is installed — typically the production build on a
1554
+ // machine onboarded against a dev deployment, or the reverse. This is
1555
+ // not cosmetic: the judge endpoint and chain ids come from the SDK the
1556
+ // plugin bundles, so the wrong build fails every judge call against a
1557
+ // chain this agent is not on. It cannot be fixed by `update`, which
1558
+ // re-resolves the spec already recorded, and 2026.2.1 has no
1559
+ // `plugins uninstall`, so the directory has to go by hand.
1560
+ steps.push({
1561
+ kind: "manual",
1562
+ label: `Replace the installed OpenClaw plugin with ${spec}`,
1563
+ 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:`,
1564
+ snippet: `rm -rf ${path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY)}\nopenclaw plugins install ${spec}`,
1565
+ });
1566
+ }
1300
1567
  if (spec.endsWith("@dev")) {
1301
1568
  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.");
1302
1569
  }
@@ -1311,14 +1578,60 @@ function buildPlan(args) {
1311
1578
  }
1312
1579
  }
1313
1580
  const raw = readTextFile(openclawConfigFile);
1581
+ // The config entry must not be written unless the plugin it names is going
1582
+ // to exist. `requires` is set only for a FRESH install — see Step.requires
1583
+ // for what a dangling entry costs.
1584
+ const requires = steps.some((s) => s.kind === "exec" && s.id === OPENCLAW_INSTALL_ID)
1585
+ ? OPENCLAW_INSTALL_ID
1586
+ : undefined;
1587
+ // Hand the merge to OpenClaw where it can do it: one validated write, in
1588
+ // its own current format, whatever that has become.
1589
+ //
1590
+ // The entry decision comes from the SAME function the hand-merge uses, so
1591
+ // the two paths cannot pick different keys — see openclawEntryDecision.
1592
+ let parsedForDecision = {};
1593
+ if (raw !== null) {
1594
+ try {
1595
+ const p = isJsonc(raw)
1596
+ ? jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false })
1597
+ : JSON.parse(raw);
1598
+ if (isRecord(p))
1599
+ parsedForDecision = p;
1600
+ }
1601
+ catch {
1602
+ /* an unparseable config decides nothing; treat it as empty */
1603
+ }
1604
+ }
1605
+ const decision = openclawEntryDecision(parsedForDecision, { installsModern: !noInstall });
1606
+ const patchPayload = canPatch
1607
+ ? openclawPatchPayload({
1608
+ entryKey: decision.entryKey,
1609
+ orgName,
1610
+ keyPath: `~/${KEY_FILE_REL.join("/")}`,
1611
+ standDownLegacy: decision.standDownLegacy,
1612
+ })
1613
+ : undefined;
1314
1614
  if (raw !== null && isJsonc(raw)) {
1315
- // Rewriting this would delete the owner's comments. Print instead.
1316
- steps.push({
1317
- kind: "manual",
1318
- label: `Enable the plugin in ${openclawConfigFile}`,
1319
- 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`:",
1320
- snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home, orgName, { installsModern: !noInstall }), null, 2),
1321
- });
1615
+ // A config with comments or trailing commas. Rewriting it as strict JSON
1616
+ // deletes the operator's comments, so we never do that.
1617
+ //
1618
+ // But `openclaw config patch` can edit this file WITHOUT losing them —
1619
+ // it is OpenClaw's own JSON5-aware writer. So where that exists, the
1620
+ // operator gets one command instead of a hand-merge, and this stops
1621
+ // being the awkward case.
1622
+ steps.push(patchPayload
1623
+ ? {
1624
+ kind: "manual",
1625
+ label: `Enable the plugin in ${openclawConfigFile}`,
1626
+ 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):",
1627
+ snippet: `openclaw config patch --stdin <<'JSON'\n${patchPayload.trimEnd()}\nJSON`,
1628
+ }
1629
+ : {
1630
+ kind: "manual",
1631
+ label: `Enable the plugin in ${openclawConfigFile}`,
1632
+ 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`:",
1633
+ snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home, orgName, { installsModern: !noInstall }), null, 2),
1634
+ });
1322
1635
  }
1323
1636
  else {
1324
1637
  let current = {};
@@ -1338,10 +1651,31 @@ function buildPlan(args) {
1338
1651
  kind: "write",
1339
1652
  label: raw === null
1340
1653
  ? "Create ~/.openclaw/openclaw.json with the plugin enabled"
1341
- : "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
1654
+ : patchPayload
1655
+ ? "Enable the plugin in ~/.openclaw/openclaw.json (OpenClaw applies and validates the merge itself)"
1656
+ : "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
1342
1657
  file: openclawConfigFile,
1343
1658
  before: raw,
1344
1659
  after,
1660
+ ...(requires ? { requires } : {}),
1661
+ // The diff above is the merge WE compute, and it is what the operator
1662
+ // approves. Where OpenClaw can apply the change itself we send it the
1663
+ // equivalent patch instead of these bytes, so the result is validated
1664
+ // and written in OpenClaw's own current format. The two agree on
1665
+ // content; OpenClaw may differ on formatting, and its formatting is
1666
+ // the correct one.
1667
+ ...(patchPayload
1668
+ ? {
1669
+ applyVia: {
1670
+ command: "openclaw",
1671
+ args: ["config", "patch", "--stdin"],
1672
+ payload: patchPayload,
1673
+ // Pin the target so the patch cannot land on a different
1674
+ // config than the one this step names — see applyVia.env.
1675
+ env: { OPENCLAW_CONFIG_PATH: openclawConfigFile },
1676
+ },
1677
+ }
1678
+ : {}),
1345
1679
  });
1346
1680
  }
1347
1681
  else {
@@ -1694,17 +2028,57 @@ function backupFile(file) {
1694
2028
  fs.copyFileSync(file, target);
1695
2029
  return target;
1696
2030
  }
1697
- /** Execute the plan. Writes first, then commands, so a failed install still
1698
- * leaves a correct config and key file behind for a manual retry. */
1699
- function applyPlan(plan) {
1700
- const result = { written: [], backups: [], ran: [], failures: [] };
1701
- for (const step of plan.steps) {
1702
- if (step.kind !== "write")
1703
- continue;
2031
+ /**
2032
+ * Execute the plan, in the order that cannot leave a half-wired machine.
2033
+ *
2034
+ * 1. Independent writes the key file above all. Safe on their own, useful
2035
+ * even if everything after fails, and required by the plugin at load.
2036
+ * 2. Commands — the plugin install, so the plugin id EXISTS on disk.
2037
+ * 3. Dependent writes — the plugin config, now that it refers to something
2038
+ * real. Skipped outright if its install failed (see Step.requires).
2039
+ * 4. Verify, and roll back anything we broke (see verify).
2040
+ *
2041
+ * ⚠️ THE ORDER IS THE FIX, and it is the reverse of what this function used to
2042
+ * do. "Writes first, then commands, so a failed install still leaves a correct
2043
+ * config behind" sounds prudent and produces the single worst outcome available:
2044
+ * a config entry for a plugin that is not installed, which OpenClaw reports as a
2045
+ * stale reference and offers to delete via `doctor --fix` — silently un-governing
2046
+ * the agent — and which on 2026.2.x helped make the config unloadable, killing
2047
+ * the install that would have fixed it.
2048
+ */
2049
+ function applyPlan(plan, opts = {}) {
2050
+ const result = { written: [], backups: [], ran: [], failures: [], skipped: [], rolledBack: [] };
2051
+ /** Backup path per file, so a verify failure can put the original back. */
2052
+ const backupOf = new Map();
2053
+ const writeStep = (step) => {
1704
2054
  try {
1705
2055
  const backup = backupFile(step.file);
1706
- if (backup)
2056
+ if (backup) {
1707
2057
  result.backups.push(backup);
2058
+ backupOf.set(step.file, backup);
2059
+ }
2060
+ // Hand the change to the runtime where the plan says to — it merges and
2061
+ // validates in its own format, which is the whole point.
2062
+ if (step.applyVia) {
2063
+ const run = (0, child_process_1.spawnSync)(step.applyVia.command, step.applyVia.args, {
2064
+ input: step.applyVia.payload,
2065
+ encoding: "utf8",
2066
+ timeout: 120000,
2067
+ env: { ...process.env, ...(step.applyVia.env ?? {}) },
2068
+ });
2069
+ const out = `${run.stdout ?? ""}${run.stderr ?? ""}`.trim();
2070
+ if (run.error || run.status !== 0) {
2071
+ // Do NOT silently fall back to writing the file ourselves. The runtime
2072
+ // refusing the change is information — usually that the config is
2073
+ // invalid for a reason we did not cause — and overwriting the file by
2074
+ // hand would bury it and destroy whatever the runtime was protecting.
2075
+ result.failures.push(`${step.applyVia.command} ${step.applyVia.args.join(" ")} did not apply the change to ${step.file}` +
2076
+ (out ? `:\n ${out.split("\n").join("\n ")}` : `: ${run.error?.message ?? `exit ${run.status}`}`));
2077
+ return;
2078
+ }
2079
+ result.written.push(step.file);
2080
+ return;
2081
+ }
1708
2082
  fs.mkdirSync(path.dirname(step.file), { recursive: true, mode: step.mode === KEY_MODE ? DIR_MODE : undefined });
1709
2083
  fs.writeFileSync(step.file, step.after, step.mode ? { mode: step.mode } : {});
1710
2084
  // writeFileSync's mode is ignored for a file that already existed, so
@@ -1716,7 +2090,14 @@ function applyPlan(plan) {
1716
2090
  catch (err) {
1717
2091
  result.failures.push(`${step.file}: ${err instanceof Error ? err.message : String(err)}`);
1718
2092
  }
2093
+ };
2094
+ // ── 1. Writes that depend on nothing.
2095
+ for (const step of plan.steps) {
2096
+ if (step.kind === "write" && !step.requires)
2097
+ writeStep(step);
1719
2098
  }
2099
+ // ── 2. Commands, recording which ones a dependent write may rely on.
2100
+ const succeeded = new Set();
1720
2101
  for (const step of plan.steps) {
1721
2102
  if (step.kind !== "exec")
1722
2103
  continue;
@@ -1736,6 +2117,8 @@ function applyPlan(plan) {
1736
2117
  }
1737
2118
  else if (run.status === 0) {
1738
2119
  result.ran.push(label);
2120
+ if (step.id)
2121
+ succeeded.add(step.id);
1739
2122
  }
1740
2123
  else if (run.signal) {
1741
2124
  result.failures.push(`${label} was killed by ${run.signal}`);
@@ -1744,6 +2127,52 @@ function applyPlan(plan) {
1744
2127
  result.failures.push(`${label} exited with code ${run.status}`);
1745
2128
  }
1746
2129
  }
2130
+ // ── 3. Writes that needed one of those commands to have worked.
2131
+ for (const step of plan.steps) {
2132
+ if (step.kind !== "write" || !step.requires)
2133
+ continue;
2134
+ if (!succeeded.has(step.requires)) {
2135
+ // The whole point: no config for a plugin that is not there. Say what was
2136
+ // NOT done and why, because silence here reads as success.
2137
+ result.skipped.push(`${step.file} was left unchanged: it configures something the step above did not manage to install, ` +
2138
+ `and a config entry for a missing plugin is worse than none — the runtime reports it as stale and ` +
2139
+ `offers to delete it. Fix the failure above and re-run; nothing needs undoing first.`);
2140
+ continue;
2141
+ }
2142
+ writeStep(step);
2143
+ }
2144
+ // ── 4. Verify, and undo what we broke.
2145
+ //
2146
+ // Only files WE wrote in this run, and only when the runtime can actually
2147
+ // answer. A `valid: false` on a file we did not touch is someone else's
2148
+ // problem to fix and not ours to revert.
2149
+ if (opts.verify) {
2150
+ for (const file of [...new Set(result.written)]) {
2151
+ const verdict = opts.verify(file);
2152
+ if (!verdict || verdict.valid)
2153
+ continue;
2154
+ const backup = backupOf.get(file);
2155
+ if (!backup) {
2156
+ // Nothing to restore to — the file did not exist before this run. Deleting
2157
+ // it would be the honest inverse, but a config we created and a config the
2158
+ // runtime created are indistinguishable by now, so say so instead of
2159
+ // guessing. Leaving a file we cannot vouch for, unmentioned, is the one
2160
+ // outcome that is not allowed.
2161
+ result.failures.push(`${file} did not validate after being written, and there was no previous version to restore:\n ${verdict.problems}`);
2162
+ continue;
2163
+ }
2164
+ try {
2165
+ fs.copyFileSync(backup, file);
2166
+ result.rolledBack.push(file);
2167
+ result.written = result.written.filter((f) => f !== file);
2168
+ result.failures.push(`${file} did not validate after being written, so the original was restored from ${backup}:\n ${verdict.problems}`);
2169
+ }
2170
+ catch (err) {
2171
+ result.failures.push(`${file} did not validate AND could not be restored from ${backup} (${err instanceof Error ? err.message : String(err)}). ` +
2172
+ `The backup is still on disk — put it back by hand before starting the runtime.`);
2173
+ }
2174
+ }
2175
+ }
1747
2176
  return result;
1748
2177
  }
1749
2178
  // ── Registration check ──────────────────────────────────────────────────────
@@ -1794,6 +2223,7 @@ function registerSetupCommand(program) {
1794
2223
  .option("--skip-verify", "Do not check the agent's registration (no network calls at all)")
1795
2224
  .option("--allow-unrecognized-host", "Permit a --host that is not a known Atbash deployment")
1796
2225
  .option("--home <dir>", "Home directory to configure (for testing)")
2226
+ .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)")
1797
2227
  .action(async (opts) => {
1798
2228
  const home = opts.home || process.env.HOME || os.homedir();
1799
2229
  const dryRun = !!opts.dryRun;
@@ -1826,6 +2256,59 @@ function registerSetupCommand(program) {
1826
2256
  process.exit(1);
1827
2257
  }
1828
2258
  console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
2259
+ // ── Replacing this machine's AGENT IDENTITY is its own decision.
2260
+ //
2261
+ // Everything else this command does is additive: a config entry, a plugin,
2262
+ // a key file where there was none. This one is a substitution, and it is
2263
+ // the only change here that alters WHO the machine is. After it, every
2264
+ // integration reading the default key path signs as a different on-chain
2265
+ // agent, so tool calls are attributed to someone else — including tool
2266
+ // calls made by work that was already running.
2267
+ //
2268
+ // It used to be folded into the single "Apply N changes to this machine?"
2269
+ // prompt, listed as one write among several. An operator concentrating on
2270
+ // the plugin config could accept it without registering that their agent
2271
+ // identity changed, and `--yes` — which reasonably means "don't ask me
2272
+ // about the file writes" — skipped it entirely. So it gets asked
2273
+ // separately, and a blanket `--yes` does not answer it: saying yes to
2274
+ // writing files is not the same as saying yes to becoming a different
2275
+ // agent. `--replace-key` is how a script says it deliberately.
2276
+ //
2277
+ // Declining ABORTS rather than continuing, because continuing cannot honour
2278
+ // what was asked. The operator ran `--key <NEW>` meaning "wire this machine
2279
+ // to NEW"; with the key left alone the machine keeps signing as OLD, so
2280
+ // proceeding would configure a runtime that reports one agent while the
2281
+ // dashboard shows another — the exact silent mismatch the rest of this work
2282
+ // exists to remove.
2283
+ const canonicalKeyFile = path.join(home, ...KEY_FILE_REL);
2284
+ const existingRaw = readTextFile(canonicalKeyFile);
2285
+ const existingMaterial = existingRaw === null ? null : parseKeyMaterial(existingRaw);
2286
+ const existingPubkey = existingMaterial ? (0, sdk_1.derivePublicKey)(existingMaterial.privkey) : undefined;
2287
+ if (existingPubkey && existingPubkey.toLowerCase() !== pubkey.toLowerCase()) {
2288
+ const archive = path.join(home, ".config", "atbash", "keys", `${existingPubkey}.key`);
2289
+ console.log(chalk_1.default.yellow("\n This machine is about to change which agent it signs as.") +
2290
+ chalk_1.default.dim(`\n now: ${existingPubkey}`) +
2291
+ chalk_1.default.dim(`\n new: ${pubkey}`) +
2292
+ chalk_1.default.dim(`\n Every integration on this machine reading ${canonicalKeyFile}`) +
2293
+ chalk_1.default.dim("\n will switch to the new agent. The current key is archived first, to") +
2294
+ chalk_1.default.dim(`\n ${archive}, so the old agent stays recoverable.\n`));
2295
+ const approved = opts.replaceKey
2296
+ ? true
2297
+ : dryRun
2298
+ ? true // a preview decides nothing; the real run will still ask
2299
+ : process.stdin.isTTY
2300
+ ? await confirm(" Replace the agent this machine signs as? [y/N] ")
2301
+ : false;
2302
+ if (!approved) {
2303
+ console.log(chalk_1.default.dim("\n Nothing was changed.") +
2304
+ chalk_1.default.dim(`\n This machine still signs as ${existingPubkey}.`) +
2305
+ (process.stdin.isTTY
2306
+ ? chalk_1.default.dim("\n Re-run without --key to configure it for the agent already here.\n")
2307
+ : chalk_1.default.dim("\n Re-run with --replace-key to switch agents non-interactively,") +
2308
+ chalk_1.default.dim("\n or without --key to configure it for the agent already here.\n")));
2309
+ return;
2310
+ }
2311
+ }
1829
2312
  // ── Registration check. Only the public key crosses the network.
1830
2313
  if (!opts.skipVerify) {
1831
2314
  const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
@@ -1891,6 +2374,58 @@ function registerSetupCommand(program) {
1891
2374
  console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
1892
2375
  }
1893
2376
  }
2377
+ // ── What OpenClaw is on this machine, and can we ask it to do the work?
2378
+ //
2379
+ // This runs BEFORE the plan, and before anything is written, because both
2380
+ // answers change what we do:
2381
+ //
2382
+ // - No OpenClaw at all → say so and stop touching OpenClaw. The likeliest
2383
+ // cause is the operator running this on the wrong computer, and the
2384
+ // previous behavior (write config for a runtime that is not here) left
2385
+ // a machine carrying governance for an agent it does not run.
2386
+ // - An OLD OpenClaw → still supported, still wired. It just cannot
2387
+ // validate the change for us, so we say that once, offer the upgrade
2388
+ // command FOR ITS OWN CHANNEL, and continue. Refusing here would break
2389
+ // 2026.2.x boxes that demonstrably work.
2390
+ //
2391
+ // Nothing below compares version numbers to decide anything: capability is
2392
+ // probed from the binary. A version table is exactly what went stale and
2393
+ // produced the config that broke the reporter's machine.
2394
+ const wantsOpenclaw = wantedRuntime("openclaw", opts.runtime ?? []);
2395
+ const openclaw = wantsOpenclaw ? (0, openclaw_runtime_1.detectOpenclaw)() : undefined;
2396
+ if (openclaw) {
2397
+ const status = (0, openclaw_runtime_1.supportStatus)(openclaw);
2398
+ console.log(status.supported ? chalk_1.default.dim(`\n ${status.message}`) : chalk_1.default.yellow(`\n ${status.message}`));
2399
+ if (status.upgrade) {
2400
+ console.log(chalk_1.default.dim(" To upgrade it: ") + chalk_1.default.cyan(status.upgrade));
2401
+ }
2402
+ // ── PREFLIGHT. The step this command never had.
2403
+ //
2404
+ // Setup merged into ~/.openclaw/openclaw.json without ever checking
2405
+ // whether that file currently loads. On a machine whose config was
2406
+ // ALREADY broken it added its entry to an unloadable file and then its
2407
+ // own `openclaw plugins install` step died on the pre-existing breakage —
2408
+ // reported to the operator as an Atbash failure, which it was not.
2409
+ //
2410
+ // A `valid: false` here is NOT a reason to refuse: the operator may well
2411
+ // be running setup precisely because their config is broken, and some of
2412
+ // what we do (pruning a retired `load.paths`) is the repair. So report
2413
+ // the problems in OpenClaw's own words and carry on — the point is that
2414
+ // nobody is left debugging our step for someone else's breakage.
2415
+ if (openclaw.caps.validate) {
2416
+ const configPath = path.join(home, ...OPENCLAW_CONFIG_REL);
2417
+ if (fs.existsSync(configPath)) {
2418
+ const verdict = (0, openclaw_runtime_1.validateConfig)(openclaw, configPath, "openclaw", home);
2419
+ if (verdict && !verdict.valid) {
2420
+ console.log(chalk_1.default.yellow("\n ⚠ OpenClaw reports your CURRENT config is invalid, before Atbash changes anything:") +
2421
+ chalk_1.default.dim(`\n${verdict.problems.split("\n").map((l) => ` ${l}`).join("\n")}`) +
2422
+ chalk_1.default.dim("\n Atbash will still wire its entry, and will back the file up first. If the") +
2423
+ chalk_1.default.dim("\n problem above is not one Atbash introduced, it needs fixing separately —") +
2424
+ chalk_1.default.dim("\n OpenClaw will not load ANY config while it stands, so the plugin cannot run.\n"));
2425
+ }
2426
+ }
2427
+ }
2428
+ }
1894
2429
  // ── Plan, show, then (maybe) apply.
1895
2430
  const plan = buildPlan({
1896
2431
  home,
@@ -1899,6 +2434,8 @@ function registerSetupCommand(program) {
1899
2434
  noInstall: opts.install === false,
1900
2435
  only: opts.runtime ?? [],
1901
2436
  orgName: opts.orgName,
2437
+ // Lets the plan hand the config merge to OpenClaw where it can do it.
2438
+ ...(openclaw ? { openclaw } : {}),
1902
2439
  // The same host the registration check used, so the plugin build and the
1903
2440
  // chain the agent lives on cannot disagree.
1904
2441
  endpoint: opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST,
@@ -1932,7 +2469,16 @@ function registerSetupCommand(program) {
1932
2469
  return;
1933
2470
  }
1934
2471
  }
1935
- const result = applyPlan(plan);
2472
+ // Verification is OpenClaw's own answer, not ours — and only for the file
2473
+ // it is the authority on. Setup used to report success based purely on
2474
+ // having written what it intended to write, which is how a machine with
2475
+ // nothing installed was told it was "fully wired".
2476
+ const openclawConfigPath = path.join(home, ...OPENCLAW_CONFIG_REL);
2477
+ const result = applyPlan(plan, {
2478
+ verify: openclaw?.caps.validate
2479
+ ? (file) => (path.resolve(file) === path.resolve(openclawConfigPath) ? (0, openclaw_runtime_1.validateConfig)(openclaw, file, "openclaw", home) : undefined)
2480
+ : undefined,
2481
+ });
1936
2482
  console.log();
1937
2483
  for (const file of result.written)
1938
2484
  console.log(chalk_1.default.green(` ✓ wrote ${file}`));
@@ -1940,9 +2486,16 @@ function registerSetupCommand(program) {
1940
2486
  console.log(chalk_1.default.dim(` backup: ${file}`));
1941
2487
  for (const cmd of result.ran)
1942
2488
  console.log(chalk_1.default.green(` ✓ ran ${cmd}`));
2489
+ for (const file of result.rolledBack)
2490
+ console.log(chalk_1.default.yellow(` ↩ restored ${file} — the change did not validate`));
2491
+ // A write we deliberately did not make is not a silent non-event: it is the
2492
+ // difference between "no config" and "config for a plugin that isn't there",
2493
+ // and the operator has to know which one they have.
2494
+ for (const skip of result.skipped)
2495
+ console.log(chalk_1.default.yellow(` ⊘ ${skip}`));
1943
2496
  for (const failure of result.failures)
1944
2497
  console.log(chalk_1.default.red(` ✗ ${failure}`));
1945
- if (result.failures.length) {
2498
+ if (result.failures.length || result.skipped.length) {
1946
2499
  console.log(chalk_1.default.yellow("\n Finished with failures — this machine is NOT fully wired.") +
1947
2500
  chalk_1.default.dim("\n Everything that did succeed is listed above; the steps that failed can be re-run.\n"));
1948
2501
  process.exitCode = 1;
@@ -1978,6 +2531,40 @@ function registerSetupCommand(program) {
1978
2531
  }
1979
2532
  // "unknown": nothing readable to judge by, so claim nothing.
1980
2533
  }
2534
+ // ── OpenClaw gets the last word about OpenClaw.
2535
+ //
2536
+ // Everything above reports what setup DID. This reports what OpenClaw now
2537
+ // SEES, which is the only thing that answers "is this agent governed?".
2538
+ // The reporter's machine had a config entry, no plugin, and a green
2539
+ // summary — a false green on a control boundary is the most expensive
2540
+ // failure this command can produce.
2541
+ if (openclaw?.present && plan.found.includes("OpenClaw") && wantedRuntime("openclaw", opts.runtime ?? [])) {
2542
+ const verdict = (0, openclaw_runtime_1.verifyOpenclawPlugin)(openclaw, {
2543
+ entryIds: [OPENCLAW_ENTRY, OPENCLAW_LEGACY_ENTRY],
2544
+ configPath: openclawConfigPath,
2545
+ // Pin HOME too: plugin discovery walks $HOME/.openclaw, so without it a
2546
+ // --home run reports the operator's real machine instead of this one.
2547
+ home,
2548
+ expectedSpec: openclawPackageForHost(opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST),
2549
+ });
2550
+ if (verdict.state === "loaded") {
2551
+ console.log(chalk_1.default.green(`\n OpenClaw loads the plugin — entry \`${verdict.entry}\`${verdict.version ? `, build ${verdict.version}` : ""}.`) +
2552
+ chalk_1.default.dim("\n Its hook registers when the gateway starts, so restart the gateway to enforce.\n"));
2553
+ }
2554
+ else if (verdict.state === "unknown") {
2555
+ // Say plainly that this is unverified rather than papering over it.
2556
+ console.log(chalk_1.default.yellow("\n Could not confirm with OpenClaw whether the plugin loaded.") +
2557
+ chalk_1.default.dim(`\n ${verdict.detail ?? ""}\n Check it yourself with: `) + chalk_1.default.cyan("openclaw plugins list") + "\n");
2558
+ }
2559
+ else {
2560
+ console.log(chalk_1.default.yellow(`\n This agent is NOT governed yet — OpenClaw does not report the plugin as loaded.`) +
2561
+ chalk_1.default.dim(`\n ${verdict.detail ?? ""}`) +
2562
+ 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") +
2563
+ chalk_1.default.dim("\n ⚠ `openclaw doctor --fix` will DELETE an Atbash entry it considers stale, so read its plan before accepting.\n"));
2564
+ process.exitCode = 1;
2565
+ return;
2566
+ }
2567
+ }
1981
2568
  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"));
1982
2569
  console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
1983
2570
  });