@atbash/cli 0.5.15-dev.8 → 0.5.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 +0 -77
- package/dist/bin/atbash.js +1 -7
- package/dist/bin/atbash.js.map +1 -1
- package/dist/commands/connect.d.ts +0 -64
- package/dist/commands/connect.js +56 -107
- package/dist/commands/connect.js.map +1 -1
- package/package.json +3 -3
- package/dist/commands/mcp-cmd.d.ts +0 -13
- package/dist/commands/mcp-cmd.js +0 -216
- package/dist/commands/mcp-cmd.js.map +0 -1
- package/dist/commands/setup.d.ts +0 -359
- package/dist/commands/setup.js +0 -1763
- package/dist/commands/setup.js.map +0 -1
- package/dist/shared/atbash-targets.d.ts +0 -49
- package/dist/shared/atbash-targets.js +0 -63
- package/dist/shared/atbash-targets.js.map +0 -1
package/dist/commands/setup.js
DELETED
|
@@ -1,1763 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.parseKeyMaterial = parseKeyMaterial;
|
|
40
|
-
exports.normalizePrivkey = normalizePrivkey;
|
|
41
|
-
exports.keyCandidatesInDir = keyCandidatesInDir;
|
|
42
|
-
exports.resolveKeySource = resolveKeySource;
|
|
43
|
-
exports.keyFileContents = keyFileContents;
|
|
44
|
-
exports.isJsonc = isJsonc;
|
|
45
|
-
exports.mergeOpenclawConfig = mergeOpenclawConfig;
|
|
46
|
-
exports.detectIndent = detectIndent;
|
|
47
|
-
exports.serializeLike = serializeLike;
|
|
48
|
-
exports.detectMcpClients = detectMcpClients;
|
|
49
|
-
exports.findHermesPython = findHermesPython;
|
|
50
|
-
exports.wantedRuntime = wantedRuntime;
|
|
51
|
-
exports.mergeHermesEnabledPlugins = mergeHermesEnabledPlugins;
|
|
52
|
-
exports.hermesGatewayRunning = hermesGatewayRunning;
|
|
53
|
-
exports.hermesPluginState = hermesPluginState;
|
|
54
|
-
exports.pythonInstallStrategy = pythonInstallStrategy;
|
|
55
|
-
exports.mergeHermesEnv = mergeHermesEnv;
|
|
56
|
-
exports.hadInlineKey = hadInlineKey;
|
|
57
|
-
exports.mergeMcpServer = mergeMcpServer;
|
|
58
|
-
exports.buildPlan = buildPlan;
|
|
59
|
-
exports.lineDiff = lineDiff;
|
|
60
|
-
exports.renderPlan = renderPlan;
|
|
61
|
-
exports.backupFile = backupFile;
|
|
62
|
-
exports.applyPlan = applyPlan;
|
|
63
|
-
exports.registerSetupCommand = registerSetupCommand;
|
|
64
|
-
const fs = __importStar(require("fs"));
|
|
65
|
-
const os = __importStar(require("os"));
|
|
66
|
-
const path = __importStar(require("path"));
|
|
67
|
-
const child_process_1 = require("child_process");
|
|
68
|
-
const chalk_1 = __importDefault(require("chalk"));
|
|
69
|
-
const jsonc = __importStar(require("jsonc-parser"));
|
|
70
|
-
const yaml_1 = require("yaml");
|
|
71
|
-
const sdk_1 = require("@atbash/sdk");
|
|
72
|
-
const atbash_targets_1 = require("../shared/atbash-targets");
|
|
73
|
-
/**
|
|
74
|
-
* `atbash setup` — the write half of onboarding.
|
|
75
|
-
*
|
|
76
|
-
* Onboarding registers an agent on chain and issues its certificate. None of
|
|
77
|
-
* that governs anything until the runtime on the agent's machine is pointed at
|
|
78
|
-
* Atbash, and that step used to be four manual operations: move a downloaded
|
|
79
|
-
* key file, install a plugin, hand-merge a JSON block into an existing config
|
|
80
|
-
* "without erasing the existing configurations", restart the gateway. The merge
|
|
81
|
-
* is the one people get wrong, and getting it wrong means an agent that reports
|
|
82
|
-
* registered while enforcing nothing.
|
|
83
|
-
*
|
|
84
|
-
* This command does those steps.
|
|
85
|
-
*
|
|
86
|
-
* ── RELATIONSHIP TO `atbash connect` ─────────────────────────────────────────
|
|
87
|
-
* `connect` is READ-ONLY and must stay that way: the onboarding page promises
|
|
88
|
-
* "installs nothing, changes nothing", and a cautious agent asked to run it is
|
|
89
|
-
* right to verify that. `setup` is the separate, explicitly-named command that
|
|
90
|
-
* writes. Never move write behavior into `connect`, and never describe `setup`
|
|
91
|
-
* with `connect`'s copy.
|
|
92
|
-
*
|
|
93
|
-
* ── WHAT IT WILL NOT DO ──────────────────────────────────────────────────────
|
|
94
|
-
* 1. It never sends the private key anywhere. The key is read locally, written
|
|
95
|
-
* locally, and used locally to derive a public key. The only network call is
|
|
96
|
-
* an optional registration check that transmits the PUBLIC key.
|
|
97
|
-
* 2. It never writes the private key into an MCP client config. The documented
|
|
98
|
-
* `@atbash/mcp` wiring passes the key as an `ATBASH_AGENT_PRIVKEY` env value
|
|
99
|
-
* inside e.g. `claude_desktop_config.json` — a file people screenshot, sync
|
|
100
|
-
* and share, and that package has no key-file fallback (verified against the
|
|
101
|
-
* published 0.1.3). So the entry setup writes points at `atbash mcp`, which
|
|
102
|
-
* reads the 0600 key file and passes the key to the server through the child
|
|
103
|
-
* environment. The config file itself gets NO credential — and an entry that
|
|
104
|
-
* was hand-wired with one has it removed.
|
|
105
|
-
* 3. It never edits application source. Code-level integrations (LangChain,
|
|
106
|
-
* LangGraph, AutoGen, Eliza, the SDK boundary) are the owner's to write.
|
|
107
|
-
* 4. It never rewrites a config file that uses comments or trailing commas.
|
|
108
|
-
* Reserializing JSONC as JSON silently deletes the owner's comments, so
|
|
109
|
-
* those files fall back to a printed snippet.
|
|
110
|
-
*
|
|
111
|
-
* ── NOTHING HERE IS INVENTED ─────────────────────────────────────────────────
|
|
112
|
-
* The plugin package name, the entry key it registers as, the config field names
|
|
113
|
-
* and the key-file format are all taken from the published integration docs. A
|
|
114
|
-
* plausible-looking config for a plugin that does not exist is worse than no
|
|
115
|
-
* config: it sends someone editing files for a package they cannot install.
|
|
116
|
-
*/
|
|
117
|
-
// ── The canonical resting place for the agent key ───────────────────────────
|
|
118
|
-
// Every Atbash integration reads this path, and `@atbash/sdk`'s resolveKeyPath
|
|
119
|
-
// defaults to it. Whichever way the key arrives — pasted inline, read out of the
|
|
120
|
-
// browser download, already present — it ends up here once, and every runtime
|
|
121
|
-
// config we write REFERENCES this path rather than embedding the secret again.
|
|
122
|
-
const KEY_FILE_REL = [".config", "atbash", "guard-client-key"];
|
|
123
|
-
const OPENCLAW_CONFIG_REL = [".openclaw", "openclaw.json"];
|
|
124
|
-
const OPENCLAW_EXTENSIONS_REL = [".openclaw", "extensions"];
|
|
125
|
-
const HERMES_AGENT_REL = [".hermes", "hermes-agent"];
|
|
126
|
-
/**
|
|
127
|
-
* The OpenClaw plugin, and the entry key it registers itself as.
|
|
128
|
-
*
|
|
129
|
-
* The entry key really is `openclaw` — that is what `@atbash/atbash-openclaw`
|
|
130
|
-
* registers as, not a copy-paste slip. Installs of the earlier
|
|
131
|
-
* `@atbash/atbash-plugin` register under `atbash-plugin`, and the dashboard's
|
|
132
|
-
* capability scan recognizes BOTH. So an existing config carrying the legacy key
|
|
133
|
-
* is already governed and must not be given a second, duplicate entry.
|
|
134
|
-
*/
|
|
135
|
-
const OPENCLAW_PKG = "@atbash/atbash-openclaw";
|
|
136
|
-
const OPENCLAW_ENTRY = "openclaw";
|
|
137
|
-
const OPENCLAW_LEGACY_ENTRY = "atbash-plugin";
|
|
138
|
-
/** File modes: 0700 for the key directory, 0600 for the key itself. */
|
|
139
|
-
const DIR_MODE = 0o700;
|
|
140
|
-
const KEY_MODE = 0o600;
|
|
141
|
-
// ── small filesystem helpers (best-effort; never throw) ─────────────────────
|
|
142
|
-
const exists = (...segs) => {
|
|
143
|
-
try {
|
|
144
|
-
return fs.existsSync(path.join(...segs));
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
const readTextFile = (file) => {
|
|
151
|
-
try {
|
|
152
|
-
return fs.readFileSync(file, "utf8");
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
/**
|
|
159
|
-
* Expand a leading `~/`. `--key-file ~/Downloads/keys.txt` typed inside quotes
|
|
160
|
-
* reaches us unexpanded, and failing on it would look like a missing file.
|
|
161
|
-
*/
|
|
162
|
-
function expandHome(p, home) {
|
|
163
|
-
if (p === "~")
|
|
164
|
-
return home;
|
|
165
|
-
if (p.startsWith("~/"))
|
|
166
|
-
return path.join(home, p.slice(2));
|
|
167
|
-
return p;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Pull key material out of whatever the owner pointed us at.
|
|
171
|
-
*
|
|
172
|
-
* Accepts every shape Atbash itself produces or documents, so "the file I
|
|
173
|
-
* downloaded from the modal" always works:
|
|
174
|
-
* - `privkey=…` / `pubkey=…` lines (what onboarding downloads, and what the
|
|
175
|
-
* plugin parses)
|
|
176
|
-
* - `{"privKey":…,"pubKey":…}` JSON (the alternate documented key-file form)
|
|
177
|
-
* - a bare 64-hex private key on its own line (someone who copied just the key)
|
|
178
|
-
*
|
|
179
|
-
* Returns null rather than throwing: the caller tries several sources in turn and
|
|
180
|
-
* an unparseable one is a reason to move on, not to abort.
|
|
181
|
-
*/
|
|
182
|
-
function parseKeyMaterial(raw) {
|
|
183
|
-
const text = raw.trim();
|
|
184
|
-
if (!text)
|
|
185
|
-
return null;
|
|
186
|
-
if (text.startsWith("{")) {
|
|
187
|
-
try {
|
|
188
|
-
const o = JSON.parse(text);
|
|
189
|
-
const priv = String(o.privKey ?? o.privkey ?? o.privateKey ?? "").trim();
|
|
190
|
-
const pub = String(o.pubKey ?? o.pubkey ?? o.publicKey ?? "").trim();
|
|
191
|
-
const clean = normalizePrivkey(priv);
|
|
192
|
-
return clean ? { privkey: clean, statedPubkey: pub || undefined } : null;
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
let priv = "";
|
|
199
|
-
let pub = "";
|
|
200
|
-
for (const line of text.split(/\r?\n/)) {
|
|
201
|
-
const trimmed = line.trim();
|
|
202
|
-
if (trimmed.startsWith("#") || !trimmed)
|
|
203
|
-
continue;
|
|
204
|
-
// Tolerate `privkey = x` and `privkey: x` — hand-edited files drift.
|
|
205
|
-
const kv = trimmed.match(/^(privkey|private_key|privatekey|pubkey|public_key|publickey)\s*[:=]\s*(.+)$/i);
|
|
206
|
-
if (kv) {
|
|
207
|
-
const value = kv[2].trim();
|
|
208
|
-
if (/^p(riv|rivate)/i.test(kv[1]))
|
|
209
|
-
priv = value;
|
|
210
|
-
else
|
|
211
|
-
pub = value;
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
// A bare key on its own line — only if we have not already found a labelled one.
|
|
215
|
-
if (!priv && /^(0x)?[0-9a-fA-F]{64}$/.test(trimmed))
|
|
216
|
-
priv = trimmed;
|
|
217
|
-
}
|
|
218
|
-
const clean = normalizePrivkey(priv);
|
|
219
|
-
return clean ? { privkey: clean, statedPubkey: pub || undefined } : null;
|
|
220
|
-
}
|
|
221
|
-
/** Normalize to the lowercase 64-hex the SDK validates, or "" if it is not one. */
|
|
222
|
-
function normalizePrivkey(raw) {
|
|
223
|
-
const clean = raw.replace(/^0x/i, "").trim().toLowerCase();
|
|
224
|
-
return (0, sdk_1.isValidPrivateKey)(clean) ? clean : "";
|
|
225
|
-
}
|
|
226
|
-
/** Only sniff the contents of small files — a key file is a few hundred bytes. */
|
|
227
|
-
const MAX_SNIFF_BYTES = 8 * 1024;
|
|
228
|
-
/** Bound the content-sniff so `--keys-dir ~` cannot turn into a directory crawl. */
|
|
229
|
-
const MAX_SNIFF_FILES = 60;
|
|
230
|
-
/** Newest-first, so a freshly downloaded key wins over one from last month. */
|
|
231
|
-
function newestFirst(files) {
|
|
232
|
-
return files
|
|
233
|
-
.map((file) => {
|
|
234
|
-
let mtime = 0;
|
|
235
|
-
try {
|
|
236
|
-
mtime = fs.statSync(file).mtimeMs;
|
|
237
|
-
}
|
|
238
|
-
catch { /* unreadable — sorts last */ }
|
|
239
|
-
return { file, mtime };
|
|
240
|
-
})
|
|
241
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
242
|
-
.map((e) => e.file);
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Files in a directory that plausibly hold an Atbash agent key, newest first.
|
|
246
|
-
*
|
|
247
|
-
* TWO PASSES, and the second one is the point.
|
|
248
|
-
*
|
|
249
|
-
* By name first — `guard-client-key`, `agent-keys-*.txt` and friends — because
|
|
250
|
-
* matching the name is cheap and unambiguous. But a name-only match is a cliff:
|
|
251
|
-
* rename the download, or export from a wallet UI that picks its own filename,
|
|
252
|
-
* and the operator gets "no key file found" while the key sits right there in the
|
|
253
|
-
* directory they explicitly pointed at.
|
|
254
|
-
*
|
|
255
|
-
* So if no name matches, read the small files and keep the ones that actually
|
|
256
|
-
* PARSE as key material. That is a narrow test — `privkey=`, the documented JSON
|
|
257
|
-
* shape, or a file that is nothing but a 64-hex key — not "contains something
|
|
258
|
-
* hex-looking", so an unrelated file does not get mistaken for an identity.
|
|
259
|
-
*
|
|
260
|
-
* Reading files the operator did not name individually is justified by the flag
|
|
261
|
-
* itself: `--keys-dir` is an explicit instruction to look in that directory. It
|
|
262
|
-
* is bounded to small regular files and a file count, nothing is transmitted, and
|
|
263
|
-
* the caller prints WHICH file it used before doing anything with it.
|
|
264
|
-
*/
|
|
265
|
-
function keyCandidatesInDir(dir) {
|
|
266
|
-
let names;
|
|
267
|
-
try {
|
|
268
|
-
names = fs.readdirSync(dir);
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
return [];
|
|
272
|
-
}
|
|
273
|
-
const byName = names.filter((n) => n === "guard-client-key" ||
|
|
274
|
-
/^agent-keys-.*\.txt$/i.test(n) ||
|
|
275
|
-
/^atbash.*(key|keys).*\.(txt|json)$/i.test(n));
|
|
276
|
-
if (byName.length)
|
|
277
|
-
return newestFirst(byName.map((n) => path.join(dir, n)));
|
|
278
|
-
const byContent = [];
|
|
279
|
-
let examined = 0;
|
|
280
|
-
for (const name of names) {
|
|
281
|
-
if (examined >= MAX_SNIFF_FILES)
|
|
282
|
-
break;
|
|
283
|
-
const file = path.join(dir, name);
|
|
284
|
-
try {
|
|
285
|
-
const stat = fs.statSync(file);
|
|
286
|
-
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_SNIFF_BYTES)
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
examined++;
|
|
293
|
-
const text = readTextFile(file);
|
|
294
|
-
if (text !== null && parseKeyMaterial(text))
|
|
295
|
-
byContent.push(file);
|
|
296
|
-
}
|
|
297
|
-
return newestFirst(byContent);
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* Read a secret from the terminal without echoing it.
|
|
301
|
-
*
|
|
302
|
-
* Prompting exists so the key does not have to appear in the command line: an
|
|
303
|
-
* inline `--key` is convenient (it is what makes a one-line command copied out of
|
|
304
|
-
* the browser work) but it lands in shell history and in the process list. When
|
|
305
|
-
* there is a TTY and no key was supplied, ask instead.
|
|
306
|
-
*
|
|
307
|
-
* The prompt deliberately accepts a PATH as well as a key, because "where are
|
|
308
|
-
* your keys?" and "paste your key" are the same question from the owner's side.
|
|
309
|
-
*/
|
|
310
|
-
async function promptForKeyOrPath() {
|
|
311
|
-
const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
|
|
312
|
-
return new Promise((resolveP) => {
|
|
313
|
-
const prompt = "Paste the agent's private key, or the path to its key file: ";
|
|
314
|
-
// Write the prompt ourselves, THEN suppress every subsequent write.
|
|
315
|
-
//
|
|
316
|
-
// The obvious implementation compares each write against the prompt text and
|
|
317
|
-
// lets that one through — but a pasted value containing the prompt as a
|
|
318
|
-
// substring would then be echoed to the terminal, which is exactly the
|
|
319
|
-
// failure this mute exists to prevent. Emitting the prompt up front means the
|
|
320
|
-
// suppressor never has to decide what a write IS: after this point, nothing
|
|
321
|
-
// is echoed, unconditionally.
|
|
322
|
-
process.stdout.write(prompt);
|
|
323
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
324
|
-
rl._writeToOutput = () => {
|
|
325
|
-
/* nothing typed after the prompt is ever echoed */
|
|
326
|
-
};
|
|
327
|
-
rl.question("", (answer) => {
|
|
328
|
-
rl.close();
|
|
329
|
-
// readline's own newline was suppressed along with everything else, so the
|
|
330
|
-
// next line of output would otherwise land on the prompt line.
|
|
331
|
-
process.stdout.write("\n");
|
|
332
|
-
resolveP(answer.trim());
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Interpret whatever someone typed at the prompt: a raw key, a file, or a
|
|
338
|
-
* directory to search. Shared by the "no key found" prompt and the "use the
|
|
339
|
-
* existing key?" prompt so both accept the same things — an operator who can
|
|
340
|
-
* paste a path in one place should not find it rejected in the other.
|
|
341
|
-
*/
|
|
342
|
-
async function keyFromAnswer(answer, home) {
|
|
343
|
-
const direct = parseKeyMaterial(answer);
|
|
344
|
-
if (direct)
|
|
345
|
-
return { material: direct, from: "key entered at the prompt" };
|
|
346
|
-
const asPath = expandHome(answer, home);
|
|
347
|
-
if (!exists(asPath))
|
|
348
|
-
return null;
|
|
349
|
-
let isDir = false;
|
|
350
|
-
try {
|
|
351
|
-
isDir = fs.statSync(asPath).isDirectory();
|
|
352
|
-
}
|
|
353
|
-
catch {
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
const file = isDir ? keyCandidatesInDir(asPath)[0] : asPath;
|
|
357
|
-
if (!file)
|
|
358
|
-
return null;
|
|
359
|
-
const text = readTextFile(file);
|
|
360
|
-
const material = text === null ? null : parseKeyMaterial(text);
|
|
361
|
-
return material ? { material, from: `${file} (given at the prompt)` } : null;
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* Find the agent key, trying every way an owner could plausibly have it.
|
|
365
|
-
*
|
|
366
|
-
* Order is "most explicit first": a flag the owner typed beats a file we guessed
|
|
367
|
-
* at. The last resort is the interactive prompt, and if there is no TTY the
|
|
368
|
-
* caller gets a clear error listing the flags rather than a hang.
|
|
369
|
-
*/
|
|
370
|
-
async function resolveKeySource(opts) {
|
|
371
|
-
const { home } = opts;
|
|
372
|
-
const fromFile = (file, label) => {
|
|
373
|
-
const text = readTextFile(file);
|
|
374
|
-
if (text === null)
|
|
375
|
-
return { error: `Could not read ${file}` };
|
|
376
|
-
const material = parseKeyMaterial(text);
|
|
377
|
-
if (!material)
|
|
378
|
-
return { error: `${file} does not contain a valid 64-hex agent private key.` };
|
|
379
|
-
return { material, from: `${label} (${file})` };
|
|
380
|
-
};
|
|
381
|
-
// 1. Inline — what a one-line command copied from the onboarding modal uses.
|
|
382
|
-
if (opts.key) {
|
|
383
|
-
const material = parseKeyMaterial(opts.key);
|
|
384
|
-
if (!material)
|
|
385
|
-
return { error: "--key is not a valid agent private key (expected 64 hex characters)." };
|
|
386
|
-
return { material, from: "--key on the command line" };
|
|
387
|
-
}
|
|
388
|
-
// 2. An explicit file.
|
|
389
|
-
if (opts.keyFile)
|
|
390
|
-
return fromFile(expandHome(opts.keyFile, home), "--key-file");
|
|
391
|
-
// 3. A directory to look in — "the keys are in my Downloads folder".
|
|
392
|
-
if (opts.keysDir) {
|
|
393
|
-
const dir = expandHome(opts.keysDir, home);
|
|
394
|
-
const candidates = keyCandidatesInDir(dir);
|
|
395
|
-
if (!candidates.length) {
|
|
396
|
-
return {
|
|
397
|
-
error: [
|
|
398
|
-
`No agent key found in ${dir}.`,
|
|
399
|
-
"Looked for guard-client-key / agent-keys-*.txt by name, then read the small",
|
|
400
|
-
"files there to see if any parsed as an agent key. Neither found one.",
|
|
401
|
-
"Point at the file directly with --key-file, or paste the key with no flags",
|
|
402
|
-
"at all and setup will prompt for it.",
|
|
403
|
-
].join("\n"),
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
return fromFile(candidates[0], "--keys-dir");
|
|
407
|
-
}
|
|
408
|
-
// 4. The environment variable the rest of the CLI already honours.
|
|
409
|
-
const fromEnv = process.env.ATBASH_AGENT_KEY;
|
|
410
|
-
if (fromEnv) {
|
|
411
|
-
const material = parseKeyMaterial(fromEnv);
|
|
412
|
-
if (material)
|
|
413
|
-
return { material, from: "ATBASH_AGENT_KEY" };
|
|
414
|
-
}
|
|
415
|
-
// 5. Already in the canonical place — a re-run, or a machine set up before.
|
|
416
|
-
//
|
|
417
|
-
// This used to be taken SILENTLY, and that was a trap. A machine that has ever
|
|
418
|
-
// governed one agent already has a key here, so onboarding a SECOND agent
|
|
419
|
-
// picked up the first one's key and then failed registration against a pubkey
|
|
420
|
-
// the operator never chose:
|
|
421
|
-
//
|
|
422
|
-
// Agent key source: existing key file (~/.config/atbash/guard-client-key)
|
|
423
|
-
// That agent is not registered on this deployment.
|
|
424
|
-
//
|
|
425
|
-
// which reads as "onboarding is broken" rather than "I used a different key
|
|
426
|
-
// than you meant". So when there is someone to ask, ask — and make the existing
|
|
427
|
-
// key the easy answer for the common case (a genuine re-run) without making it
|
|
428
|
-
// the only answer.
|
|
429
|
-
const keyFile = path.join(home, ...KEY_FILE_REL);
|
|
430
|
-
if (exists(keyFile)) {
|
|
431
|
-
const found = fromFile(keyFile, "existing key file");
|
|
432
|
-
if (!("error" in found)) {
|
|
433
|
-
if (!opts.allowPrompt)
|
|
434
|
-
return found; // non-interactive: same as before
|
|
435
|
-
const existingPub = (0, sdk_1.derivePublicKey)(found.material.privkey);
|
|
436
|
-
process.stdout.write(`\n This machine already has an agent key at ${keyFile}\n` +
|
|
437
|
-
` Public key: ${existingPub}\n`);
|
|
438
|
-
const useExisting = await confirm(" Use that key? [Y/n] ", true);
|
|
439
|
-
if (useExisting)
|
|
440
|
-
return found;
|
|
441
|
-
const answer = await promptForKeyOrPath();
|
|
442
|
-
if (!answer)
|
|
443
|
-
return { error: "No key provided." };
|
|
444
|
-
const supplied = await keyFromAnswer(answer, home);
|
|
445
|
-
if (supplied)
|
|
446
|
-
return supplied;
|
|
447
|
-
return { error: "That is neither a 64-hex private key nor a path that exists." };
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// 6. The CLI's own config, populated by `atbash set agent-key`.
|
|
451
|
-
const fromConfig = (0, sdk_1.resolve)("agentKey");
|
|
452
|
-
if (fromConfig) {
|
|
453
|
-
const material = parseKeyMaterial(fromConfig);
|
|
454
|
-
if (material)
|
|
455
|
-
return { material, from: "atbash config (agentKey)" };
|
|
456
|
-
}
|
|
457
|
-
// 7. Ask.
|
|
458
|
-
if (opts.allowPrompt) {
|
|
459
|
-
const answer = await promptForKeyOrPath();
|
|
460
|
-
if (!answer)
|
|
461
|
-
return { error: "No key provided." };
|
|
462
|
-
const supplied = await keyFromAnswer(answer, home);
|
|
463
|
-
if (supplied)
|
|
464
|
-
return supplied;
|
|
465
|
-
return { error: "That is neither a 64-hex private key nor a path that exists." };
|
|
466
|
-
}
|
|
467
|
-
return {
|
|
468
|
-
error: [
|
|
469
|
-
"No agent key found.",
|
|
470
|
-
"Provide one with --key <64-hex>, --key-file <path>, or --keys-dir <dir>,",
|
|
471
|
-
`or place it at ${keyFile} first.`,
|
|
472
|
-
].join("\n"),
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
/** The key file, in the `key=value` form the plugin and the SDK both parse. */
|
|
476
|
-
function keyFileContents(privkey, pubkey) {
|
|
477
|
-
return [
|
|
478
|
-
"# Atbash agent key",
|
|
479
|
-
"# Keep this file private (chmod 600). Read locally by every Atbash integration.",
|
|
480
|
-
`pubkey=${pubkey}`,
|
|
481
|
-
`privkey=${privkey}`,
|
|
482
|
-
"",
|
|
483
|
-
].join("\n");
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* True when a JSON file uses JSONC features (comments, trailing commas).
|
|
487
|
-
*
|
|
488
|
-
* We must not auto-merge into one: writing it back with JSON.stringify would
|
|
489
|
-
* silently delete the owner's comments. Detected by the disagreement between the
|
|
490
|
-
* strict and tolerant parsers — strict fails, tolerant succeeds.
|
|
491
|
-
*/
|
|
492
|
-
function isJsonc(text) {
|
|
493
|
-
try {
|
|
494
|
-
JSON.parse(text);
|
|
495
|
-
return false;
|
|
496
|
-
}
|
|
497
|
-
catch { /* fall through */ }
|
|
498
|
-
const errors = [];
|
|
499
|
-
const value = jsonc.parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
500
|
-
return errors.length === 0 && value !== undefined;
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* Merge the Atbash plugin block into an OpenClaw config object, in place.
|
|
504
|
-
*
|
|
505
|
-
* A MERGE, not a replacement — that distinction is the whole reason this command
|
|
506
|
-
* exists. Other plugins already in `allow`, `load.paths` and `entries` are
|
|
507
|
-
* preserved, and an entry from the legacy `@atbash/atbash-plugin` install is
|
|
508
|
-
* updated where it stands rather than being shadowed by a duplicate: the
|
|
509
|
-
* dashboard scan recognizes both keys, so two entries would mean two hooks.
|
|
510
|
-
*
|
|
511
|
-
* `load.paths` gets the real absolute extension path. The published docs show a
|
|
512
|
-
* `<your-username>` placeholder that people paste verbatim, producing a path that
|
|
513
|
-
* does not exist and a plugin that never loads.
|
|
514
|
-
*/
|
|
515
|
-
function mergeOpenclawConfig(config, home) {
|
|
516
|
-
const out = { ...config };
|
|
517
|
-
const plugins = { ...(isRecord(out.plugins) ? out.plugins : {}) };
|
|
518
|
-
const entries = { ...(isRecord(plugins.entries) ? plugins.entries : {}) };
|
|
519
|
-
// Which entry is this install governed under, and does one already exist?
|
|
520
|
-
const legacy = OPENCLAW_LEGACY_ENTRY in entries;
|
|
521
|
-
const modern = OPENCLAW_ENTRY in entries;
|
|
522
|
-
const entryKey = legacy && !modern ? OPENCLAW_LEGACY_ENTRY : OPENCLAW_ENTRY;
|
|
523
|
-
const existing = isRecord(entries[entryKey]) ? entries[entryKey] : null;
|
|
524
|
-
// ⚠️ AN EXISTING ENTRY IS TOUCHED AS LITTLE AS POSSIBLE.
|
|
525
|
-
//
|
|
526
|
-
// This function used to assert the full documented shape onto whatever it
|
|
527
|
-
// found, which broke a real machine: a legacy `atbash-plugin` install accepts
|
|
528
|
-
// only `config` and `enabled`, so writing the `hooks` block that the NEW
|
|
529
|
-
// `openclaw` entry documents made the whole file invalid —
|
|
530
|
-
//
|
|
531
|
-
// Invalid config at ~/.openclaw/openclaw.json:
|
|
532
|
-
// - plugins.entries.atbash-plugin: Unrecognized key: "hooks"
|
|
533
|
-
//
|
|
534
|
-
// and OpenClaw then refused to load any config at all. It also added a
|
|
535
|
-
// `load.paths` entry pointing at `extensions/openclaw` on a box whose plugin
|
|
536
|
-
// lives in `extensions/atbash-plugin`, and rewrote an absolute
|
|
537
|
-
// `chromiaSecretPath` to a `~` one for no gain.
|
|
538
|
-
//
|
|
539
|
-
// A plugin that is already installed and registered does not need `allow`,
|
|
540
|
-
// `load` or `hooks` re-declared — it is working. The only thing setup has any
|
|
541
|
-
// business changing is that it is switched on and pointed at the right key.
|
|
542
|
-
if (existing) {
|
|
543
|
-
const existingConfig = isRecord(existing.config) ? existing.config : {};
|
|
544
|
-
entries[entryKey] = {
|
|
545
|
-
...existing,
|
|
546
|
-
enabled: true,
|
|
547
|
-
config: { ...existingConfig, enabled: true, enforceDecision: true, ...keyPathUpdate(existingConfig, home) },
|
|
548
|
-
};
|
|
549
|
-
plugins.entries = entries;
|
|
550
|
-
out.plugins = plugins;
|
|
551
|
-
return out;
|
|
552
|
-
}
|
|
553
|
-
// No entry yet — write the shape the published plugin documents, in full.
|
|
554
|
-
const allow = Array.isArray(plugins.allow) ? [...plugins.allow] : [];
|
|
555
|
-
if (!allow.includes(entryKey))
|
|
556
|
-
allow.push(entryKey);
|
|
557
|
-
plugins.allow = allow;
|
|
558
|
-
const load = { ...(isRecord(plugins.load) ? plugins.load : {}) };
|
|
559
|
-
const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, entryKey);
|
|
560
|
-
const paths = Array.isArray(load.paths) ? [...load.paths] : [];
|
|
561
|
-
if (!paths.includes(extensionPath))
|
|
562
|
-
paths.push(extensionPath);
|
|
563
|
-
load.paths = paths;
|
|
564
|
-
plugins.load = load;
|
|
565
|
-
entries[entryKey] = {
|
|
566
|
-
enabled: true,
|
|
567
|
-
config: { enabled: true, enforceDecision: true, chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}` },
|
|
568
|
-
hooks: { allowConversationAccess: true, allowPromptInjection: true },
|
|
569
|
-
};
|
|
570
|
-
plugins.entries = entries;
|
|
571
|
-
out.plugins = plugins;
|
|
572
|
-
return out;
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* The key-path field, but only when it actually needs changing.
|
|
576
|
-
*
|
|
577
|
-
* `/Users/me/.config/atbash/guard-client-key` and `~/.config/atbash/guard-client-key`
|
|
578
|
-
* are the same file, and the plugin expands `~`. Rewriting one into the other is
|
|
579
|
-
* a diff the operator has to read and approve for no behavioural change, so an
|
|
580
|
-
* existing value that already resolves to the canonical key file is left exactly
|
|
581
|
-
* as they wrote it.
|
|
582
|
-
*/
|
|
583
|
-
function keyPathUpdate(existingConfig, home) {
|
|
584
|
-
const canonical = path.join(home, ...KEY_FILE_REL);
|
|
585
|
-
const current = existingConfig.chromiaSecretPath;
|
|
586
|
-
if (typeof current === "string" && path.resolve(expandHome(current.trim(), home)) === path.resolve(canonical)) {
|
|
587
|
-
return {};
|
|
588
|
-
}
|
|
589
|
-
return { chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}` };
|
|
590
|
-
}
|
|
591
|
-
function isRecord(v) {
|
|
592
|
-
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
593
|
-
}
|
|
594
|
-
/**
|
|
595
|
-
* Detect the indentation a JSON file already uses, so a merge does not reformat
|
|
596
|
-
* the parts it did not touch.
|
|
597
|
-
*
|
|
598
|
-
* Without this, `JSON.stringify(obj, null, 2)` re-indents a tab-indented or
|
|
599
|
-
* 4-space config from top to bottom. The RESULT is still correct, but the diff
|
|
600
|
-
* shown for approval becomes every line in the file, which buries the two lines
|
|
601
|
-
* that actually changed — and the operator's own formatting choice is collateral
|
|
602
|
-
* damage in a file we were asked to make one addition to.
|
|
603
|
-
*
|
|
604
|
-
* Falls back to two spaces, which is what the published docs show.
|
|
605
|
-
*/
|
|
606
|
-
function detectIndent(text) {
|
|
607
|
-
if (!text)
|
|
608
|
-
return 2;
|
|
609
|
-
// First line that is indented under an opening brace/bracket tells us the unit.
|
|
610
|
-
const match = text.match(/\n([ \t]+)\S/);
|
|
611
|
-
if (!match)
|
|
612
|
-
return 2;
|
|
613
|
-
const indent = match[1];
|
|
614
|
-
return indent.startsWith("\t") ? "\t" : indent.length;
|
|
615
|
-
}
|
|
616
|
-
/**
|
|
617
|
-
* Serialize a merged config the way the file was already written: same
|
|
618
|
-
* indentation, and a trailing newline only if the original had one.
|
|
619
|
-
*/
|
|
620
|
-
function serializeLike(original, value) {
|
|
621
|
-
const body = JSON.stringify(value, null, detectIndent(original));
|
|
622
|
-
// A file that ended without a newline keeps ending without one. Trivial, but it
|
|
623
|
-
// is one more line of unexplained diff for someone reviewing the change.
|
|
624
|
-
const trailing = original === null || original.endsWith("\n") ? "\n" : "";
|
|
625
|
-
return body + trailing;
|
|
626
|
-
}
|
|
627
|
-
/**
|
|
628
|
-
* The MCP server entry setup writes into a client's config.
|
|
629
|
-
*
|
|
630
|
-
* Note what is NOT here: an `env` block. The published `@atbash/mcp` wiring
|
|
631
|
-
* carries the agent's private key in one, because that package reads only
|
|
632
|
-
* ATBASH_AGENT_PRIVKEY. Going through `atbash mcp` instead means the launcher
|
|
633
|
-
* reads the 0600 key file and passes the key to the server in the child process
|
|
634
|
-
* environment, so this entry holds no credential and the client's config file is
|
|
635
|
-
* no more sensitive after setup runs than it was before.
|
|
636
|
-
*
|
|
637
|
-
* Deliberately NOT pinned to an exact CLI version: unlike the one-shot connector
|
|
638
|
-
* command, this entry persists in the operator's config and is re-executed every
|
|
639
|
-
* time the client starts. Pinning here would freeze their MCP server at whatever
|
|
640
|
-
* version happened to be current on the day they ran setup.
|
|
641
|
-
*/
|
|
642
|
-
const MCP_SERVER_ENTRY = { command: "npx", args: ["--yes", "@atbash/cli", "mcp"] };
|
|
643
|
-
const MCP_SERVER_NAME = "atbash";
|
|
644
|
-
/**
|
|
645
|
-
* MCP client configs present under this home directory.
|
|
646
|
-
*
|
|
647
|
-
* Paths come from the shared MCP_CONFIGS so the writer and the scanner cannot
|
|
648
|
-
* drift: a client the scan reports but setup cannot find would look like a bug in
|
|
649
|
-
* whichever of the two the operator happened to trust.
|
|
650
|
-
*/
|
|
651
|
-
function detectMcpClients(home) {
|
|
652
|
-
const out = [];
|
|
653
|
-
const seen = new Set();
|
|
654
|
-
for (const { label, segs } of atbash_targets_1.MCP_CONFIGS) {
|
|
655
|
-
if (seen.has(label))
|
|
656
|
-
continue;
|
|
657
|
-
const file = path.join(home, ...segs);
|
|
658
|
-
if (!exists(file))
|
|
659
|
-
continue;
|
|
660
|
-
seen.add(label);
|
|
661
|
-
// Read which key this file already uses rather than assuming. VS Code's
|
|
662
|
-
// mcp.json uses `servers`; writing `mcpServers` into it would be ignored.
|
|
663
|
-
const existing = readJsonLoose(file);
|
|
664
|
-
const serversKey = existing && isRecord(existing.servers) && !isRecord(existing.mcpServers) ? "servers" : "mcpServers";
|
|
665
|
-
out.push({ label, file, format: "json", serversKey });
|
|
666
|
-
}
|
|
667
|
-
// Claude Code and Codex are special-cased in the scanner too — same paths.
|
|
668
|
-
const claudeCode = path.join(home, ".claude.json");
|
|
669
|
-
if (exists(claudeCode))
|
|
670
|
-
out.push({ label: "Claude Code", file: claudeCode, format: "json", serversKey: "mcpServers" });
|
|
671
|
-
const codex = path.join(home, ".codex", "config.toml");
|
|
672
|
-
if (exists(codex))
|
|
673
|
-
out.push({ label: "Codex", file: codex, format: "toml", serversKey: "mcpServers" });
|
|
674
|
-
return out;
|
|
675
|
-
}
|
|
676
|
-
/** Tolerant read used only to sniff an existing file's shape. */
|
|
677
|
-
function readJsonLoose(file) {
|
|
678
|
-
const text = readTextFile(file);
|
|
679
|
-
if (text === null)
|
|
680
|
-
return null;
|
|
681
|
-
const value = jsonc.parse(text, [], { allowTrailingComma: true, disallowComments: false });
|
|
682
|
-
return isRecord(value) ? value : null;
|
|
683
|
-
}
|
|
684
|
-
/**
|
|
685
|
-
* Add the Atbash server to a client config's server map, in place.
|
|
686
|
-
*
|
|
687
|
-
* A merge, like the OpenClaw one: every server the operator already configured
|
|
688
|
-
* stays exactly as it is. An existing `atbash` entry is REPLACED rather than
|
|
689
|
-
* merged field-by-field — a stale `env` block carrying a private key from the old
|
|
690
|
-
* hand-written wiring is precisely what we want gone, and preserving it would
|
|
691
|
-
* defeat the point of routing through the launcher.
|
|
692
|
-
*/
|
|
693
|
-
/** The Hermes plugin, and the exact version the wiring is written against. */
|
|
694
|
-
const HERMES_PKG = "atbash-hermes-plugin";
|
|
695
|
-
const HERMES_VERSION = "0.4.5";
|
|
696
|
-
/** The name Hermes lists this plugin under, and the allow-list entry. */
|
|
697
|
-
const HERMES_PLUGIN_ENTRY = "atbash-hermes-plugin";
|
|
698
|
-
/**
|
|
699
|
-
* Find the Python interpreter that actually runs Hermes.
|
|
700
|
-
*
|
|
701
|
-
* This is the difference between installing the plugin and only appearing to.
|
|
702
|
-
* `pip install atbash-hermes-plugin` puts the package wherever the *shell's*
|
|
703
|
-
* `pip` points — commonly a system or conda Python — while Hermes typically runs
|
|
704
|
-
* from its own virtualenv. The install succeeds, prints nothing alarming, and the
|
|
705
|
-
* plugin is invisible to Hermes forever. Nobody can debug that from the output.
|
|
706
|
-
*
|
|
707
|
-
* The launcher knows the answer. A pip-installed console script begins with a
|
|
708
|
-
* shebang naming the interpreter that created it:
|
|
709
|
-
*
|
|
710
|
-
* $ head -1 $(command -v hermes)
|
|
711
|
-
* #!/Users/me/.hermes/hermes-agent/venv/bin/python3
|
|
712
|
-
*
|
|
713
|
-
* So resolve `hermes`, read its first line, and use that interpreter directly via
|
|
714
|
-
* `-m pip`. Falls back to the conventional venv location under ~/.hermes, then to
|
|
715
|
-
* null — and a null becomes a printed command rather than a guess, because a
|
|
716
|
-
* wrong guess here is the silent failure this whole function exists to avoid.
|
|
717
|
-
*/
|
|
718
|
-
function findHermesPython(home) {
|
|
719
|
-
const viable = (candidate) => {
|
|
720
|
-
try {
|
|
721
|
-
return fs.statSync(candidate).isFile();
|
|
722
|
-
}
|
|
723
|
-
catch {
|
|
724
|
-
return false;
|
|
725
|
-
}
|
|
726
|
-
};
|
|
727
|
-
// 1. The launcher's own shebang — authoritative, but only for a real run.
|
|
728
|
-
//
|
|
729
|
-
// `--home <dir>` exists so a dry run can be hermetic (the release checklist
|
|
730
|
-
// depends on it). A PATH lookup ignores it entirely: under `--home /tmp/fake`
|
|
731
|
-
// this would find the operator's ACTUAL hermes and plan an install into their
|
|
732
|
-
// real virtualenv. So the shebang route is skipped whenever `home` is not the
|
|
733
|
-
// machine's own home — the caller then falls through to the venv path under the
|
|
734
|
-
// given home, which is correctly scoped.
|
|
735
|
-
const realHome = process.env.HOME || os.homedir();
|
|
736
|
-
const scoped = path.resolve(home) !== path.resolve(realHome);
|
|
737
|
-
const which = scoped
|
|
738
|
-
? { status: 1, stdout: "" }
|
|
739
|
-
: (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["hermes"], { encoding: "utf8" });
|
|
740
|
-
const launcher = which.status === 0 ? which.stdout.split(/\r?\n/)[0]?.trim() : "";
|
|
741
|
-
if (launcher && viable(launcher)) {
|
|
742
|
-
const firstLine = (readTextFile(launcher) ?? "").split(/\r?\n/)[0] ?? "";
|
|
743
|
-
const shebang = firstLine.startsWith("#!") ? firstLine.slice(2).trim() : "";
|
|
744
|
-
// `#!/usr/bin/env python3` names no path; anything else should be absolute.
|
|
745
|
-
const interpreter = shebang.split(/\s+/).filter((part) => !part.endsWith("/env"))[0] ?? "";
|
|
746
|
-
if (/python[0-9.]*$/.test(interpreter) && viable(interpreter)) {
|
|
747
|
-
return { python: interpreter, how: `shebang of ${launcher}` };
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
// 2. The conventional venv Hermes ships with.
|
|
751
|
-
for (const name of ["python3", "python"]) {
|
|
752
|
-
const candidate = path.join(home, ".hermes", "hermes-agent", "venv", "bin", name);
|
|
753
|
-
if (viable(candidate))
|
|
754
|
-
return { python: candidate, how: "Hermes virtualenv under ~/.hermes" };
|
|
755
|
-
}
|
|
756
|
-
return null;
|
|
757
|
-
}
|
|
758
|
-
/** Same rule as buildPlan's `wanted`: an empty --runtime list means everything. */
|
|
759
|
-
function wantedRuntime(id, only) {
|
|
760
|
-
return only.length === 0 || only.includes(id);
|
|
761
|
-
}
|
|
762
|
-
/**
|
|
763
|
-
* Add the plugin to Hermes' opt-in allow-list at `plugins.enabled` in
|
|
764
|
-
* `~/.hermes/config.yaml`.
|
|
765
|
-
*
|
|
766
|
-
* WHY NOT `hermes plugins enable`: that command cannot accept this plugin, on
|
|
767
|
-
* this version, ever. `_plugin_exists` (hermes_cli/plugins_cmd.py) looks only for
|
|
768
|
-
* a DIRECTORY in the user plugins dir or a bundled dir — it never consults entry
|
|
769
|
-
* points. Meanwhile the runtime loader (`plugins.py:_scan_entry_points`) does
|
|
770
|
-
* discover them. So Hermes will happily LOAD a pip-installed plugin but refuses
|
|
771
|
-
* to put one on the allow-list it requires, and since plugins are opt-in, a
|
|
772
|
-
* package that cannot get onto the list can never load. Running that command
|
|
773
|
-
* exits 1 with "not installed or bundled". Writing the list entry directly is the
|
|
774
|
-
* only route that works.
|
|
775
|
-
*
|
|
776
|
-
* A LINE MERGE, not a parse-and-reserialize. config.yaml is tens of kilobytes of
|
|
777
|
-
* heavily commented configuration; round-tripping it through a YAML emitter would
|
|
778
|
-
* strip every comment and reflow the file. So this inserts the one line needed and
|
|
779
|
-
* leaves every other byte alone — the same discipline as the .env merge.
|
|
780
|
-
*/
|
|
781
|
-
function mergeHermesEnabledPlugins(existing, plugin = HERMES_PLUGIN_ENTRY) {
|
|
782
|
-
const lines = existing === null ? [] : existing.split("\n");
|
|
783
|
-
// Already on the list? Then there is nothing to do.
|
|
784
|
-
const pluginsAt = lines.findIndex((l) => /^plugins:\s*(#.*)?$/.test(l));
|
|
785
|
-
if (pluginsAt !== -1) {
|
|
786
|
-
// Walk the plugins block (indented lines) looking for `enabled:` and its items.
|
|
787
|
-
let enabledAt = -1;
|
|
788
|
-
let end = lines.length;
|
|
789
|
-
for (let i = pluginsAt + 1; i < lines.length; i++) {
|
|
790
|
-
const line = lines[i];
|
|
791
|
-
if (line.trim() === "" || line.startsWith("#"))
|
|
792
|
-
continue;
|
|
793
|
-
if (!/^\s/.test(line)) {
|
|
794
|
-
end = i;
|
|
795
|
-
break;
|
|
796
|
-
} // dedented — block over
|
|
797
|
-
if (/^\s+enabled:\s*(#.*)?$/.test(line))
|
|
798
|
-
enabledAt = i;
|
|
799
|
-
}
|
|
800
|
-
if (enabledAt !== -1) {
|
|
801
|
-
// Collect the list items under `enabled:` and bail if ours is there.
|
|
802
|
-
const itemIndent = (lines[enabledAt].match(/^\s*/)?.[0] ?? " ") + " ";
|
|
803
|
-
let last = enabledAt;
|
|
804
|
-
for (let i = enabledAt + 1; i < end; i++) {
|
|
805
|
-
if (/^\s*-\s+/.test(lines[i])) {
|
|
806
|
-
if (lines[i].replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "") === plugin)
|
|
807
|
-
return existing;
|
|
808
|
-
last = i;
|
|
809
|
-
}
|
|
810
|
-
else if (lines[i].trim() !== "")
|
|
811
|
-
break;
|
|
812
|
-
}
|
|
813
|
-
lines.splice(last + 1, 0, `${itemIndent}- ${plugin}`);
|
|
814
|
-
return lines.join("\n");
|
|
815
|
-
}
|
|
816
|
-
// A plugins block with no `enabled:` key — add one inside it.
|
|
817
|
-
lines.splice(pluginsAt + 1, 0, " enabled:", ` - ${plugin}`);
|
|
818
|
-
return lines.join("\n");
|
|
819
|
-
}
|
|
820
|
-
// No plugins block at all — which is the default, and means nothing loads.
|
|
821
|
-
const out = existing === null ? [] : [...lines];
|
|
822
|
-
if (out.length && out[out.length - 1].trim() !== "")
|
|
823
|
-
out.push("");
|
|
824
|
-
out.push("# Added by `atbash setup` - Hermes loads only plugins on this allow-list");
|
|
825
|
-
out.push("plugins:", " enabled:", ` - ${plugin}`);
|
|
826
|
-
let text = out.join("\n");
|
|
827
|
-
if (!text.endsWith("\n"))
|
|
828
|
-
text += "\n";
|
|
829
|
-
return text;
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Is a Hermes gateway service running, and therefore restartable?
|
|
833
|
-
*
|
|
834
|
-
* "Restart Hermes" only means something when there is a service to bounce.
|
|
835
|
-
* Hermes has two shapes: `hermes` starts an interactive chat session, and
|
|
836
|
-
* `hermes gateway install` registers a launchd/systemd background service. A
|
|
837
|
-
* setup command must not conflate them —
|
|
838
|
-
*
|
|
839
|
-
* - with a service running, `hermes gateway restart` picks up the new config and
|
|
840
|
-
* is worth doing for the operator;
|
|
841
|
-
* - without one, there is nothing to restart. The plugin loads the next time
|
|
842
|
-
* they run `hermes`, and anything we "restarted" would either be a no-op or,
|
|
843
|
-
* worse, an interactive session someone is in the middle of using.
|
|
844
|
-
*
|
|
845
|
-
* `gateway status` exits 0 either way, so the state comes from its text.
|
|
846
|
-
*/
|
|
847
|
-
function hermesGatewayRunning() {
|
|
848
|
-
const probe = (0, child_process_1.spawnSync)("hermes", ["gateway", "status"], { encoding: "utf8" });
|
|
849
|
-
if (probe.status !== 0 || typeof probe.stdout !== "string")
|
|
850
|
-
return false;
|
|
851
|
-
// "✗ Gateway is not running" vs a running report.
|
|
852
|
-
return !/not\s+running/i.test(probe.stdout);
|
|
853
|
-
}
|
|
854
|
-
/**
|
|
855
|
-
* Is Hermes CONFIGURED to load the plugin?
|
|
856
|
-
*
|
|
857
|
-
* This used to shell out to `hermes plugins list` and look for an atbash row —
|
|
858
|
-
* a signal that can never be true. `_discover_all_plugins` (plugins_cmd.py) walks
|
|
859
|
-
* plugin DIRECTORIES only: bundled, user, project. It never scans entry points,
|
|
860
|
-
* exactly like the `_plugin_exists` gate behind `plugins enable`. So a
|
|
861
|
-
* pip-installed plugin is invisible to both, and the check reported "not picked
|
|
862
|
-
* up" while everything was in fact correct — then told the operator to run the
|
|
863
|
-
* enable command that cannot work. Advising a known-impossible fix is worse than
|
|
864
|
-
* saying nothing.
|
|
865
|
-
*
|
|
866
|
-
* The two facts that actually decide it are both on disk:
|
|
867
|
-
*
|
|
868
|
-
* 1. the package is importable by the interpreter that runs Hermes, and
|
|
869
|
-
* 2. its name is on the `plugins.enabled` allow-list in config.yaml, which is
|
|
870
|
-
* what the RUNTIME loader (`plugins.py:_scan_entry_points`) honours.
|
|
871
|
-
*
|
|
872
|
-
* "configured" is as far as a setup command can honestly go. Proof of loading is
|
|
873
|
-
* a line in the agent log after Hermes next starts, which is why the caller points
|
|
874
|
-
* at that rather than claiming enforcement.
|
|
875
|
-
*/
|
|
876
|
-
function hermesPluginState(home) {
|
|
877
|
-
const venvBase = path.join(home, ".hermes", "hermes-agent", "venv", "lib");
|
|
878
|
-
let installed = false;
|
|
879
|
-
try {
|
|
880
|
-
installed = fs.readdirSync(venvBase, { withFileTypes: true })
|
|
881
|
-
.filter((e) => e.isDirectory())
|
|
882
|
-
.some((e) => exists(venvBase, e.name, "site-packages", "atbash_hermes_plugin"));
|
|
883
|
-
}
|
|
884
|
-
catch {
|
|
885
|
-
return "unknown";
|
|
886
|
-
}
|
|
887
|
-
const configYaml = readTextFile(path.join(home, ".hermes", "config.yaml"));
|
|
888
|
-
if (configYaml === null)
|
|
889
|
-
return installed ? "not-enabled" : "not-installed";
|
|
890
|
-
let enabled = false;
|
|
891
|
-
try {
|
|
892
|
-
const parsed = (0, yaml_1.parse)(configYaml);
|
|
893
|
-
const list = parsed?.plugins?.enabled;
|
|
894
|
-
enabled = Array.isArray(list) && list.some((n) => String(n).trim() === HERMES_PLUGIN_ENTRY);
|
|
895
|
-
}
|
|
896
|
-
catch {
|
|
897
|
-
return "unknown";
|
|
898
|
-
}
|
|
899
|
-
if (!installed)
|
|
900
|
-
return "not-installed";
|
|
901
|
-
return enabled ? "configured" : "not-enabled";
|
|
902
|
-
}
|
|
903
|
-
/**
|
|
904
|
-
* How to install a Python package into a specific interpreter on THIS machine.
|
|
905
|
-
*
|
|
906
|
-
* `<python> -m pip install` is the obvious answer and it is frequently wrong: a
|
|
907
|
-
* venv created by `uv venv` has no pip at all (that is uv's default), so the
|
|
908
|
-
* command fails with
|
|
909
|
-
*
|
|
910
|
-
* /path/venv/bin/python3: No module named pip
|
|
911
|
-
*
|
|
912
|
-
* after setup has already written every config file — which is exactly what
|
|
913
|
-
* happened on a real Hermes box. So probe, in order of what suits the venv:
|
|
914
|
-
*
|
|
915
|
-
* 1. `uv pip install --python <python>` when uv is present. Correct for a
|
|
916
|
-
* uv-created venv and fast; uv is also what created most pip-less venvs.
|
|
917
|
-
* 2. `<python> -m pip install` when pip actually answers.
|
|
918
|
-
* 3. Neither — hand it over, with `ensurepip` named, rather than planning a
|
|
919
|
-
* command that is known in advance to fail.
|
|
920
|
-
*/
|
|
921
|
-
function pythonInstallStrategy(python, pkg) {
|
|
922
|
-
const uv = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", ["uv"], { stdio: "ignore" });
|
|
923
|
-
if (uv.status === 0) {
|
|
924
|
-
return {
|
|
925
|
-
kind: "exec",
|
|
926
|
-
command: "uv",
|
|
927
|
-
args: ["pip", "install", "--python", python, pkg],
|
|
928
|
-
how: "uv, targeting the interpreter that runs Hermes",
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
|
-
const pip = (0, child_process_1.spawnSync)(python, ["-m", "pip", "--version"], { stdio: "ignore" });
|
|
932
|
-
if (pip.status === 0) {
|
|
933
|
-
return { kind: "exec", command: python, args: ["-m", "pip", "install", pkg], how: "pip in the interpreter that runs Hermes" };
|
|
934
|
-
}
|
|
935
|
-
return {
|
|
936
|
-
kind: "manual",
|
|
937
|
-
why: `${python} has no pip (a venv created by \`uv venv\` has none by default) and uv is not on PATH, so setup cannot install into it. Bootstrap pip, then install:`,
|
|
938
|
-
snippet: [`${python} -m ensurepip --upgrade`, `${python} -m pip install ${pkg}`].join("\n"),
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
/**
|
|
942
|
-
* The env vars the Hermes plugin documents, merged into an existing `.env`.
|
|
943
|
-
*
|
|
944
|
-
* A `.env` is line-oriented and hand-maintained, so this is a line merge rather
|
|
945
|
-
* than a parse-and-reserialize: keys Atbash owns are replaced in place (keeping
|
|
946
|
-
* their position), keys it does not own are never touched, and anything else in
|
|
947
|
-
* the file — comments, blank lines, unrelated settings, ordering — survives
|
|
948
|
-
* exactly as written. Reformatting someone's .env to add four lines would be a
|
|
949
|
-
* poor trade.
|
|
950
|
-
*
|
|
951
|
-
* Values are from the published plugin README (PyPI atbash-hermes-plugin 0.4.5).
|
|
952
|
-
* `ATBASH_ORG_NAME` is deliberately NOT written: its value is the operator's org,
|
|
953
|
-
* which this command has no reliable way to know, and a wrong org sends the SDK
|
|
954
|
-
* at the wrong chain. It is called out in the manual step instead.
|
|
955
|
-
*/
|
|
956
|
-
function mergeHermesEnv(existing) {
|
|
957
|
-
const desired = {
|
|
958
|
-
ATBASH_KEY_PATH: "$HOME/.config/atbash/guard-client-key",
|
|
959
|
-
ATBASH_ENFORCE_DECISION: "true",
|
|
960
|
-
};
|
|
961
|
-
const lines = existing === null ? [] : existing.split("\n");
|
|
962
|
-
const seen = new Set();
|
|
963
|
-
const out = lines.map((line) => {
|
|
964
|
-
const match = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=/);
|
|
965
|
-
const key = match?.[1];
|
|
966
|
-
if (!key || !(key in desired) || seen.has(key))
|
|
967
|
-
return line;
|
|
968
|
-
seen.add(key);
|
|
969
|
-
// Already correct — keep the operator's own formatting rather than rewriting.
|
|
970
|
-
if (line.trim() === `${key}=${desired[key]}`)
|
|
971
|
-
return line;
|
|
972
|
-
return `${key}=${desired[key]}`;
|
|
973
|
-
});
|
|
974
|
-
const missing = Object.entries(desired).filter(([key]) => !seen.has(key));
|
|
975
|
-
if (missing.length) {
|
|
976
|
-
// Separate the block we add from whatever came before it.
|
|
977
|
-
if (out.length && out[out.length - 1].trim() !== "")
|
|
978
|
-
out.push("");
|
|
979
|
-
// ASCII-only comment on purpose: .env files are read by many different
|
|
980
|
-
// parsers and a stray multi-byte dash is a free way to trip a strict one.
|
|
981
|
-
if (existing !== null)
|
|
982
|
-
out.push("# Added by `atbash setup` - Atbash Hermes plugin");
|
|
983
|
-
for (const [key, value] of missing)
|
|
984
|
-
out.push(`${key}=${value}`);
|
|
985
|
-
}
|
|
986
|
-
let text = out.join("\n");
|
|
987
|
-
if (!text.endsWith("\n"))
|
|
988
|
-
text += "\n";
|
|
989
|
-
return text;
|
|
990
|
-
}
|
|
991
|
-
/**
|
|
992
|
-
* Does this config's existing Atbash entry carry a key in its `env` block?
|
|
993
|
-
*
|
|
994
|
-
* True means the operator hand-wired it from the published docs and their private
|
|
995
|
-
* key is sitting in that file today. Setup takes it out, but the backup it writes
|
|
996
|
-
* first still has it — so this exists to make that sayable rather than silently
|
|
997
|
-
* relocating the leak.
|
|
998
|
-
*/
|
|
999
|
-
function hadInlineKey(config, serversKey = "mcpServers") {
|
|
1000
|
-
const servers = isRecord(config[serversKey]) ? config[serversKey] : undefined;
|
|
1001
|
-
const entry = servers && isRecord(servers[MCP_SERVER_NAME]) ? servers[MCP_SERVER_NAME] : undefined;
|
|
1002
|
-
const env = entry && isRecord(entry.env) ? entry.env : undefined;
|
|
1003
|
-
if (!env)
|
|
1004
|
-
return false;
|
|
1005
|
-
// Any 64-hex value, under any key name — not just the documented one, since a
|
|
1006
|
-
// hand-edited config may well have renamed it.
|
|
1007
|
-
return Object.values(env).some((v) => typeof v === "string" && /^(0x)?[0-9a-fA-F]{64}$/.test(v.trim()));
|
|
1008
|
-
}
|
|
1009
|
-
function mergeMcpServer(config, serversKey = "mcpServers") {
|
|
1010
|
-
const out = { ...config };
|
|
1011
|
-
const servers = { ...(isRecord(out[serversKey]) ? out[serversKey] : {}) };
|
|
1012
|
-
servers[MCP_SERVER_NAME] = { ...MCP_SERVER_ENTRY, args: [...MCP_SERVER_ENTRY.args] };
|
|
1013
|
-
out[serversKey] = servers;
|
|
1014
|
-
return out;
|
|
1015
|
-
}
|
|
1016
|
-
/** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
|
|
1017
|
-
function hasExecutable(command) {
|
|
1018
|
-
const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
|
|
1019
|
-
return probe.status === 0;
|
|
1020
|
-
}
|
|
1021
|
-
/**
|
|
1022
|
-
* Work out everything that needs doing on this machine, without doing any of it.
|
|
1023
|
-
*
|
|
1024
|
-
* Detection drives the plan rather than a flag the owner picks, for the same
|
|
1025
|
-
* reason the scan does: what is actually installed here is knowable, and asking
|
|
1026
|
-
* someone to identify their own runtime from a list invites a wrong answer that
|
|
1027
|
-
* writes a config for a plugin they do not have.
|
|
1028
|
-
*/
|
|
1029
|
-
function buildPlan(args) {
|
|
1030
|
-
const { home, privkey, pubkey, noInstall, only } = args;
|
|
1031
|
-
const steps = [];
|
|
1032
|
-
const notes = [];
|
|
1033
|
-
const found = [];
|
|
1034
|
-
const wanted = (id) => only.length === 0 || only.includes(id);
|
|
1035
|
-
// ── 1. The key file. Always, for every runtime: it is the one artifact every
|
|
1036
|
-
// integration reads, and the thing the owner would otherwise move by hand.
|
|
1037
|
-
const keyFile = path.join(home, ...KEY_FILE_REL);
|
|
1038
|
-
const desiredKeyFile = keyFileContents(privkey, pubkey);
|
|
1039
|
-
const currentKeyFile = readTextFile(keyFile);
|
|
1040
|
-
const alreadyThisKey = currentKeyFile !== null && parseKeyMaterial(currentKeyFile)?.privkey === privkey;
|
|
1041
|
-
if (!alreadyThisKey) {
|
|
1042
|
-
if (currentKeyFile !== null) {
|
|
1043
|
-
// The outgoing key belongs to a real agent that this machine may still be
|
|
1044
|
-
// governing. A `.atbash-bak` preserves the bytes but not the identity — six
|
|
1045
|
-
// months later nobody knows which agent `guard-client-key.atbash-bak` was.
|
|
1046
|
-
// So archive it under its own public key: recoverable, self-identifying,
|
|
1047
|
-
// and a path a runtime config can point at directly if this box needs to
|
|
1048
|
-
// run two agents (both OpenClaw's `chromiaSecretPath` and Hermes'
|
|
1049
|
-
// `ATBASH_KEY_PATH` take an explicit path).
|
|
1050
|
-
const outgoing = parseKeyMaterial(currentKeyFile);
|
|
1051
|
-
const outgoingPub = outgoing ? (0, sdk_1.derivePublicKey)(outgoing.privkey) : null;
|
|
1052
|
-
if (outgoingPub) {
|
|
1053
|
-
const archive = path.join(home, ".config", "atbash", "keys", `${outgoingPub}.key`);
|
|
1054
|
-
if (!exists(archive)) {
|
|
1055
|
-
steps.push({
|
|
1056
|
-
kind: "write",
|
|
1057
|
-
label: `Archive the agent key already on this machine (${outgoingPub.slice(0, 12)}…)`,
|
|
1058
|
-
file: archive,
|
|
1059
|
-
mode: KEY_MODE,
|
|
1060
|
-
before: null,
|
|
1061
|
-
after: currentKeyFile,
|
|
1062
|
-
secret: true,
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
notes.push(`${keyFile} currently holds a DIFFERENT agent (${outgoingPub.slice(0, 12)}…). It is archived to ~/.config/atbash/keys/${outgoingPub.slice(0, 12)}….key before being replaced, so that agent is recoverable — but every integration on this machine reading the default path will switch to the new agent.`);
|
|
1066
|
-
}
|
|
1067
|
-
else {
|
|
1068
|
-
notes.push(`${keyFile} holds something this command could not parse as an agent key. It will be backed up before being replaced.`);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
steps.push({
|
|
1072
|
-
kind: "write",
|
|
1073
|
-
label: currentKeyFile === null ? "Save the agent key where every integration looks for it" : "Replace the agent key file",
|
|
1074
|
-
file: keyFile,
|
|
1075
|
-
mode: KEY_MODE,
|
|
1076
|
-
before: currentKeyFile,
|
|
1077
|
-
after: desiredKeyFile,
|
|
1078
|
-
secret: true,
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
1081
|
-
else {
|
|
1082
|
-
notes.push(`${keyFile} already holds this agent's key — left untouched.`);
|
|
1083
|
-
}
|
|
1084
|
-
// ── 2. OpenClaw: the one runtime governed purely by a config file, so the one
|
|
1085
|
-
// this command can finish end to end.
|
|
1086
|
-
const openclawConfigFile = path.join(home, ...OPENCLAW_CONFIG_REL);
|
|
1087
|
-
if (exists(openclawConfigFile) || exists(home, ".openclaw")) {
|
|
1088
|
-
found.push("OpenClaw");
|
|
1089
|
-
if (wanted("openclaw")) {
|
|
1090
|
-
if (!noInstall) {
|
|
1091
|
-
if (hasExecutable("openclaw")) {
|
|
1092
|
-
steps.push({ kind: "exec", label: `Install ${OPENCLAW_PKG}`, command: "openclaw", args: ["plugins", "install", OPENCLAW_PKG] });
|
|
1093
|
-
}
|
|
1094
|
-
else {
|
|
1095
|
-
steps.push({
|
|
1096
|
-
kind: "manual",
|
|
1097
|
-
label: "Install the OpenClaw plugin",
|
|
1098
|
-
detail: "The `openclaw` command is not on this machine's PATH, so the plugin cannot be installed for you. Run this wherever the OpenClaw CLI lives:",
|
|
1099
|
-
snippet: `openclaw plugins install ${OPENCLAW_PKG}`,
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
const raw = readTextFile(openclawConfigFile);
|
|
1104
|
-
if (raw !== null && isJsonc(raw)) {
|
|
1105
|
-
// Rewriting this would delete the owner's comments. Print instead.
|
|
1106
|
-
steps.push({
|
|
1107
|
-
kind: "manual",
|
|
1108
|
-
label: `Enable the plugin in ${openclawConfigFile}`,
|
|
1109
|
-
detail: "That file uses comments or trailing commas, and rewriting it as strict JSON would delete them. Merge this into the existing `plugins` object by hand — keep any other plugins already in `allow` and `entries`:",
|
|
1110
|
-
snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home), null, 2),
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
else {
|
|
1114
|
-
let current = {};
|
|
1115
|
-
if (raw !== null) {
|
|
1116
|
-
try {
|
|
1117
|
-
const parsed = JSON.parse(raw);
|
|
1118
|
-
if (isRecord(parsed))
|
|
1119
|
-
current = parsed;
|
|
1120
|
-
}
|
|
1121
|
-
catch {
|
|
1122
|
-
notes.push(`${openclawConfigFile} is not valid JSON — it will be backed up and rewritten from scratch, which loses whatever was in it. Fix the file first if it holds configuration you need.`);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
const after = serializeLike(raw, mergeOpenclawConfig(current, home));
|
|
1126
|
-
if (raw !== after) {
|
|
1127
|
-
steps.push({
|
|
1128
|
-
kind: "write",
|
|
1129
|
-
label: raw === null
|
|
1130
|
-
? "Create ~/.openclaw/openclaw.json with the plugin enabled"
|
|
1131
|
-
: "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
|
|
1132
|
-
file: openclawConfigFile,
|
|
1133
|
-
before: raw,
|
|
1134
|
-
after,
|
|
1135
|
-
});
|
|
1136
|
-
}
|
|
1137
|
-
else {
|
|
1138
|
-
notes.push(`${openclawConfigFile} already has the plugin enabled — left untouched.`);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
notes.push("Restart the OpenClaw gateway. The hook is registered at startup, so the plugin does nothing until it restarts.");
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
// ── 3. Hermes: not a runtime that merely lacks a plugin. It runs the SAME
|
|
1145
|
-
// agent, reads the same skills and the same key — but the Atbash hook lives in
|
|
1146
|
-
// the OpenClaw gateway, so anything driven through the Hermes API is never
|
|
1147
|
-
// judged. Setup places the key file and says so; it does not pretend to wire it.
|
|
1148
|
-
if (exists(home, ...HERMES_AGENT_REL)) {
|
|
1149
|
-
found.push("Hermes");
|
|
1150
|
-
if (wanted("hermes")) {
|
|
1151
|
-
const envFile = path.join(home, ".hermes", ".env");
|
|
1152
|
-
const raw = readTextFile(envFile);
|
|
1153
|
-
const merged = mergeHermesEnv(raw);
|
|
1154
|
-
if (merged !== raw) {
|
|
1155
|
-
steps.push({
|
|
1156
|
-
kind: "write",
|
|
1157
|
-
label: raw === null
|
|
1158
|
-
? "Create ~/.hermes/.env pointing the Hermes plugin at the agent key"
|
|
1159
|
-
: "Point the Hermes plugin at the agent key in ~/.hermes/.env (a merge — your other settings are kept)",
|
|
1160
|
-
file: envFile,
|
|
1161
|
-
before: raw,
|
|
1162
|
-
after: merged,
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
|
-
else {
|
|
1166
|
-
notes.push(`${envFile} already points the Hermes plugin at this key — left untouched.`);
|
|
1167
|
-
}
|
|
1168
|
-
const hermesConfig = path.join(home, ".hermes", "config.yaml");
|
|
1169
|
-
const rawConfig = readTextFile(hermesConfig);
|
|
1170
|
-
const withPlugin = mergeHermesEnabledPlugins(rawConfig);
|
|
1171
|
-
if (withPlugin !== rawConfig) {
|
|
1172
|
-
steps.push({
|
|
1173
|
-
kind: "write",
|
|
1174
|
-
label: rawConfig === null
|
|
1175
|
-
? "Create ~/.hermes/config.yaml enabling the plugin"
|
|
1176
|
-
: "Add the plugin to Hermes' enabled allow-list in ~/.hermes/config.yaml",
|
|
1177
|
-
file: hermesConfig,
|
|
1178
|
-
before: rawConfig,
|
|
1179
|
-
after: withPlugin,
|
|
1180
|
-
});
|
|
1181
|
-
notes.push("Hermes loads ONLY plugins on the `plugins.enabled` allow-list, so installing the package is not enough. `hermes plugins enable` cannot add it — that command only recognises plugin directories, not pip-installed entry points — so setup writes the list entry directly.");
|
|
1182
|
-
}
|
|
1183
|
-
else if (rawConfig !== null) {
|
|
1184
|
-
notes.push(`${hermesConfig} already has the plugin on its enabled list — left untouched.`);
|
|
1185
|
-
}
|
|
1186
|
-
// Only a running gateway service can be restarted for them. An interactive
|
|
1187
|
-
// session cannot, and must not be — see hermesGatewayRunning.
|
|
1188
|
-
const restartable = hasExecutable("hermes") && hermesGatewayRunning();
|
|
1189
|
-
// The Python package must land in the interpreter that RUNS Hermes, not
|
|
1190
|
-
// whichever pip the shell happens to resolve. When we can identify that
|
|
1191
|
-
// interpreter we install into it directly; when we cannot, we hand the
|
|
1192
|
-
// command over rather than guess, because guessing wrong installs
|
|
1193
|
-
// successfully and governs nothing.
|
|
1194
|
-
if (!noInstall) {
|
|
1195
|
-
const hermesPython = findHermesPython(home);
|
|
1196
|
-
if (hermesPython) {
|
|
1197
|
-
const spec = `${HERMES_PKG}==${HERMES_VERSION}`;
|
|
1198
|
-
const strategy = pythonInstallStrategy(hermesPython.python, spec);
|
|
1199
|
-
if (strategy.kind === "exec") {
|
|
1200
|
-
steps.push({
|
|
1201
|
-
kind: "exec",
|
|
1202
|
-
label: `Install ${HERMES_PKG} via ${strategy.how} (interpreter found via ${hermesPython.how})`,
|
|
1203
|
-
command: strategy.command,
|
|
1204
|
-
args: strategy.args,
|
|
1205
|
-
});
|
|
1206
|
-
}
|
|
1207
|
-
else {
|
|
1208
|
-
steps.push({
|
|
1209
|
-
kind: "manual",
|
|
1210
|
-
label: "Install the Hermes plugin",
|
|
1211
|
-
detail: strategy.why,
|
|
1212
|
-
snippet: strategy.snippet,
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
// The plugin pins atbash-sdk==0.4.5, which constrains cryptography — on
|
|
1216
|
-
// a venv with a newer one this install is a DOWNGRADE of a shared
|
|
1217
|
-
// dependency. Say so; it is the kind of thing that breaks the host app
|
|
1218
|
-
// and is invisible in a list of file writes.
|
|
1219
|
-
notes.push(`Installing ${HERMES_PKG} may change shared Python dependencies in that venv (it pins atbash-sdk==${HERMES_VERSION}, which constrains cryptography). Check the resolver output before confirming if Hermes depends on newer versions.`);
|
|
1220
|
-
// Installing is not enabling. Hermes keeps plugins off until told
|
|
1221
|
-
// otherwise, so without this the hook is never registered and the
|
|
1222
|
-
// agent is not governed — while every file on disk says it is.
|
|
1223
|
-
// Ordered after the install because `enable` needs the package present.
|
|
1224
|
-
}
|
|
1225
|
-
else {
|
|
1226
|
-
steps.push({
|
|
1227
|
-
kind: "manual",
|
|
1228
|
-
label: "Install the Hermes plugin",
|
|
1229
|
-
detail: [
|
|
1230
|
-
"The `hermes` launcher is not on this machine's PATH, so setup cannot tell which Python interpreter runs Hermes — and installing into the wrong one succeeds while governing nothing.",
|
|
1231
|
-
"",
|
|
1232
|
-
"Run this with the interpreter Hermes uses (if it runs in a virtualenv, that venv's python):",
|
|
1233
|
-
].join("\n"),
|
|
1234
|
-
snippet: `/path/to/hermes/venv/bin/python -m pip install ${HERMES_PKG}==${HERMES_VERSION}`,
|
|
1235
|
-
});
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
if (restartable) {
|
|
1239
|
-
steps.push({
|
|
1240
|
-
kind: "exec",
|
|
1241
|
-
label: "Restart the Hermes gateway so it loads the plugin",
|
|
1242
|
-
command: "hermes",
|
|
1243
|
-
args: ["gateway", "restart"],
|
|
1244
|
-
});
|
|
1245
|
-
}
|
|
1246
|
-
else {
|
|
1247
|
-
notes.push("No Hermes gateway service is running, so there is nothing to restart — the plugin loads the next time you start Hermes. (If you run it as a service, `hermes gateway install` then `hermes gateway restart`.)");
|
|
1248
|
-
}
|
|
1249
|
-
notes.push("Confirm the hook registered after Hermes next starts: `tail -n 50 ~/.hermes/logs/agent.log | grep -i atbash`. `hermes plugins list` will not show it — that listing only walks plugin directories.");
|
|
1250
|
-
notes.push("ATBASH_ENFORCE_DECISION=true is fail-closed: if Atbash cannot be reached, the Hermes tool call is blocked rather than allowed.");
|
|
1251
|
-
notes.push("ATBASH_ORG_NAME is not set for you — it decides which chain the SDK uses, and a wrong value points at the wrong one. Add it to ~/.hermes/.env yourself if your org needs it.");
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
// ── 4. MCP clients.
|
|
1255
|
-
//
|
|
1256
|
-
// This used to be a manual step, and the reason was specific: `@atbash/mcp`
|
|
1257
|
-
// reads its identity from ATBASH_AGENT_PRIVKEY with no key-file fallback, so
|
|
1258
|
-
// the documented wiring puts a raw private key inside the client's own config —
|
|
1259
|
-
// `claude_desktop_config.json` and friends, files that get synced between
|
|
1260
|
-
// machines and pasted into help requests. Automating that would have meant the
|
|
1261
|
-
// automation's whole job was planting a secret somewhere worse.
|
|
1262
|
-
//
|
|
1263
|
-
// `atbash mcp` removes the reason. The client spawns the launcher, which reads
|
|
1264
|
-
// the key from the 0600 file and hands it to the server through the child
|
|
1265
|
-
// environment only. The config entry carries NO credential, so it is safe to
|
|
1266
|
-
// write — and a config with no secret in it is strictly better than the one the
|
|
1267
|
-
// operator would have hand-written from the docs.
|
|
1268
|
-
if (wanted("mcp")) {
|
|
1269
|
-
for (const client of detectMcpClients(home)) {
|
|
1270
|
-
found.push(client.label);
|
|
1271
|
-
if (client.format !== "json") {
|
|
1272
|
-
// TOML (Codex) — @iarna/toml can round-trip values but not comments, and
|
|
1273
|
-
// a config.toml is usually hand-maintained. Print it instead.
|
|
1274
|
-
steps.push({
|
|
1275
|
-
kind: "manual",
|
|
1276
|
-
label: `Add Atbash to ${client.label}`,
|
|
1277
|
-
detail: `${client.file} is TOML, and rewriting it would drop any comments in it. Add this table by hand:`,
|
|
1278
|
-
snippet: ["[mcp_servers.atbash]", 'command = "npx"', 'args = ["--yes", "@atbash/cli", "mcp"]'].join("\n"),
|
|
1279
|
-
});
|
|
1280
|
-
continue;
|
|
1281
|
-
}
|
|
1282
|
-
const raw = readTextFile(client.file);
|
|
1283
|
-
if (raw !== null && isJsonc(raw)) {
|
|
1284
|
-
steps.push({
|
|
1285
|
-
kind: "manual",
|
|
1286
|
-
label: `Add Atbash to ${client.label}`,
|
|
1287
|
-
detail: `${client.file} uses comments or trailing commas, and rewriting it as strict JSON would delete them. Merge this in by hand — note it holds no key, so the file stays as non-secret as it is today:`,
|
|
1288
|
-
snippet: JSON.stringify({ mcpServers: { atbash: MCP_SERVER_ENTRY } }, null, 2),
|
|
1289
|
-
});
|
|
1290
|
-
continue;
|
|
1291
|
-
}
|
|
1292
|
-
let current = {};
|
|
1293
|
-
if (raw !== null) {
|
|
1294
|
-
try {
|
|
1295
|
-
const parsed = JSON.parse(raw);
|
|
1296
|
-
if (isRecord(parsed))
|
|
1297
|
-
current = parsed;
|
|
1298
|
-
}
|
|
1299
|
-
catch {
|
|
1300
|
-
notes.push(`${client.file} is not valid JSON, so it was left alone. Fix the file and re-run to wire ${client.label}.`);
|
|
1301
|
-
continue;
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
const after = serializeLike(raw, mergeMcpServer(current, client.serversKey));
|
|
1305
|
-
if (raw !== after) {
|
|
1306
|
-
// A hand-wired entry from the old documented shape carries the private key
|
|
1307
|
-
// in an `env` block. Replacing it REMOVES that secret from the live config
|
|
1308
|
-
// — good — but the backup we are about to take still contains it, and an
|
|
1309
|
-
// operator who does not know that has simply moved the leak to a new file.
|
|
1310
|
-
if (hadInlineKey(current, client.serversKey)) {
|
|
1311
|
-
notes.push(`${client.file} currently holds your private key in an env block. Setup replaces that entry with the keyless launcher, but the .atbash-bak it leaves behind WILL still contain the key — delete that backup once you have confirmed the client works.`);
|
|
1312
|
-
}
|
|
1313
|
-
steps.push({
|
|
1314
|
-
kind: "write",
|
|
1315
|
-
label: `Add Atbash as an MCP server in ${client.label} (a merge — existing servers are kept${hadInlineKey(current, client.serversKey) ? ", and your key is removed from this file" : ""})`,
|
|
1316
|
-
file: client.file,
|
|
1317
|
-
before: raw,
|
|
1318
|
-
after,
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
else {
|
|
1322
|
-
notes.push(`${client.label} already has the Atbash MCP server — left untouched.`);
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
if (found.some((f) => f !== "OpenClaw" && f !== "Hermes")) {
|
|
1326
|
-
notes.push("Restart any MCP client that was changed — clients read their server list at startup.");
|
|
1327
|
-
// A client whose first launch of the server takes ~12s can report a startup
|
|
1328
|
-
// timeout that looks like a broken config. Say so, so the first thing an
|
|
1329
|
-
// operator does is retry rather than undo the wiring.
|
|
1330
|
-
notes.push("The first time a client starts the Atbash server it takes ~10-15s while npx caches the package; after that it is about a second. If a client reports a startup timeout on the very first try, start it again.");
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
if (!found.length) {
|
|
1334
|
-
notes.push("No OpenClaw, Hermes or MCP client configuration was found under this home directory. The key file is still placed, so an SDK-level integration in your own code will find it — but nothing on this machine is wired to a runtime.");
|
|
1335
|
-
}
|
|
1336
|
-
return { steps, notes, found };
|
|
1337
|
-
}
|
|
1338
|
-
// ── Showing the plan ────────────────────────────────────────────────────────
|
|
1339
|
-
/**
|
|
1340
|
-
* A minimal line diff, so the preview shows what CHANGES rather than dumping a
|
|
1341
|
-
* whole config and leaving the owner to spot the difference. Standard LCS; these
|
|
1342
|
-
* files are small enough that the quadratic table is irrelevant.
|
|
1343
|
-
*/
|
|
1344
|
-
function lineDiff(before, after) {
|
|
1345
|
-
const a = before.split("\n");
|
|
1346
|
-
const b = after.split("\n");
|
|
1347
|
-
const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
1348
|
-
for (let i = a.length - 1; i >= 0; i--) {
|
|
1349
|
-
for (let j = b.length - 1; j >= 0; j--) {
|
|
1350
|
-
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
const out = [];
|
|
1354
|
-
let i = 0;
|
|
1355
|
-
let j = 0;
|
|
1356
|
-
while (i < a.length && j < b.length) {
|
|
1357
|
-
if (a[i] === b[j]) {
|
|
1358
|
-
out.push(` ${a[i]}`);
|
|
1359
|
-
i++;
|
|
1360
|
-
j++;
|
|
1361
|
-
}
|
|
1362
|
-
else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
1363
|
-
out.push(`- ${a[i]}`);
|
|
1364
|
-
i++;
|
|
1365
|
-
}
|
|
1366
|
-
else {
|
|
1367
|
-
out.push(`+ ${b[j]}`);
|
|
1368
|
-
j++;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
for (; i < a.length; i++)
|
|
1372
|
-
out.push(`- ${a[i]}`);
|
|
1373
|
-
for (; j < b.length; j++)
|
|
1374
|
-
out.push(`+ ${b[j]}`);
|
|
1375
|
-
return out;
|
|
1376
|
-
}
|
|
1377
|
-
/** Drop unchanged runs down to a little context, so a long config stays readable. */
|
|
1378
|
-
function condense(diff, context = 2) {
|
|
1379
|
-
const keep = new Set();
|
|
1380
|
-
diff.forEach((line, index) => {
|
|
1381
|
-
if (line.startsWith("+") || line.startsWith("-")) {
|
|
1382
|
-
for (let k = index - context; k <= index + context; k++)
|
|
1383
|
-
if (k >= 0 && k < diff.length)
|
|
1384
|
-
keep.add(k);
|
|
1385
|
-
}
|
|
1386
|
-
});
|
|
1387
|
-
const out = [];
|
|
1388
|
-
let skipping = false;
|
|
1389
|
-
diff.forEach((line, index) => {
|
|
1390
|
-
if (keep.has(index)) {
|
|
1391
|
-
out.push(line);
|
|
1392
|
-
skipping = false;
|
|
1393
|
-
}
|
|
1394
|
-
else if (!skipping) {
|
|
1395
|
-
out.push(chalk_1.default.dim(" …"));
|
|
1396
|
-
skipping = true;
|
|
1397
|
-
}
|
|
1398
|
-
});
|
|
1399
|
-
return out;
|
|
1400
|
-
}
|
|
1401
|
-
/**
|
|
1402
|
-
* Print the plan. Used for `--dry-run` and for the confirmation prompt, so what
|
|
1403
|
-
* the owner is shown and what they agree to cannot diverge.
|
|
1404
|
-
*
|
|
1405
|
-
* The key file's CONTENTS are never printed — the whole point of the file is that
|
|
1406
|
-
* the private key stays put, and echoing it into a terminal scrollback undoes
|
|
1407
|
-
* that. The path, mode and the public key are shown instead.
|
|
1408
|
-
*/
|
|
1409
|
-
function renderPlan(plan, pubkey) {
|
|
1410
|
-
console.log();
|
|
1411
|
-
console.log(chalk_1.default.bold(" Atbash setup"));
|
|
1412
|
-
console.log(chalk_1.default.dim(` Agent public key: ${pubkey}`));
|
|
1413
|
-
console.log(chalk_1.default.dim(` Detected on this machine: ${plan.found.length ? plan.found.join(", ") : "no supported runtime"}`));
|
|
1414
|
-
console.log();
|
|
1415
|
-
const writes = plan.steps.filter((s) => s.kind === "write");
|
|
1416
|
-
const execs = plan.steps.filter((s) => s.kind === "exec");
|
|
1417
|
-
const manuals = plan.steps.filter((s) => s.kind === "manual");
|
|
1418
|
-
if (!writes.length && !execs.length) {
|
|
1419
|
-
console.log(chalk_1.default.green(" Nothing to change — this machine is already wired.") + "\n");
|
|
1420
|
-
}
|
|
1421
|
-
if (writes.length) {
|
|
1422
|
-
console.log(chalk_1.default.bold(` Files (${writes.length})`));
|
|
1423
|
-
for (const step of writes) {
|
|
1424
|
-
console.log(` ${chalk_1.default.cyan(step.file)}${step.mode ? chalk_1.default.dim(` mode ${step.mode.toString(8)}`) : ""}`);
|
|
1425
|
-
console.log(` ${step.label}`);
|
|
1426
|
-
if (step.secret) {
|
|
1427
|
-
// Deliberately not the contents.
|
|
1428
|
-
console.log(chalk_1.default.dim(` Contents: pubkey= and privkey= lines for the agent above. Not printed — it is a private key.`));
|
|
1429
|
-
}
|
|
1430
|
-
else if (step.before === null) {
|
|
1431
|
-
for (const line of step.after.split("\n").slice(0, 40))
|
|
1432
|
-
console.log(chalk_1.default.dim(` + ${line}`));
|
|
1433
|
-
if (step.after.split("\n").length > 40)
|
|
1434
|
-
console.log(chalk_1.default.dim(" …"));
|
|
1435
|
-
}
|
|
1436
|
-
else {
|
|
1437
|
-
for (const line of condense(lineDiff(step.before, step.after))) {
|
|
1438
|
-
const painted = line.startsWith("+") ? chalk_1.default.green(line) : line.startsWith("-") ? chalk_1.default.red(line) : chalk_1.default.dim(line);
|
|
1439
|
-
console.log(` ${painted}`);
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
if (step.before !== null)
|
|
1443
|
-
console.log(chalk_1.default.dim(" The existing file is copied to a .atbash-bak alongside it first."));
|
|
1444
|
-
console.log();
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
if (execs.length) {
|
|
1448
|
-
console.log(chalk_1.default.bold(` Commands (${execs.length})`));
|
|
1449
|
-
for (const step of execs)
|
|
1450
|
-
console.log(` ${chalk_1.default.cyan(`${step.command} ${step.args.join(" ")}`)}\n ${step.label}`);
|
|
1451
|
-
console.log();
|
|
1452
|
-
}
|
|
1453
|
-
if (manuals.length) {
|
|
1454
|
-
console.log(chalk_1.default.bold(` For you to do (${manuals.length})`));
|
|
1455
|
-
for (const step of manuals) {
|
|
1456
|
-
console.log(` ${chalk_1.default.yellow("•")} ${chalk_1.default.bold(step.label)}`);
|
|
1457
|
-
for (const line of step.detail.split("\n"))
|
|
1458
|
-
console.log(` ${chalk_1.default.dim(line)}`);
|
|
1459
|
-
if (step.snippet)
|
|
1460
|
-
for (const line of step.snippet.split("\n"))
|
|
1461
|
-
console.log(chalk_1.default.dim(` ${line}`));
|
|
1462
|
-
console.log();
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
if (plan.notes.length) {
|
|
1466
|
-
console.log(chalk_1.default.bold(" Notes"));
|
|
1467
|
-
for (const note of plan.notes)
|
|
1468
|
-
console.log(` ${chalk_1.default.dim("•")} ${chalk_1.default.dim(note)}`);
|
|
1469
|
-
console.log();
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
// ── Doing it ────────────────────────────────────────────────────────────────
|
|
1473
|
-
/**
|
|
1474
|
-
* Copy a file aside before overwriting it, without ever clobbering an existing
|
|
1475
|
-
* backup — a second run must not overwrite the pristine copy from the first.
|
|
1476
|
-
*/
|
|
1477
|
-
function backupFile(file) {
|
|
1478
|
-
if (!fs.existsSync(file))
|
|
1479
|
-
return null;
|
|
1480
|
-
let target = `${file}.atbash-bak`;
|
|
1481
|
-
let n = 1;
|
|
1482
|
-
while (fs.existsSync(target))
|
|
1483
|
-
target = `${file}.atbash-bak.${n++}`;
|
|
1484
|
-
fs.copyFileSync(file, target);
|
|
1485
|
-
return target;
|
|
1486
|
-
}
|
|
1487
|
-
/** Execute the plan. Writes first, then commands, so a failed install still
|
|
1488
|
-
* leaves a correct config and key file behind for a manual retry. */
|
|
1489
|
-
function applyPlan(plan) {
|
|
1490
|
-
const result = { written: [], backups: [], ran: [], failures: [] };
|
|
1491
|
-
for (const step of plan.steps) {
|
|
1492
|
-
if (step.kind !== "write")
|
|
1493
|
-
continue;
|
|
1494
|
-
try {
|
|
1495
|
-
const backup = backupFile(step.file);
|
|
1496
|
-
if (backup)
|
|
1497
|
-
result.backups.push(backup);
|
|
1498
|
-
fs.mkdirSync(path.dirname(step.file), { recursive: true, mode: step.mode === KEY_MODE ? DIR_MODE : undefined });
|
|
1499
|
-
fs.writeFileSync(step.file, step.after, step.mode ? { mode: step.mode } : {});
|
|
1500
|
-
// writeFileSync's mode is ignored for a file that already existed, so
|
|
1501
|
-
// assert it explicitly — a key file at 0644 is the failure this guards.
|
|
1502
|
-
if (step.mode)
|
|
1503
|
-
fs.chmodSync(step.file, step.mode);
|
|
1504
|
-
result.written.push(step.file);
|
|
1505
|
-
}
|
|
1506
|
-
catch (err) {
|
|
1507
|
-
result.failures.push(`${step.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
for (const step of plan.steps) {
|
|
1511
|
-
if (step.kind !== "exec")
|
|
1512
|
-
continue;
|
|
1513
|
-
const label = `${step.command} ${step.args.join(" ")}`;
|
|
1514
|
-
const run = (0, child_process_1.spawnSync)(step.command, step.args, { stdio: "inherit" });
|
|
1515
|
-
// Three distinct outcomes, and they used to collapse into one misleading
|
|
1516
|
-
// message. `spawnSync` reports a binary it could not launch via `.error` with
|
|
1517
|
-
// `status` left null — so an ENOENT printed "exited on a signal", which reads
|
|
1518
|
-
// like the plugin installer crashed rather than "that command is not here".
|
|
1519
|
-
// The distinction matters because only one of them is the operator's to fix,
|
|
1520
|
-
// and the fix is to run it somewhere the CLI exists.
|
|
1521
|
-
if (run.error) {
|
|
1522
|
-
const missing = run.error.code === "ENOENT";
|
|
1523
|
-
result.failures.push(missing
|
|
1524
|
-
? `${step.command} is not on this machine's PATH, so \`${label}\` did not run. Everything else above was applied — run that one command wherever the ${step.command} CLI lives.`
|
|
1525
|
-
: `${label} could not start: ${run.error.message}`);
|
|
1526
|
-
}
|
|
1527
|
-
else if (run.status === 0) {
|
|
1528
|
-
result.ran.push(label);
|
|
1529
|
-
}
|
|
1530
|
-
else if (run.signal) {
|
|
1531
|
-
result.failures.push(`${label} was killed by ${run.signal}`);
|
|
1532
|
-
}
|
|
1533
|
-
else {
|
|
1534
|
-
result.failures.push(`${label} exited with code ${run.status}`);
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
return result;
|
|
1538
|
-
}
|
|
1539
|
-
// ── Registration check ──────────────────────────────────────────────────────
|
|
1540
|
-
/**
|
|
1541
|
-
* Confirm the agent this key belongs to is actually registered.
|
|
1542
|
-
*
|
|
1543
|
-
* Wiring a runtime to an unregistered agent produces the worst outcome available:
|
|
1544
|
-
* a machine that looks governed, with a plugin that cannot get a verdict. The
|
|
1545
|
-
* check sends only the PUBLIC key (GET /api/ai/exists), never the private one.
|
|
1546
|
-
*/
|
|
1547
|
-
async function verifyRegistration(privkey, endpoint) {
|
|
1548
|
-
try {
|
|
1549
|
-
const atbash = new sdk_1.Atbash(privkey, { endpoint });
|
|
1550
|
-
return (await atbash.checkAgentExists()) ? { state: "registered" } : { state: "unregistered" };
|
|
1551
|
-
}
|
|
1552
|
-
catch (err) {
|
|
1553
|
-
return { state: "unknown", reason: err instanceof Error ? err.message : String(err) };
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
/**
|
|
1557
|
-
* Confirmation prompt. Defaults to NO — anything but an explicit yes is a no —
|
|
1558
|
-
* except where `defaultYes` is set, which is only for questions where the safe
|
|
1559
|
-
* answer is also the common one (reusing the key already on this machine).
|
|
1560
|
-
*/
|
|
1561
|
-
async function confirm(question, defaultYes = false) {
|
|
1562
|
-
const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
|
|
1563
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1564
|
-
const answer = await new Promise((r) => rl.question(question, (a) => { rl.close(); r(a); }));
|
|
1565
|
-
const trimmed = answer.trim();
|
|
1566
|
-
if (!trimmed)
|
|
1567
|
-
return defaultYes;
|
|
1568
|
-
return /^y(es)?$/i.test(trimmed);
|
|
1569
|
-
}
|
|
1570
|
-
// ── The command ─────────────────────────────────────────────────────────────
|
|
1571
|
-
function registerSetupCommand(program) {
|
|
1572
|
-
program
|
|
1573
|
-
.command("setup")
|
|
1574
|
-
.description("Wire this machine's runtime to a registered Atbash agent: place the agent key, install the plugin and merge its config (WRITES to your machine — use --dry-run first)")
|
|
1575
|
-
.option("-k, --key <privkey>", "Agent private key (64 hex). Convenient, but it lands in your shell history — prefer the prompt or --key-file")
|
|
1576
|
-
.option("--key-file <path>", "Read the key from a file (the agent-keys-*.txt from onboarding, or an existing guard-client-key)")
|
|
1577
|
-
.option("--keys-dir <dir>", "Directory holding the key file, e.g. ~/Downloads")
|
|
1578
|
-
.option("--host <url>", "Atbash deployment to check the agent's registration against")
|
|
1579
|
-
.option("--runtime <ids...>", "Only configure these runtimes (currently: openclaw)")
|
|
1580
|
-
.option("--dry-run", "Show exactly which files would change, and the diffs, then exit WITHOUT writing anything")
|
|
1581
|
-
.option("-y, --yes", "Do not ask for confirmation before writing")
|
|
1582
|
-
.option("--no-install", "Do not install any package; write the key file and configs only")
|
|
1583
|
-
.option("--skip-verify", "Do not check the agent's registration (no network calls at all)")
|
|
1584
|
-
.option("--allow-unrecognized-host", "Permit a --host that is not a known Atbash deployment")
|
|
1585
|
-
.option("--home <dir>", "Home directory to configure (for testing)")
|
|
1586
|
-
.action(async (opts) => {
|
|
1587
|
-
const home = opts.home || process.env.HOME || os.homedir();
|
|
1588
|
-
const dryRun = !!opts.dryRun;
|
|
1589
|
-
// A key on argv is in the shell history and in `ps` output. Say so once,
|
|
1590
|
-
// rather than silently accepting the convenient-but-leaky path.
|
|
1591
|
-
if (opts.key) {
|
|
1592
|
-
console.log(chalk_1.default.yellow("\n Note: a key passed with --key is recorded in your shell history.") +
|
|
1593
|
-
chalk_1.default.dim("\n Clear it afterwards, or re-run without --key and paste it at the hidden prompt."));
|
|
1594
|
-
}
|
|
1595
|
-
const keySource = await resolveKeySource({
|
|
1596
|
-
key: opts.key,
|
|
1597
|
-
keyFile: opts.keyFile,
|
|
1598
|
-
keysDir: opts.keysDir,
|
|
1599
|
-
home,
|
|
1600
|
-
allowPrompt: process.stdin.isTTY === true,
|
|
1601
|
-
});
|
|
1602
|
-
if ("error" in keySource) {
|
|
1603
|
-
console.error(chalk_1.default.red(`\n${keySource.error}\n`));
|
|
1604
|
-
process.exit(1);
|
|
1605
|
-
}
|
|
1606
|
-
const privkey = keySource.material.privkey;
|
|
1607
|
-
const pubkey = (0, sdk_1.derivePublicKey)(privkey);
|
|
1608
|
-
// A key file whose stated pubkey does not match the private key is either
|
|
1609
|
-
// corrupt or two different keypairs spliced together. Either way, wiring a
|
|
1610
|
-
// runtime with it produces signatures nobody can attribute.
|
|
1611
|
-
const stated = keySource.material.statedPubkey?.replace(/^0x/i, "").toLowerCase();
|
|
1612
|
-
if (stated && stated !== pubkey.toLowerCase()) {
|
|
1613
|
-
console.error(chalk_1.default.red("\n The key file's `pubkey` does not match the key derived from its `privkey`.") +
|
|
1614
|
-
chalk_1.default.dim(`\n File says: ${stated}\n Derived: ${pubkey}\n Fix the file (or re-download it) before wiring anything.\n`));
|
|
1615
|
-
process.exit(1);
|
|
1616
|
-
}
|
|
1617
|
-
console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
|
|
1618
|
-
// ── Registration check. Only the public key crosses the network.
|
|
1619
|
-
if (!opts.skipVerify) {
|
|
1620
|
-
const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || atbash_targets_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
1621
|
-
let hostname = "";
|
|
1622
|
-
try {
|
|
1623
|
-
hostname = new URL(endpoint).hostname.toLowerCase();
|
|
1624
|
-
}
|
|
1625
|
-
catch {
|
|
1626
|
-
console.error(chalk_1.default.red(`\n --host is not a valid URL: ${endpoint}\n`));
|
|
1627
|
-
process.exit(1);
|
|
1628
|
-
}
|
|
1629
|
-
// An unrecognized host could answer "registered" for any key, which is
|
|
1630
|
-
// exactly the confirmation this check exists to provide. Exact hostname
|
|
1631
|
-
// match, never a suffix — "atbash.ai.evil.com" must not pass.
|
|
1632
|
-
const recognizedHost = atbash_targets_1.KNOWN_HOSTS.has(hostname);
|
|
1633
|
-
if (!recognizedHost && !opts.allowUnrecognizedHost) {
|
|
1634
|
-
console.error(chalk_1.default.red(`\n ${hostname} is not a recognized Atbash deployment.`) +
|
|
1635
|
-
chalk_1.default.dim("\n Re-run with --allow-unrecognized-host if you meant to point at a self-hosted instance,\n or with --skip-verify to configure this machine without any network call.\n"));
|
|
1636
|
-
process.exit(1);
|
|
1637
|
-
}
|
|
1638
|
-
const verdict = await verifyRegistration(privkey, endpoint);
|
|
1639
|
-
if (verdict.state === "unregistered") {
|
|
1640
|
-
console.error(chalk_1.default.red("\n That agent is not registered on this deployment.") +
|
|
1641
|
-
chalk_1.default.dim(`\n Public key: ${pubkey}\n Key came from: ${keySource.from}\n`));
|
|
1642
|
-
// The likeliest cause is not "you skipped onboarding" — it is "this is a
|
|
1643
|
-
// key you did not choose". Say so when the key was found rather than
|
|
1644
|
-
// supplied, because the fix is completely different.
|
|
1645
|
-
if (/existing key file|atbash config/.test(keySource.from)) {
|
|
1646
|
-
console.error(chalk_1.default.yellow(" This is a key that was already on this machine, not one you supplied.") +
|
|
1647
|
-
chalk_1.default.dim("\n If you are onboarding a DIFFERENT agent, pass its key explicitly:" +
|
|
1648
|
-
"\n --key <64-hex> the key shown in the browser" +
|
|
1649
|
-
"\n --keys-dir ~/Downloads the key file you saved" +
|
|
1650
|
-
"\n Or re-run and answer 'n' when asked whether to use the existing key.\n"));
|
|
1651
|
-
}
|
|
1652
|
-
else {
|
|
1653
|
-
console.error(chalk_1.default.dim(" Finish onboarding first — wiring a runtime to an unregistered agent leaves it\n" +
|
|
1654
|
-
" looking governed while the plugin can never get a verdict.\n"));
|
|
1655
|
-
}
|
|
1656
|
-
process.exit(1);
|
|
1657
|
-
}
|
|
1658
|
-
if (verdict.state === "unknown") {
|
|
1659
|
-
console.log(chalk_1.default.yellow(`\n Could not confirm the agent's registration (${verdict.reason}).`));
|
|
1660
|
-
console.log(chalk_1.default.dim(" The wiring below is still correct, but nothing has verified that this agent exists."));
|
|
1661
|
-
if (!opts.yes && !dryRun && process.stdin.isTTY && !(await confirm(" Continue anyway? [y/N] "))) {
|
|
1662
|
-
console.log(chalk_1.default.dim("\n Nothing was changed.\n"));
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
else if (recognizedHost) {
|
|
1667
|
-
console.log(chalk_1.default.green(` Agent is registered on ${hostname}.`));
|
|
1668
|
-
}
|
|
1669
|
-
else {
|
|
1670
|
-
// --allow-unrecognized-host is a real bypass, and its most dangerous
|
|
1671
|
-
// property is that the check still PRINTS a reassuring answer. A host
|
|
1672
|
-
// chosen by an attacker returns "registered" for any key at all, so a
|
|
1673
|
-
// "✓ registered" line here would be the attacker's own claim wearing
|
|
1674
|
-
// Atbash's voice. Never let that line stand unqualified: say the answer
|
|
1675
|
-
// came from an unvouched-for server, so a talked-into-it operator sees
|
|
1676
|
-
// the one thing that would tell them something is wrong.
|
|
1677
|
-
console.log(chalk_1.default.yellow(` ${hostname} answered "registered" — but this is NOT a recognized Atbash deployment.`));
|
|
1678
|
-
console.log(chalk_1.default.yellow(" A registration check against an unrecognized host proves nothing: any server") +
|
|
1679
|
-
chalk_1.default.yellow("\n can answer \"registered\" for any key. Treat this as UNVERIFIED."));
|
|
1680
|
-
console.log(chalk_1.default.dim(` Recognized deployments: ${[...atbash_targets_1.KNOWN_HOSTS].join(", ")}`));
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
// ── Plan, show, then (maybe) apply.
|
|
1684
|
-
const plan = buildPlan({
|
|
1685
|
-
home,
|
|
1686
|
-
privkey,
|
|
1687
|
-
pubkey,
|
|
1688
|
-
noInstall: opts.install === false,
|
|
1689
|
-
only: opts.runtime ?? [],
|
|
1690
|
-
});
|
|
1691
|
-
renderPlan(plan, pubkey);
|
|
1692
|
-
const changes = plan.steps.filter((s) => s.kind === "write" || s.kind === "exec");
|
|
1693
|
-
if (dryRun) {
|
|
1694
|
-
console.log(chalk_1.default.green(" Dry run — nothing was written.") +
|
|
1695
|
-
chalk_1.default.dim(" Re-run without --dry-run to apply.\n"));
|
|
1696
|
-
return;
|
|
1697
|
-
}
|
|
1698
|
-
if (!changes.length) {
|
|
1699
|
-
console.log(chalk_1.default.dim(" Nothing to apply.\n"));
|
|
1700
|
-
return;
|
|
1701
|
-
}
|
|
1702
|
-
if (!opts.yes) {
|
|
1703
|
-
if (!process.stdin.isTTY) {
|
|
1704
|
-
console.error(chalk_1.default.red(" Refusing to write without confirmation.") +
|
|
1705
|
-
chalk_1.default.dim(" Re-run with --yes (or --dry-run to preview).\n"));
|
|
1706
|
-
process.exit(1);
|
|
1707
|
-
}
|
|
1708
|
-
if (!(await confirm(` Apply ${changes.length} change${changes.length === 1 ? "" : "s"} to this machine? [y/N] `))) {
|
|
1709
|
-
console.log(chalk_1.default.dim("\n Nothing was changed.\n"));
|
|
1710
|
-
return;
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
1713
|
-
const result = applyPlan(plan);
|
|
1714
|
-
console.log();
|
|
1715
|
-
for (const file of result.written)
|
|
1716
|
-
console.log(chalk_1.default.green(` ✓ wrote ${file}`));
|
|
1717
|
-
for (const file of result.backups)
|
|
1718
|
-
console.log(chalk_1.default.dim(` backup: ${file}`));
|
|
1719
|
-
for (const cmd of result.ran)
|
|
1720
|
-
console.log(chalk_1.default.green(` ✓ ran ${cmd}`));
|
|
1721
|
-
for (const failure of result.failures)
|
|
1722
|
-
console.log(chalk_1.default.red(` ✗ ${failure}`));
|
|
1723
|
-
if (result.failures.length) {
|
|
1724
|
-
console.log(chalk_1.default.yellow("\n Finished with failures — this machine is NOT fully wired.") +
|
|
1725
|
-
chalk_1.default.dim("\n Everything that did succeed is listed above; the steps that failed can be re-run.\n"));
|
|
1726
|
-
process.exitCode = 1;
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
// ── Check, do not assume.
|
|
1730
|
-
//
|
|
1731
|
-
// Every step reporting success is not the same as the runtime having
|
|
1732
|
-
// picked the plugin up, and this command used to print "Done" on the
|
|
1733
|
-
// strength of the former. On a real machine the install succeeded, the
|
|
1734
|
-
// entry point was correct, and Hermes still listed nothing — so the agent
|
|
1735
|
-
// was ungoverned behind a green summary. Where a runtime can be asked, ask.
|
|
1736
|
-
if (plan.found.includes("Hermes") && wantedRuntime("hermes", opts.runtime ?? [])) {
|
|
1737
|
-
const state = hermesPluginState(home);
|
|
1738
|
-
if (state === "configured") {
|
|
1739
|
-
console.log(chalk_1.default.green("\n Hermes is configured to load the plugin.") +
|
|
1740
|
-
chalk_1.default.dim("\n It takes effect on the next Hermes start. Confirm the hook registered:") +
|
|
1741
|
-
chalk_1.default.dim("\n tail -n 50 ~/.hermes/logs/agent.log | grep -i atbash") +
|
|
1742
|
-
chalk_1.default.dim("\n (`hermes plugins list` will NOT show it — that listing only walks plugin") +
|
|
1743
|
-
chalk_1.default.dim("\n directories and cannot see a pip-installed one.)\n"));
|
|
1744
|
-
}
|
|
1745
|
-
else if (state === "not-enabled") {
|
|
1746
|
-
console.log(chalk_1.default.yellow("\n The plugin is installed but NOT on Hermes' enabled allow-list — this agent is not governed.") +
|
|
1747
|
-
chalk_1.default.dim("\n Hermes loads only what is listed. Add it to ~/.hermes/config.yaml:") +
|
|
1748
|
-
chalk_1.default.dim("\n plugins:\n enabled:\n - " + HERMES_PLUGIN_ENTRY + "\n"));
|
|
1749
|
-
process.exitCode = 1;
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
else if (state === "not-installed") {
|
|
1753
|
-
console.log(chalk_1.default.yellow("\n The plugin is not installed in the interpreter that runs Hermes — this agent is not governed.\n"));
|
|
1754
|
-
process.exitCode = 1;
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
// "unknown": nothing readable to judge by, so claim nothing.
|
|
1758
|
-
}
|
|
1759
|
-
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"));
|
|
1760
|
-
console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
//# sourceMappingURL=setup.js.map
|