@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.
- package/README.md +29 -1
- package/dist/commands/connect.d.ts +35 -1
- package/dist/commands/connect.js +175 -21
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/setup.d.ts +153 -21
- package/dist/commands/setup.js +595 -54
- package/dist/commands/setup.js.map +1 -1
- package/dist/shared/openclaw-runtime.d.ts +221 -0
- package/dist/shared/openclaw-runtime.js +453 -0
- package/dist/shared/openclaw-runtime.js.map +1 -0
- package/package.json +2 -1
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
129
|
-
*
|
|
130
|
-
* The entry key really is `openclaw`
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
154
|
-
*
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* `
|
|
163
|
-
*
|
|
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
|
-
|
|
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
|
|
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 —
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
:
|
|
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
|
-
/**
|
|
1769
|
-
*
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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
|
-
|
|
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
|
});
|