@atbash/cli 0.5.15-dev.13 → 0.5.15-dev.16

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,6 +42,7 @@ 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;
46
47
  exports.openclawInstallState = openclawInstallState;
47
48
  exports.detectIndent = detectIndent;
@@ -56,6 +57,7 @@ exports.pythonInstallStrategy = pythonInstallStrategy;
56
57
  exports.mergeHermesEnv = mergeHermesEnv;
57
58
  exports.hadInlineKey = hadInlineKey;
58
59
  exports.mergeMcpServer = mergeMcpServer;
60
+ exports.openclawPatchPayload = openclawPatchPayload;
59
61
  exports.buildPlan = buildPlan;
60
62
  exports.lineDiff = lineDiff;
61
63
  exports.renderPlan = renderPlan;
@@ -71,6 +73,7 @@ const jsonc = __importStar(require("jsonc-parser"));
71
73
  const yaml_1 = require("yaml");
72
74
  const sdk_1 = require("@atbash/sdk");
73
75
  const atbash_targets_1 = require("../shared/atbash-targets");
76
+ const openclaw_runtime_1 = require("../shared/openclaw-runtime");
74
77
  /**
75
78
  * `atbash setup` — the write half of onboarding.
76
79
  *
@@ -125,13 +128,15 @@ const OPENCLAW_CONFIG_REL = [".openclaw", "openclaw.json"];
125
128
  const OPENCLAW_EXTENSIONS_REL = [".openclaw", "extensions"];
126
129
  const HERMES_AGENT_REL = [".hermes", "hermes-agent"];
127
130
  /**
128
- * The OpenClaw plugin, and the entry key it registers itself as.
129
- *
130
- * The entry key really is `openclaw` that is what `@atbash/atbash-openclaw`
131
- * registers as, not a copy-paste slip. Installs of the earlier
132
- * `@atbash/atbash-plugin` register under `atbash-plugin`, and the dashboard's
133
- * capability scan recognizes BOTH. So an existing config carrying the legacy key
134
- * is already governed and must not be given a second, duplicate entry.
131
+ * The OpenClaw plugin package.
132
+ *
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.
135
140
  */
136
141
  const OPENCLAW_PKG = "@atbash/atbash-openclaw";
137
142
  /**
@@ -148,22 +153,55 @@ const OPENCLAW_ENTRY = "atbash-openclaw";
148
153
  /** Installs of the earlier `@atbash/atbash-plugin` register under this. */
149
154
  const OPENCLAW_LEGACY_ENTRY = "atbash-plugin";
150
155
  /**
151
- * Drop the `hooks` block from a plugin entry. ⚠️ It is not a key OpenClaw has.
156
+ * Drop the `hooks` block from a plugin entry.
157
+ *
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.
152
160
  *
153
- * Earlier releases wrote one the published plugin docs show it and OpenClaw
154
- * rejects it. Verified against 2026.2.1 by feeding a generated config through
155
- * OPENCLAW_CONFIG_PATH:
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:
156
163
  *
157
164
  * - plugins.entries.atbash-openclaw: Unrecognized key: "hooks"
158
165
  *
159
- * The first repair scoped removal to the LEGACY entry, on the stated belief that
160
- * the modern entry documented the key. It does not: OpenClaw rejects it on
161
- * either, and rejecting means refusing to load ANY config, which also makes
162
- * `openclaw plugins install` exit 1. So it is stripped wherever it is found, and
163
- * 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.
164
188
  */
165
- function stripInvalidHooks(entry) {
166
- if ("hooks" in entry)
189
+ function stripInvalidHooks(entry, supported = false) {
190
+ // ⚠️ ONLY WHERE THE KEY IS ACTUALLY INVALID. This used to delete `hooks`
191
+ // unconditionally, which is right on 2026.2.x — where an unrecognized entry key
192
+ // makes OpenClaw refuse the whole config — and destructive on 2026.6.6+, where
193
+ // the key is valid, optional and undeprecated.
194
+ //
195
+ // `hooks.timeoutMs` bounds this plugin's hook latency "without changing plugin
196
+ // code". An operator who sets it has made a deliberate choice, and silently
197
+ // removing it on the next run is not repair — it also contradicts the rule
198
+ // stated further down this file: an existing entry is touched as little as
199
+ // possible.
200
+ //
201
+ // Defaults to `false` (strip), so a caller that cannot determine support gets
202
+ // the fail-safe behaviour. A lost timeout value is a far smaller harm than a
203
+ // config OpenClaw will not load at all.
204
+ if (!supported && "hooks" in entry)
167
205
  delete entry.hooks;
168
206
  return entry;
169
207
  }
@@ -582,8 +620,43 @@ function isJsonc(text) {
582
620
  * `<your-username>` placeholder that people paste verbatim, producing a path that
583
621
  * does not exist and a plugin that never loads.
584
622
  */
623
+ /**
624
+ * WHICH entry this run governs, and whether a legacy one is being stood down.
625
+ *
626
+ * Extracted so the hand-merge and the `openclaw config patch` payload cannot
627
+ * disagree. Two implementations of this decision is exactly how a build ended up
628
+ * writing the modern plugin's `orgName` onto the LEGACY entry — whose schema is
629
+ * closed and has no such field — so OpenClaw rejected the whole file and the run
630
+ * broke its own install step.
631
+ *
632
+ * An entry is not the only way the legacy plugin is present: `plugins.installs`
633
+ * records it independently, and an installed, allowed plugin loads with its
634
+ * defaults whether or not anyone wrote an entry for it.
635
+ */
636
+ function openclawEntryDecision(config, opts = {}) {
637
+ const installsModern = opts.installsModern !== false;
638
+ const plugins = isRecord(config.plugins) ? config.plugins : {};
639
+ const entries = isRecord(plugins.entries) ? plugins.entries : {};
640
+ const installs = isRecord(plugins.installs) ? plugins.installs : {};
641
+ const legacy = OPENCLAW_LEGACY_ENTRY in entries || OPENCLAW_LEGACY_ENTRY in installs;
642
+ const modern = OPENCLAW_ENTRY in entries;
643
+ // Only a --no-install run on a box whose sole entry is the legacy one keeps
644
+ // writing to the legacy key: there, nothing is arriving to replace it, and
645
+ // switching it off would leave the machine unguarded.
646
+ const useLegacy = !installsModern && legacy && !modern;
647
+ return {
648
+ entryKey: useLegacy ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY,
649
+ useLegacy,
650
+ legacy,
651
+ standDownLegacy: !useLegacy && legacy,
652
+ };
653
+ }
585
654
  function mergeOpenclawConfig(config, home, orgName, opts = {}) {
586
655
  const installsModern = opts.installsModern !== false;
656
+ // Whether THIS machine's OpenClaw accepts `hooks` on a plugin entry. Probed
657
+ // from `openclaw config schema` by the caller; absent means "assume not", which
658
+ // is the fail-safe direction. See stripInvalidHooks.
659
+ const hooksSupported = opts.hooksSupported === true;
587
660
  const out = { ...config };
588
661
  const plugins = { ...(isRecord(out.plugins) ? out.plugins : {}) };
589
662
  const entries = { ...(isRecord(plugins.entries) ? plugins.entries : {}) };
@@ -606,12 +679,7 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
606
679
  // An entry is not the only way the legacy plugin is present: `plugins.installs`
607
680
  // records it independently, and a plugin that is installed and allowed loads
608
681
  // with its defaults whether or not anyone wrote an entry for it.
609
- const legacyEntry = OPENCLAW_LEGACY_ENTRY in entries;
610
- const installs = isRecord(plugins.installs) ? plugins.installs : {};
611
- const legacy = legacyEntry || OPENCLAW_LEGACY_ENTRY in installs;
612
- const modern = OPENCLAW_ENTRY in entries;
613
- const useLegacy = !installsModern && legacy && !modern;
614
- const entryKey = useLegacy ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY;
682
+ const { entryKey, useLegacy, legacy } = openclawEntryDecision(config, { installsModern });
615
683
  const existing = isRecord(entries[entryKey]) ? entries[entryKey] : null;
616
684
  // Stand the legacy plugin down when the modern one is taking over. Two live
617
685
  // entries means two hooks, so every tool call would be judged twice — against
@@ -652,7 +720,7 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
652
720
  // little as possible" is the right rule, but it must not mean stepping
653
721
  // politely around damage this tool caused — a later run would leave the
654
722
  // machine broken forever and report nothing wrong.
655
- const repaired = stripInvalidHooks({ ...existing });
723
+ const repaired = stripInvalidHooks({ ...existing }, hooksSupported);
656
724
  entries[entryKey] = {
657
725
  ...repaired,
658
726
  enabled: true,
@@ -682,16 +750,48 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
682
750
  out.plugins = plugins;
683
751
  return out;
684
752
  }
685
- // No entry yet — write the shape the published plugin documents, in full.
686
- const allow = reconcileAllow(plugins.allow, entryKey, !useLegacy && legacy) ?? [entryKey];
687
- plugins.allow = allow;
688
- const load = pruneRetiredLoadPath(plugins.load, home) ?? {};
689
- const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, entryKey);
690
- const paths = Array.isArray(load.paths) ? [...load.paths] : [];
691
- if (!paths.includes(extensionPath))
692
- paths.push(extensionPath);
693
- load.paths = paths;
694
- plugins.load = load;
753
+ // No entry yet — configure the ENTRY, and nothing else.
754
+ //
755
+ // ⚠️ THIS IS THE FRESH-INSTALL PATH, AND IT USED TO CREATE BOTH
756
+ // `plugins.allow` AND `plugins.load.paths`. That is the config a reporter got
757
+ // on a clean OpenClaw 2026.6.6, which then refused to load it:
758
+ //
759
+ // - plugins.load.paths: plugin path not found:
760
+ // /Users/me/.openclaw/extensions/atbash-openclaw
761
+ // - plugins.allow: plugins.allow now gates bundled provider discovery by
762
+ // default; run "openclaw doctor --fix" …
763
+ //
764
+ // `load.paths` was the fatal half, and note WHY it could never have worked: it
765
+ // named `~/.openclaw/extensions/<id>`, which is the 2026.2.x plugin store.
766
+ // 2026.6.x installs plugins as npm dependencies of `~/.openclaw` — into
767
+ // `~/.openclaw/node_modules` — and leaves `extensions/` empty. So we wrote a
768
+ // path OpenClaw was never going to create, and a `load.paths` entry naming a
769
+ // missing directory fails the ENTIRE config, which then takes down the
770
+ // `openclaw plugins install` in this very same run. The command broke its own
771
+ // install step and left behind a dangling entry that `openclaw doctor --fix`
772
+ // then offers to delete — silently un-governing the agent.
773
+ //
774
+ // Neither key is ours to write:
775
+ //
776
+ // - `openclaw plugins install` records where it put the plugin and loads from
777
+ // that record. The location has moved twice in six months across four
778
+ // channels; we cannot know it and must not guess it.
779
+ // - `allow` only matters if the operator KEEPS such a list, and newer builds
780
+ // have repurposed it to gate bundled provider discovery. Creating one where
781
+ // there was none newly restricts every other plugin on the machine — a side
782
+ // effect nobody asked this command for.
783
+ //
784
+ // Existing lists are still MAINTAINED, here and in the branch above, because an
785
+ // `allow` list that omits this entry means the plugin never loads however well
786
+ // configured it is. Adjust what is there; conjure nothing.
787
+ const reconciledAllow = reconcileAllow(plugins.allow, entryKey, !useLegacy && legacy);
788
+ if (reconciledAllow)
789
+ plugins.allow = reconciledAllow;
790
+ // A retired path is still pruned when present: that entry is fatal on every
791
+ // version, so removing it is repair rather than assertion.
792
+ const prunedLoad = pruneRetiredLoadPath(plugins.load, home);
793
+ if (prunedLoad)
794
+ plugins.load = prunedLoad;
695
795
  // `orgName` is the modern plugin's field. This branch can still land on the
696
796
  // legacy key — a `--no-install` run against a box where the old plugin is in
697
797
  // `plugins.installs` but nobody ever wrote it an entry — and its configSchema
@@ -799,7 +899,74 @@ function pruneRetiredLoadPath(current, home) {
799
899
  * existing install is `plugins update <id>`, which re-resolves the recorded spec
800
900
  * and reports "up to date" when there is nothing to do.
801
901
  */
902
+ /**
903
+ * The plugin version recorded by a 2026.7.x npm-project install, if there is one.
904
+ *
905
+ * `openclaw plugins install` creates `~/.openclaw/npm/projects/<mangled>/` whose
906
+ * package.json pins the plugin exactly:
907
+ *
908
+ * dependencies: { "@atbash/atbash-openclaw": "0.1.14-dev.0" }
909
+ *
910
+ * The directory name is derived from the package name (`@atbash/atbash-openclaw`
911
+ * → `atbash-atbash-openclaw-<hash>`), which is how the plugin is told apart from
912
+ * `@opentelemetry/api`, a dependency OpenClaw adds to every one of these projects.
913
+ */
914
+ function openclawNpmProjectVersion(home) {
915
+ const mangle = (name) => name.replace(/^@/, "").replace(/[/]/g, "-");
916
+ const projectsDir = path.join(home, ".openclaw", "npm", "projects");
917
+ let projects = [];
918
+ try {
919
+ projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
920
+ }
921
+ catch {
922
+ return undefined;
923
+ }
924
+ for (const project of projects) {
925
+ const raw = readTextFile(path.join(projectsDir, project, "package.json"));
926
+ if (raw === null)
927
+ continue;
928
+ let deps = {};
929
+ try {
930
+ const parsed = JSON.parse(raw);
931
+ deps = isRecord(parsed.dependencies) ? parsed.dependencies : {};
932
+ }
933
+ catch {
934
+ continue;
935
+ }
936
+ for (const [name, version] of Object.entries(deps)) {
937
+ if (!project.startsWith(mangle(name)))
938
+ continue;
939
+ if (name !== OPENCLAW_PKG && name !== OPENCLAW_ENTRY)
940
+ continue;
941
+ if (typeof version === "string")
942
+ return version;
943
+ }
944
+ }
945
+ return undefined;
946
+ }
802
947
  function openclawInstallState(home, spec) {
948
+ // ── The 2026.7.x layout FIRST: it is what a current install produces, and it
949
+ // is authoritative whenever it is present.
950
+ //
951
+ // ⚠️ THIS FUNCTION USED TO READ ONLY `extensions/` + `plugins.installs`, and on
952
+ // a real 2026.7.1 machine that produced actively harmful advice. That machine
953
+ // carried BOTH a stale `extensions/atbash-openclaw` left from the 2026.2.x era
954
+ // AND the real install under `npm/projects/`. The old code saw the stale
955
+ // directory (installed = true), found no `plugins.installs` record — 2026.7.x
956
+ // keeps that in `~/.openclaw/state/openclaw.sqlite` — and concluded "a
957
+ // different, unidentifiable build is installed". Setup then told the operator
958
+ // to `rm -rf ~/.openclaw/extensions/atbash-openclaw` and reinstall, on a
959
+ // machine whose plugin was installed, correct, and loading. The release
960
+ // immediately before had planned a plain `plugins update` for the same box.
961
+ const projectVersion = openclawNpmProjectVersion(home);
962
+ if (projectVersion) {
963
+ // The npm project records a VERSION, not the `@dev`/plain TAG that was asked
964
+ // for, so compare lineage the way verifyOpenclawPlugin does: a `-dev.` build
965
+ // satisfies `@dev` and only that. Comparing the literal spec string here
966
+ // would report every correct install as a mismatch.
967
+ const wantsDev = /@dev$/.test(spec);
968
+ return { installed: true, sameSpec: wantsDev === /-dev\./.test(projectVersion), version: projectVersion };
969
+ }
803
970
  if (!exists(path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY))) {
804
971
  return { installed: false, sameSpec: false };
805
972
  }
@@ -1254,6 +1421,45 @@ function mergeMcpServer(config, serversKey = "mcpServers") {
1254
1421
  out[serversKey] = servers;
1255
1422
  return out;
1256
1423
  }
1424
+ /** The exec-step id the OpenClaw config write depends on. See Step.requires. */
1425
+ const OPENCLAW_INSTALL_ID = "openclaw-install";
1426
+ /**
1427
+ * The change we want, expressed as a PATCH rather than a whole file.
1428
+ *
1429
+ * `openclaw config patch` merges objects recursively and validates in one write,
1430
+ * so this is intent — "this entry should exist and look like this" — instead of
1431
+ * "here are the complete new bytes of your config". That difference is what makes
1432
+ * it survive the things that kept defeating us from outside: OpenClaw's own
1433
+ * format changes, key renames, comment preservation, file permissions, its
1434
+ * migrations, and the fact that it rewrites `openclaw.json` on its own (a machine
1435
+ * in the field carried three `openclaw.json.clobbered.<timestamp>` files).
1436
+ *
1437
+ * ⚠️ DO NOT call `config patch --dry-run` from buildPlan to preview this. It is
1438
+ * NOT side-effect free: running it triggers OpenClaw's state migrations, which
1439
+ * wrote `~/.openclaw/update-check.json.migrated` and moved config-health state
1440
+ * into SQLite on the machine this was developed on. `atbash setup --dry-run`
1441
+ * promises that nothing is written, and that promise has to hold for OpenClaw's
1442
+ * housekeeping too. The preview stays a merge we compute ourselves.
1443
+ */
1444
+ function openclawPatchPayload(args) {
1445
+ const entries = {
1446
+ [args.entryKey]: {
1447
+ enabled: true,
1448
+ config: {
1449
+ enabled: true,
1450
+ enforceDecision: true,
1451
+ chromiaSecretPath: args.keyPath,
1452
+ ...(args.entryKey !== OPENCLAW_LEGACY_ENTRY && args.orgName?.trim() ? { orgName: args.orgName.trim() } : {}),
1453
+ },
1454
+ },
1455
+ };
1456
+ // Two live entries means two hooks and every tool call judged twice against two
1457
+ // different SDKs, so the handover happens in the SAME patch as the new entry —
1458
+ // never as a second write that could land on its own.
1459
+ if (args.standDownLegacy)
1460
+ entries[OPENCLAW_LEGACY_ENTRY] = { enabled: false };
1461
+ return `${JSON.stringify({ plugins: { entries } }, null, 2)}\n`;
1462
+ }
1257
1463
  /** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
1258
1464
  function hasExecutable(command) {
1259
1465
  const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
@@ -1269,6 +1475,12 @@ function hasExecutable(command) {
1269
1475
  */
1270
1476
  function buildPlan(args) {
1271
1477
  const { home, privkey, pubkey, noInstall, only, orgName, endpoint } = args;
1478
+ // Capability, never a version comparison. A version table is what went stale
1479
+ // and produced a config that broke a fresh machine.
1480
+ const canPatch = Boolean(args.openclaw?.caps.patch);
1481
+ // Probed, never inferred: `hooks` is unknown on 2026.2.1 and valid on 2026.6.6+,
1482
+ // so the same key is fatal on one build and legitimate on the other.
1483
+ const hooksSupported = args.openclaw?.hooksSupported === true;
1272
1484
  const steps = [];
1273
1485
  const notes = [];
1274
1486
  const found = [];
@@ -1340,6 +1552,7 @@ function buildPlan(args) {
1340
1552
  : `Install ${spec} — the production build`,
1341
1553
  command: "openclaw",
1342
1554
  args: ["plugins", "install", spec],
1555
+ id: OPENCLAW_INSTALL_ID,
1343
1556
  });
1344
1557
  }
1345
1558
  else if (installed.sameSpec) {
@@ -1350,6 +1563,10 @@ function buildPlan(args) {
1350
1563
  kind: "exec",
1351
1564
  label: `Update ${spec} to the newest build under its tag (installed: ${installed.version ?? "unknown"})`,
1352
1565
  command: "openclaw",
1566
+ // Deliberately NOT tagged with OPENCLAW_INSTALL_ID: the plugin is
1567
+ // already on disk here, so the config entry refers to something real
1568
+ // whether or not this refresh succeeds. Gating the config on an
1569
+ // update would withhold a correct config over a failed download.
1353
1570
  args: ["plugins", "update", OPENCLAW_ENTRY],
1354
1571
  });
1355
1572
  }
@@ -1382,14 +1599,60 @@ function buildPlan(args) {
1382
1599
  }
1383
1600
  }
1384
1601
  const raw = readTextFile(openclawConfigFile);
1602
+ // The config entry must not be written unless the plugin it names is going
1603
+ // to exist. `requires` is set only for a FRESH install — see Step.requires
1604
+ // for what a dangling entry costs.
1605
+ const requires = steps.some((s) => s.kind === "exec" && s.id === OPENCLAW_INSTALL_ID)
1606
+ ? OPENCLAW_INSTALL_ID
1607
+ : undefined;
1608
+ // Hand the merge to OpenClaw where it can do it: one validated write, in
1609
+ // its own current format, whatever that has become.
1610
+ //
1611
+ // The entry decision comes from the SAME function the hand-merge uses, so
1612
+ // the two paths cannot pick different keys — see openclawEntryDecision.
1613
+ let parsedForDecision = {};
1614
+ if (raw !== null) {
1615
+ try {
1616
+ const p = isJsonc(raw)
1617
+ ? jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false })
1618
+ : JSON.parse(raw);
1619
+ if (isRecord(p))
1620
+ parsedForDecision = p;
1621
+ }
1622
+ catch {
1623
+ /* an unparseable config decides nothing; treat it as empty */
1624
+ }
1625
+ }
1626
+ const decision = openclawEntryDecision(parsedForDecision, { installsModern: !noInstall });
1627
+ const patchPayload = canPatch
1628
+ ? openclawPatchPayload({
1629
+ entryKey: decision.entryKey,
1630
+ orgName,
1631
+ keyPath: `~/${KEY_FILE_REL.join("/")}`,
1632
+ standDownLegacy: decision.standDownLegacy,
1633
+ })
1634
+ : undefined;
1385
1635
  if (raw !== null && isJsonc(raw)) {
1386
- // Rewriting this would delete the owner's comments. Print instead.
1387
- steps.push({
1388
- kind: "manual",
1389
- label: `Enable the plugin in ${openclawConfigFile}`,
1390
- 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`:",
1391
- snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home, orgName, { installsModern: !noInstall }), null, 2),
1392
- });
1636
+ // A config with comments or trailing commas. Rewriting it as strict JSON
1637
+ // deletes the operator's comments, so we never do that.
1638
+ //
1639
+ // But `openclaw config patch` can edit this file WITHOUT losing them —
1640
+ // it is OpenClaw's own JSON5-aware writer. So where that exists, the
1641
+ // operator gets one command instead of a hand-merge, and this stops
1642
+ // being the awkward case.
1643
+ steps.push(patchPayload
1644
+ ? {
1645
+ kind: "manual",
1646
+ label: `Enable the plugin in ${openclawConfigFile}`,
1647
+ 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):",
1648
+ snippet: `openclaw config patch --stdin <<'JSON'\n${patchPayload.trimEnd()}\nJSON`,
1649
+ }
1650
+ : {
1651
+ kind: "manual",
1652
+ label: `Enable the plugin in ${openclawConfigFile}`,
1653
+ 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`:",
1654
+ snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home, orgName, { installsModern: !noInstall, hooksSupported }), null, 2),
1655
+ });
1393
1656
  }
1394
1657
  else {
1395
1658
  let current = {};
@@ -1403,16 +1666,37 @@ function buildPlan(args) {
1403
1666
  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.`);
1404
1667
  }
1405
1668
  }
1406
- const after = serializeLike(raw, mergeOpenclawConfig(current, home, orgName, { installsModern: !noInstall }));
1669
+ const after = serializeLike(raw, mergeOpenclawConfig(current, home, orgName, { installsModern: !noInstall, hooksSupported }));
1407
1670
  if (raw !== after) {
1408
1671
  steps.push({
1409
1672
  kind: "write",
1410
1673
  label: raw === null
1411
1674
  ? "Create ~/.openclaw/openclaw.json with the plugin enabled"
1412
- : "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
1675
+ : patchPayload
1676
+ ? "Enable the plugin in ~/.openclaw/openclaw.json (OpenClaw applies and validates the merge itself)"
1677
+ : "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
1413
1678
  file: openclawConfigFile,
1414
1679
  before: raw,
1415
1680
  after,
1681
+ ...(requires ? { requires } : {}),
1682
+ // The diff above is the merge WE compute, and it is what the operator
1683
+ // approves. Where OpenClaw can apply the change itself we send it the
1684
+ // equivalent patch instead of these bytes, so the result is validated
1685
+ // and written in OpenClaw's own current format. The two agree on
1686
+ // content; OpenClaw may differ on formatting, and its formatting is
1687
+ // the correct one.
1688
+ ...(patchPayload
1689
+ ? {
1690
+ applyVia: {
1691
+ command: "openclaw",
1692
+ args: ["config", "patch", "--stdin"],
1693
+ payload: patchPayload,
1694
+ // Pin the target so the patch cannot land on a different
1695
+ // config than the one this step names — see applyVia.env.
1696
+ env: { OPENCLAW_CONFIG_PATH: openclawConfigFile },
1697
+ },
1698
+ }
1699
+ : {}),
1416
1700
  });
1417
1701
  }
1418
1702
  else {
@@ -1765,17 +2049,57 @@ function backupFile(file) {
1765
2049
  fs.copyFileSync(file, target);
1766
2050
  return target;
1767
2051
  }
1768
- /** Execute the plan. Writes first, then commands, so a failed install still
1769
- * leaves a correct config and key file behind for a manual retry. */
1770
- function applyPlan(plan) {
1771
- const result = { written: [], backups: [], ran: [], failures: [] };
1772
- for (const step of plan.steps) {
1773
- if (step.kind !== "write")
1774
- continue;
2052
+ /**
2053
+ * Execute the plan, in the order that cannot leave a half-wired machine.
2054
+ *
2055
+ * 1. Independent writes the key file above all. Safe on their own, useful
2056
+ * even if everything after fails, and required by the plugin at load.
2057
+ * 2. Commands — the plugin install, so the plugin id EXISTS on disk.
2058
+ * 3. Dependent writes — the plugin config, now that it refers to something
2059
+ * real. Skipped outright if its install failed (see Step.requires).
2060
+ * 4. Verify, and roll back anything we broke (see verify).
2061
+ *
2062
+ * ⚠️ THE ORDER IS THE FIX, and it is the reverse of what this function used to
2063
+ * do. "Writes first, then commands, so a failed install still leaves a correct
2064
+ * config behind" sounds prudent and produces the single worst outcome available:
2065
+ * a config entry for a plugin that is not installed, which OpenClaw reports as a
2066
+ * stale reference and offers to delete via `doctor --fix` — silently un-governing
2067
+ * the agent — and which on 2026.2.x helped make the config unloadable, killing
2068
+ * the install that would have fixed it.
2069
+ */
2070
+ function applyPlan(plan, opts = {}) {
2071
+ const result = { written: [], backups: [], ran: [], failures: [], skipped: [], rolledBack: [] };
2072
+ /** Backup path per file, so a verify failure can put the original back. */
2073
+ const backupOf = new Map();
2074
+ const writeStep = (step) => {
1775
2075
  try {
1776
2076
  const backup = backupFile(step.file);
1777
- if (backup)
2077
+ if (backup) {
1778
2078
  result.backups.push(backup);
2079
+ backupOf.set(step.file, backup);
2080
+ }
2081
+ // Hand the change to the runtime where the plan says to — it merges and
2082
+ // validates in its own format, which is the whole point.
2083
+ if (step.applyVia) {
2084
+ const run = (0, child_process_1.spawnSync)(step.applyVia.command, step.applyVia.args, {
2085
+ input: step.applyVia.payload,
2086
+ encoding: "utf8",
2087
+ timeout: 120000,
2088
+ env: { ...process.env, ...(step.applyVia.env ?? {}) },
2089
+ });
2090
+ const out = `${run.stdout ?? ""}${run.stderr ?? ""}`.trim();
2091
+ if (run.error || run.status !== 0) {
2092
+ // Do NOT silently fall back to writing the file ourselves. The runtime
2093
+ // refusing the change is information — usually that the config is
2094
+ // invalid for a reason we did not cause — and overwriting the file by
2095
+ // hand would bury it and destroy whatever the runtime was protecting.
2096
+ result.failures.push(`${step.applyVia.command} ${step.applyVia.args.join(" ")} did not apply the change to ${step.file}` +
2097
+ (out ? `:\n ${out.split("\n").join("\n ")}` : `: ${run.error?.message ?? `exit ${run.status}`}`));
2098
+ return;
2099
+ }
2100
+ result.written.push(step.file);
2101
+ return;
2102
+ }
1779
2103
  fs.mkdirSync(path.dirname(step.file), { recursive: true, mode: step.mode === KEY_MODE ? DIR_MODE : undefined });
1780
2104
  fs.writeFileSync(step.file, step.after, step.mode ? { mode: step.mode } : {});
1781
2105
  // writeFileSync's mode is ignored for a file that already existed, so
@@ -1787,7 +2111,14 @@ function applyPlan(plan) {
1787
2111
  catch (err) {
1788
2112
  result.failures.push(`${step.file}: ${err instanceof Error ? err.message : String(err)}`);
1789
2113
  }
2114
+ };
2115
+ // ── 1. Writes that depend on nothing.
2116
+ for (const step of plan.steps) {
2117
+ if (step.kind === "write" && !step.requires)
2118
+ writeStep(step);
1790
2119
  }
2120
+ // ── 2. Commands, recording which ones a dependent write may rely on.
2121
+ const succeeded = new Set();
1791
2122
  for (const step of plan.steps) {
1792
2123
  if (step.kind !== "exec")
1793
2124
  continue;
@@ -1807,6 +2138,8 @@ function applyPlan(plan) {
1807
2138
  }
1808
2139
  else if (run.status === 0) {
1809
2140
  result.ran.push(label);
2141
+ if (step.id)
2142
+ succeeded.add(step.id);
1810
2143
  }
1811
2144
  else if (run.signal) {
1812
2145
  result.failures.push(`${label} was killed by ${run.signal}`);
@@ -1815,6 +2148,52 @@ function applyPlan(plan) {
1815
2148
  result.failures.push(`${label} exited with code ${run.status}`);
1816
2149
  }
1817
2150
  }
2151
+ // ── 3. Writes that needed one of those commands to have worked.
2152
+ for (const step of plan.steps) {
2153
+ if (step.kind !== "write" || !step.requires)
2154
+ continue;
2155
+ if (!succeeded.has(step.requires)) {
2156
+ // The whole point: no config for a plugin that is not there. Say what was
2157
+ // NOT done and why, because silence here reads as success.
2158
+ result.skipped.push(`${step.file} was left unchanged: it configures something the step above did not manage to install, ` +
2159
+ `and a config entry for a missing plugin is worse than none — the runtime reports it as stale and ` +
2160
+ `offers to delete it. Fix the failure above and re-run; nothing needs undoing first.`);
2161
+ continue;
2162
+ }
2163
+ writeStep(step);
2164
+ }
2165
+ // ── 4. Verify, and undo what we broke.
2166
+ //
2167
+ // Only files WE wrote in this run, and only when the runtime can actually
2168
+ // answer. A `valid: false` on a file we did not touch is someone else's
2169
+ // problem to fix and not ours to revert.
2170
+ if (opts.verify) {
2171
+ for (const file of [...new Set(result.written)]) {
2172
+ const verdict = opts.verify(file);
2173
+ if (!verdict || verdict.valid)
2174
+ continue;
2175
+ const backup = backupOf.get(file);
2176
+ if (!backup) {
2177
+ // Nothing to restore to — the file did not exist before this run. Deleting
2178
+ // it would be the honest inverse, but a config we created and a config the
2179
+ // runtime created are indistinguishable by now, so say so instead of
2180
+ // guessing. Leaving a file we cannot vouch for, unmentioned, is the one
2181
+ // outcome that is not allowed.
2182
+ result.failures.push(`${file} did not validate after being written, and there was no previous version to restore:\n ${verdict.problems}`);
2183
+ continue;
2184
+ }
2185
+ try {
2186
+ fs.copyFileSync(backup, file);
2187
+ result.rolledBack.push(file);
2188
+ result.written = result.written.filter((f) => f !== file);
2189
+ result.failures.push(`${file} did not validate after being written, so the original was restored from ${backup}:\n ${verdict.problems}`);
2190
+ }
2191
+ catch (err) {
2192
+ result.failures.push(`${file} did not validate AND could not be restored from ${backup} (${err instanceof Error ? err.message : String(err)}). ` +
2193
+ `The backup is still on disk — put it back by hand before starting the runtime.`);
2194
+ }
2195
+ }
2196
+ }
1818
2197
  return result;
1819
2198
  }
1820
2199
  // ── Registration check ──────────────────────────────────────────────────────
@@ -1865,6 +2244,7 @@ function registerSetupCommand(program) {
1865
2244
  .option("--skip-verify", "Do not check the agent's registration (no network calls at all)")
1866
2245
  .option("--allow-unrecognized-host", "Permit a --host that is not a known Atbash deployment")
1867
2246
  .option("--home <dir>", "Home directory to configure (for testing)")
2247
+ .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)")
1868
2248
  .action(async (opts) => {
1869
2249
  const home = opts.home || process.env.HOME || os.homedir();
1870
2250
  const dryRun = !!opts.dryRun;
@@ -1897,6 +2277,59 @@ function registerSetupCommand(program) {
1897
2277
  process.exit(1);
1898
2278
  }
1899
2279
  console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
2280
+ // ── Replacing this machine's AGENT IDENTITY is its own decision.
2281
+ //
2282
+ // Everything else this command does is additive: a config entry, a plugin,
2283
+ // a key file where there was none. This one is a substitution, and it is
2284
+ // the only change here that alters WHO the machine is. After it, every
2285
+ // integration reading the default key path signs as a different on-chain
2286
+ // agent, so tool calls are attributed to someone else — including tool
2287
+ // calls made by work that was already running.
2288
+ //
2289
+ // It used to be folded into the single "Apply N changes to this machine?"
2290
+ // prompt, listed as one write among several. An operator concentrating on
2291
+ // the plugin config could accept it without registering that their agent
2292
+ // identity changed, and `--yes` — which reasonably means "don't ask me
2293
+ // about the file writes" — skipped it entirely. So it gets asked
2294
+ // separately, and a blanket `--yes` does not answer it: saying yes to
2295
+ // writing files is not the same as saying yes to becoming a different
2296
+ // agent. `--replace-key` is how a script says it deliberately.
2297
+ //
2298
+ // Declining ABORTS rather than continuing, because continuing cannot honour
2299
+ // what was asked. The operator ran `--key <NEW>` meaning "wire this machine
2300
+ // to NEW"; with the key left alone the machine keeps signing as OLD, so
2301
+ // proceeding would configure a runtime that reports one agent while the
2302
+ // dashboard shows another — the exact silent mismatch the rest of this work
2303
+ // exists to remove.
2304
+ const canonicalKeyFile = path.join(home, ...KEY_FILE_REL);
2305
+ const existingRaw = readTextFile(canonicalKeyFile);
2306
+ const existingMaterial = existingRaw === null ? null : parseKeyMaterial(existingRaw);
2307
+ const existingPubkey = existingMaterial ? (0, sdk_1.derivePublicKey)(existingMaterial.privkey) : undefined;
2308
+ if (existingPubkey && existingPubkey.toLowerCase() !== pubkey.toLowerCase()) {
2309
+ const archive = path.join(home, ".config", "atbash", "keys", `${existingPubkey}.key`);
2310
+ console.log(chalk_1.default.yellow("\n This machine is about to change which agent it signs as.") +
2311
+ chalk_1.default.dim(`\n now: ${existingPubkey}`) +
2312
+ chalk_1.default.dim(`\n new: ${pubkey}`) +
2313
+ chalk_1.default.dim(`\n Every integration on this machine reading ${canonicalKeyFile}`) +
2314
+ chalk_1.default.dim("\n will switch to the new agent. The current key is archived first, to") +
2315
+ chalk_1.default.dim(`\n ${archive}, so the old agent stays recoverable.\n`));
2316
+ const approved = opts.replaceKey
2317
+ ? true
2318
+ : dryRun
2319
+ ? true // a preview decides nothing; the real run will still ask
2320
+ : process.stdin.isTTY
2321
+ ? await confirm(" Replace the agent this machine signs as? [y/N] ")
2322
+ : false;
2323
+ if (!approved) {
2324
+ console.log(chalk_1.default.dim("\n Nothing was changed.") +
2325
+ chalk_1.default.dim(`\n This machine still signs as ${existingPubkey}.`) +
2326
+ (process.stdin.isTTY
2327
+ ? chalk_1.default.dim("\n Re-run without --key to configure it for the agent already here.\n")
2328
+ : chalk_1.default.dim("\n Re-run with --replace-key to switch agents non-interactively,") +
2329
+ chalk_1.default.dim("\n or without --key to configure it for the agent already here.\n")));
2330
+ return;
2331
+ }
2332
+ }
1900
2333
  // ── Registration check. Only the public key crosses the network.
1901
2334
  if (!opts.skipVerify) {
1902
2335
  const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
@@ -1962,6 +2395,58 @@ function registerSetupCommand(program) {
1962
2395
  console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
1963
2396
  }
1964
2397
  }
2398
+ // ── What OpenClaw is on this machine, and can we ask it to do the work?
2399
+ //
2400
+ // This runs BEFORE the plan, and before anything is written, because both
2401
+ // answers change what we do:
2402
+ //
2403
+ // - No OpenClaw at all → say so and stop touching OpenClaw. The likeliest
2404
+ // cause is the operator running this on the wrong computer, and the
2405
+ // previous behavior (write config for a runtime that is not here) left
2406
+ // a machine carrying governance for an agent it does not run.
2407
+ // - An OLD OpenClaw → still supported, still wired. It just cannot
2408
+ // validate the change for us, so we say that once, offer the upgrade
2409
+ // command FOR ITS OWN CHANNEL, and continue. Refusing here would break
2410
+ // 2026.2.x boxes that demonstrably work.
2411
+ //
2412
+ // Nothing below compares version numbers to decide anything: capability is
2413
+ // probed from the binary. A version table is exactly what went stale and
2414
+ // produced the config that broke the reporter's machine.
2415
+ const wantsOpenclaw = wantedRuntime("openclaw", opts.runtime ?? []);
2416
+ const openclaw = wantsOpenclaw ? (0, openclaw_runtime_1.detectOpenclaw)() : undefined;
2417
+ if (openclaw) {
2418
+ const status = (0, openclaw_runtime_1.supportStatus)(openclaw);
2419
+ console.log(status.supported ? chalk_1.default.dim(`\n ${status.message}`) : chalk_1.default.yellow(`\n ${status.message}`));
2420
+ if (status.upgrade) {
2421
+ console.log(chalk_1.default.dim(" To upgrade it: ") + chalk_1.default.cyan(status.upgrade));
2422
+ }
2423
+ // ── PREFLIGHT. The step this command never had.
2424
+ //
2425
+ // Setup merged into ~/.openclaw/openclaw.json without ever checking
2426
+ // whether that file currently loads. On a machine whose config was
2427
+ // ALREADY broken it added its entry to an unloadable file and then its
2428
+ // own `openclaw plugins install` step died on the pre-existing breakage —
2429
+ // reported to the operator as an Atbash failure, which it was not.
2430
+ //
2431
+ // A `valid: false` here is NOT a reason to refuse: the operator may well
2432
+ // be running setup precisely because their config is broken, and some of
2433
+ // what we do (pruning a retired `load.paths`) is the repair. So report
2434
+ // the problems in OpenClaw's own words and carry on — the point is that
2435
+ // nobody is left debugging our step for someone else's breakage.
2436
+ if (openclaw.caps.validate) {
2437
+ const configPath = path.join(home, ...OPENCLAW_CONFIG_REL);
2438
+ if (fs.existsSync(configPath)) {
2439
+ const verdict = (0, openclaw_runtime_1.validateConfig)(openclaw, configPath, "openclaw", home);
2440
+ if (verdict && !verdict.valid) {
2441
+ console.log(chalk_1.default.yellow("\n ⚠ OpenClaw reports your CURRENT config is invalid, before Atbash changes anything:") +
2442
+ chalk_1.default.dim(`\n${verdict.problems.split("\n").map((l) => ` ${l}`).join("\n")}`) +
2443
+ chalk_1.default.dim("\n Atbash will still wire its entry, and will back the file up first. If the") +
2444
+ chalk_1.default.dim("\n problem above is not one Atbash introduced, it needs fixing separately —") +
2445
+ chalk_1.default.dim("\n OpenClaw will not load ANY config while it stands, so the plugin cannot run.\n"));
2446
+ }
2447
+ }
2448
+ }
2449
+ }
1965
2450
  // ── Plan, show, then (maybe) apply.
1966
2451
  const plan = buildPlan({
1967
2452
  home,
@@ -1970,6 +2455,12 @@ function registerSetupCommand(program) {
1970
2455
  noInstall: opts.install === false,
1971
2456
  only: opts.runtime ?? [],
1972
2457
  orgName: opts.orgName,
2458
+ // Lets the plan hand the config merge to OpenClaw where it can do it, and
2459
+ // tells it whether THIS build accepts `hooks` on a plugin entry — asked of
2460
+ // `openclaw config schema`, since the same key is fatal on 2026.2.x and
2461
+ // legitimate on 2026.6.6+. Unknown resolves to "not supported", which is
2462
+ // the fail-safe direction.
2463
+ ...(openclaw ? { openclaw: { ...openclaw, hooksSupported: (0, openclaw_runtime_1.pluginEntryAcceptsHooks)(openclaw) === true } } : {}),
1973
2464
  // The same host the registration check used, so the plugin build and the
1974
2465
  // chain the agent lives on cannot disagree.
1975
2466
  endpoint: opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST,
@@ -2003,7 +2494,16 @@ function registerSetupCommand(program) {
2003
2494
  return;
2004
2495
  }
2005
2496
  }
2006
- const result = applyPlan(plan);
2497
+ // Verification is OpenClaw's own answer, not ours — and only for the file
2498
+ // it is the authority on. Setup used to report success based purely on
2499
+ // having written what it intended to write, which is how a machine with
2500
+ // nothing installed was told it was "fully wired".
2501
+ const openclawConfigPath = path.join(home, ...OPENCLAW_CONFIG_REL);
2502
+ const result = applyPlan(plan, {
2503
+ verify: openclaw?.caps.validate
2504
+ ? (file) => (path.resolve(file) === path.resolve(openclawConfigPath) ? (0, openclaw_runtime_1.validateConfig)(openclaw, file, "openclaw", home) : undefined)
2505
+ : undefined,
2506
+ });
2007
2507
  console.log();
2008
2508
  for (const file of result.written)
2009
2509
  console.log(chalk_1.default.green(` ✓ wrote ${file}`));
@@ -2011,9 +2511,16 @@ function registerSetupCommand(program) {
2011
2511
  console.log(chalk_1.default.dim(` backup: ${file}`));
2012
2512
  for (const cmd of result.ran)
2013
2513
  console.log(chalk_1.default.green(` ✓ ran ${cmd}`));
2514
+ for (const file of result.rolledBack)
2515
+ console.log(chalk_1.default.yellow(` ↩ restored ${file} — the change did not validate`));
2516
+ // A write we deliberately did not make is not a silent non-event: it is the
2517
+ // difference between "no config" and "config for a plugin that isn't there",
2518
+ // and the operator has to know which one they have.
2519
+ for (const skip of result.skipped)
2520
+ console.log(chalk_1.default.yellow(` ⊘ ${skip}`));
2014
2521
  for (const failure of result.failures)
2015
2522
  console.log(chalk_1.default.red(` ✗ ${failure}`));
2016
- if (result.failures.length) {
2523
+ if (result.failures.length || result.skipped.length) {
2017
2524
  console.log(chalk_1.default.yellow("\n Finished with failures — this machine is NOT fully wired.") +
2018
2525
  chalk_1.default.dim("\n Everything that did succeed is listed above; the steps that failed can be re-run.\n"));
2019
2526
  process.exitCode = 1;
@@ -2049,6 +2556,40 @@ function registerSetupCommand(program) {
2049
2556
  }
2050
2557
  // "unknown": nothing readable to judge by, so claim nothing.
2051
2558
  }
2559
+ // ── OpenClaw gets the last word about OpenClaw.
2560
+ //
2561
+ // Everything above reports what setup DID. This reports what OpenClaw now
2562
+ // SEES, which is the only thing that answers "is this agent governed?".
2563
+ // The reporter's machine had a config entry, no plugin, and a green
2564
+ // summary — a false green on a control boundary is the most expensive
2565
+ // failure this command can produce.
2566
+ if (openclaw?.present && plan.found.includes("OpenClaw") && wantedRuntime("openclaw", opts.runtime ?? [])) {
2567
+ const verdict = (0, openclaw_runtime_1.verifyOpenclawPlugin)(openclaw, {
2568
+ entryIds: [OPENCLAW_ENTRY, OPENCLAW_LEGACY_ENTRY],
2569
+ configPath: openclawConfigPath,
2570
+ // Pin HOME too: plugin discovery walks $HOME/.openclaw, so without it a
2571
+ // --home run reports the operator's real machine instead of this one.
2572
+ home,
2573
+ expectedSpec: openclawPackageForHost(opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST),
2574
+ });
2575
+ if (verdict.state === "loaded") {
2576
+ console.log(chalk_1.default.green(`\n OpenClaw loads the plugin — entry \`${verdict.entry}\`${verdict.version ? `, build ${verdict.version}` : ""}.`) +
2577
+ chalk_1.default.dim("\n Its hook registers when the gateway starts, so restart the gateway to enforce.\n"));
2578
+ }
2579
+ else if (verdict.state === "unknown") {
2580
+ // Say plainly that this is unverified rather than papering over it.
2581
+ console.log(chalk_1.default.yellow("\n Could not confirm with OpenClaw whether the plugin loaded.") +
2582
+ chalk_1.default.dim(`\n ${verdict.detail ?? ""}\n Check it yourself with: `) + chalk_1.default.cyan("openclaw plugins list") + "\n");
2583
+ }
2584
+ else {
2585
+ console.log(chalk_1.default.yellow(`\n This agent is NOT governed yet — OpenClaw does not report the plugin as loaded.`) +
2586
+ chalk_1.default.dim(`\n ${verdict.detail ?? ""}`) +
2587
+ 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") +
2588
+ chalk_1.default.dim("\n ⚠ `openclaw doctor --fix` will DELETE an Atbash entry it considers stale, so read its plan before accepting.\n"));
2589
+ process.exitCode = 1;
2590
+ return;
2591
+ }
2592
+ }
2052
2593
  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"));
2053
2594
  console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
2054
2595
  });