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