@atbash/cli 0.5.15-dev.13 → 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 +151 -21
- package/dist/commands/setup.js +566 -50
- 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,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,19 +153,38 @@ 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
189
|
function stripInvalidHooks(entry) {
|
|
166
190
|
if ("hooks" in entry)
|
|
@@ -582,6 +606,37 @@ function isJsonc(text) {
|
|
|
582
606
|
* `<your-username>` placeholder that people paste verbatim, producing a path that
|
|
583
607
|
* does not exist and a plugin that never loads.
|
|
584
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
|
+
}
|
|
585
640
|
function mergeOpenclawConfig(config, home, orgName, opts = {}) {
|
|
586
641
|
const installsModern = opts.installsModern !== false;
|
|
587
642
|
const out = { ...config };
|
|
@@ -606,12 +661,7 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
|
|
|
606
661
|
// An entry is not the only way the legacy plugin is present: `plugins.installs`
|
|
607
662
|
// records it independently, and a plugin that is installed and allowed loads
|
|
608
663
|
// 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;
|
|
664
|
+
const { entryKey, useLegacy, legacy } = openclawEntryDecision(config, { installsModern });
|
|
615
665
|
const existing = isRecord(entries[entryKey]) ? entries[entryKey] : null;
|
|
616
666
|
// Stand the legacy plugin down when the modern one is taking over. Two live
|
|
617
667
|
// entries means two hooks, so every tool call would be judged twice — against
|
|
@@ -682,16 +732,48 @@ function mergeOpenclawConfig(config, home, orgName, opts = {}) {
|
|
|
682
732
|
out.plugins = plugins;
|
|
683
733
|
return out;
|
|
684
734
|
}
|
|
685
|
-
// No entry yet —
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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;
|
|
695
777
|
// `orgName` is the modern plugin's field. This branch can still land on the
|
|
696
778
|
// legacy key — a `--no-install` run against a box where the old plugin is in
|
|
697
779
|
// `plugins.installs` but nobody ever wrote it an entry — and its configSchema
|
|
@@ -799,7 +881,74 @@ function pruneRetiredLoadPath(current, home) {
|
|
|
799
881
|
* existing install is `plugins update <id>`, which re-resolves the recorded spec
|
|
800
882
|
* and reports "up to date" when there is nothing to do.
|
|
801
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
|
+
}
|
|
802
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
|
+
}
|
|
803
952
|
if (!exists(path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY))) {
|
|
804
953
|
return { installed: false, sameSpec: false };
|
|
805
954
|
}
|
|
@@ -1254,6 +1403,45 @@ function mergeMcpServer(config, serversKey = "mcpServers") {
|
|
|
1254
1403
|
out[serversKey] = servers;
|
|
1255
1404
|
return out;
|
|
1256
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
|
+
}
|
|
1257
1445
|
/** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
|
|
1258
1446
|
function hasExecutable(command) {
|
|
1259
1447
|
const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
|
|
@@ -1269,6 +1457,9 @@ function hasExecutable(command) {
|
|
|
1269
1457
|
*/
|
|
1270
1458
|
function buildPlan(args) {
|
|
1271
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);
|
|
1272
1463
|
const steps = [];
|
|
1273
1464
|
const notes = [];
|
|
1274
1465
|
const found = [];
|
|
@@ -1340,6 +1531,7 @@ function buildPlan(args) {
|
|
|
1340
1531
|
: `Install ${spec} — the production build`,
|
|
1341
1532
|
command: "openclaw",
|
|
1342
1533
|
args: ["plugins", "install", spec],
|
|
1534
|
+
id: OPENCLAW_INSTALL_ID,
|
|
1343
1535
|
});
|
|
1344
1536
|
}
|
|
1345
1537
|
else if (installed.sameSpec) {
|
|
@@ -1350,6 +1542,10 @@ function buildPlan(args) {
|
|
|
1350
1542
|
kind: "exec",
|
|
1351
1543
|
label: `Update ${spec} to the newest build under its tag (installed: ${installed.version ?? "unknown"})`,
|
|
1352
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.
|
|
1353
1549
|
args: ["plugins", "update", OPENCLAW_ENTRY],
|
|
1354
1550
|
});
|
|
1355
1551
|
}
|
|
@@ -1382,14 +1578,60 @@ function buildPlan(args) {
|
|
|
1382
1578
|
}
|
|
1383
1579
|
}
|
|
1384
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;
|
|
1385
1614
|
if (raw !== null && isJsonc(raw)) {
|
|
1386
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
+
});
|
|
1393
1635
|
}
|
|
1394
1636
|
else {
|
|
1395
1637
|
let current = {};
|
|
@@ -1409,10 +1651,31 @@ function buildPlan(args) {
|
|
|
1409
1651
|
kind: "write",
|
|
1410
1652
|
label: raw === null
|
|
1411
1653
|
? "Create ~/.openclaw/openclaw.json with the plugin enabled"
|
|
1412
|
-
:
|
|
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)",
|
|
1413
1657
|
file: openclawConfigFile,
|
|
1414
1658
|
before: raw,
|
|
1415
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
|
+
: {}),
|
|
1416
1679
|
});
|
|
1417
1680
|
}
|
|
1418
1681
|
else {
|
|
@@ -1765,17 +2028,57 @@ function backupFile(file) {
|
|
|
1765
2028
|
fs.copyFileSync(file, target);
|
|
1766
2029
|
return target;
|
|
1767
2030
|
}
|
|
1768
|
-
/**
|
|
1769
|
-
*
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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) => {
|
|
1775
2054
|
try {
|
|
1776
2055
|
const backup = backupFile(step.file);
|
|
1777
|
-
if (backup)
|
|
2056
|
+
if (backup) {
|
|
1778
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
|
+
}
|
|
1779
2082
|
fs.mkdirSync(path.dirname(step.file), { recursive: true, mode: step.mode === KEY_MODE ? DIR_MODE : undefined });
|
|
1780
2083
|
fs.writeFileSync(step.file, step.after, step.mode ? { mode: step.mode } : {});
|
|
1781
2084
|
// writeFileSync's mode is ignored for a file that already existed, so
|
|
@@ -1787,7 +2090,14 @@ function applyPlan(plan) {
|
|
|
1787
2090
|
catch (err) {
|
|
1788
2091
|
result.failures.push(`${step.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1789
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);
|
|
1790
2098
|
}
|
|
2099
|
+
// ── 2. Commands, recording which ones a dependent write may rely on.
|
|
2100
|
+
const succeeded = new Set();
|
|
1791
2101
|
for (const step of plan.steps) {
|
|
1792
2102
|
if (step.kind !== "exec")
|
|
1793
2103
|
continue;
|
|
@@ -1807,6 +2117,8 @@ function applyPlan(plan) {
|
|
|
1807
2117
|
}
|
|
1808
2118
|
else if (run.status === 0) {
|
|
1809
2119
|
result.ran.push(label);
|
|
2120
|
+
if (step.id)
|
|
2121
|
+
succeeded.add(step.id);
|
|
1810
2122
|
}
|
|
1811
2123
|
else if (run.signal) {
|
|
1812
2124
|
result.failures.push(`${label} was killed by ${run.signal}`);
|
|
@@ -1815,6 +2127,52 @@ function applyPlan(plan) {
|
|
|
1815
2127
|
result.failures.push(`${label} exited with code ${run.status}`);
|
|
1816
2128
|
}
|
|
1817
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
|
+
}
|
|
1818
2176
|
return result;
|
|
1819
2177
|
}
|
|
1820
2178
|
// ── Registration check ──────────────────────────────────────────────────────
|
|
@@ -1865,6 +2223,7 @@ function registerSetupCommand(program) {
|
|
|
1865
2223
|
.option("--skip-verify", "Do not check the agent's registration (no network calls at all)")
|
|
1866
2224
|
.option("--allow-unrecognized-host", "Permit a --host that is not a known Atbash deployment")
|
|
1867
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)")
|
|
1868
2227
|
.action(async (opts) => {
|
|
1869
2228
|
const home = opts.home || process.env.HOME || os.homedir();
|
|
1870
2229
|
const dryRun = !!opts.dryRun;
|
|
@@ -1897,6 +2256,59 @@ function registerSetupCommand(program) {
|
|
|
1897
2256
|
process.exit(1);
|
|
1898
2257
|
}
|
|
1899
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
|
+
}
|
|
1900
2312
|
// ── Registration check. Only the public key crosses the network.
|
|
1901
2313
|
if (!opts.skipVerify) {
|
|
1902
2314
|
const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
@@ -1962,6 +2374,58 @@ function registerSetupCommand(program) {
|
|
|
1962
2374
|
console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
|
|
1963
2375
|
}
|
|
1964
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
|
+
}
|
|
1965
2429
|
// ── Plan, show, then (maybe) apply.
|
|
1966
2430
|
const plan = buildPlan({
|
|
1967
2431
|
home,
|
|
@@ -1970,6 +2434,8 @@ function registerSetupCommand(program) {
|
|
|
1970
2434
|
noInstall: opts.install === false,
|
|
1971
2435
|
only: opts.runtime ?? [],
|
|
1972
2436
|
orgName: opts.orgName,
|
|
2437
|
+
// Lets the plan hand the config merge to OpenClaw where it can do it.
|
|
2438
|
+
...(openclaw ? { openclaw } : {}),
|
|
1973
2439
|
// The same host the registration check used, so the plugin build and the
|
|
1974
2440
|
// chain the agent lives on cannot disagree.
|
|
1975
2441
|
endpoint: opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST,
|
|
@@ -2003,7 +2469,16 @@ function registerSetupCommand(program) {
|
|
|
2003
2469
|
return;
|
|
2004
2470
|
}
|
|
2005
2471
|
}
|
|
2006
|
-
|
|
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
|
+
});
|
|
2007
2482
|
console.log();
|
|
2008
2483
|
for (const file of result.written)
|
|
2009
2484
|
console.log(chalk_1.default.green(` ✓ wrote ${file}`));
|
|
@@ -2011,9 +2486,16 @@ function registerSetupCommand(program) {
|
|
|
2011
2486
|
console.log(chalk_1.default.dim(` backup: ${file}`));
|
|
2012
2487
|
for (const cmd of result.ran)
|
|
2013
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}`));
|
|
2014
2496
|
for (const failure of result.failures)
|
|
2015
2497
|
console.log(chalk_1.default.red(` ✗ ${failure}`));
|
|
2016
|
-
if (result.failures.length) {
|
|
2498
|
+
if (result.failures.length || result.skipped.length) {
|
|
2017
2499
|
console.log(chalk_1.default.yellow("\n Finished with failures — this machine is NOT fully wired.") +
|
|
2018
2500
|
chalk_1.default.dim("\n Everything that did succeed is listed above; the steps that failed can be re-run.\n"));
|
|
2019
2501
|
process.exitCode = 1;
|
|
@@ -2049,6 +2531,40 @@ function registerSetupCommand(program) {
|
|
|
2049
2531
|
}
|
|
2050
2532
|
// "unknown": nothing readable to judge by, so claim nothing.
|
|
2051
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
|
+
}
|
|
2052
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"));
|
|
2053
2569
|
console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
|
|
2054
2570
|
});
|