@atbash/cli 0.5.15 → 0.6.0-dev.1
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 +338 -0
- package/dist/bin/atbash.js +34 -2
- package/dist/bin/atbash.js.map +1 -1
- package/dist/commands/connect.d.ts +55 -1
- package/dist/commands/connect.js +11 -8
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/github-scan.d.ts +104 -0
- package/dist/commands/github-scan.js +1054 -0
- package/dist/commands/github-scan.js.map +1 -0
- package/dist/commands/held.js +7 -2
- package/dist/commands/held.js.map +1 -1
- package/dist/commands/history.js +4 -1
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/judge-options.d.ts +21 -0
- package/dist/commands/judge-options.js +24 -0
- package/dist/commands/judge-options.js.map +1 -0
- package/dist/commands/judge.js +4 -1
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/policy.js +4 -1
- package/dist/commands/policy.js.map +1 -1
- package/dist/commands/scan.d.ts +45 -0
- package/dist/commands/scan.js +301 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/setup.d.ts +151 -0
- package/dist/commands/setup.js +944 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/stats.js +3 -1
- package/dist/commands/stats.js.map +1 -1
- package/dist/commands/status.js +4 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/tier.js +4 -1
- package/dist/commands/tier.js.map +1 -1
- package/dist/commands/tools.js +11 -4
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/whoami.js +4 -1
- package/dist/commands/whoami.js.map +1 -1
- package/dist/lib/grade-llm.d.ts +38 -0
- package/dist/lib/grade-llm.js +64 -0
- package/dist/lib/grade-llm.js.map +1 -0
- package/dist/lib/llm.d.ts +34 -0
- package/dist/lib/llm.js +101 -0
- package/dist/lib/llm.js.map +1 -0
- package/dist/lib/policy.d.ts +40 -0
- package/dist/lib/policy.js +94 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/risk.d.ts +112 -0
- package/dist/lib/risk.js +288 -0
- package/dist/lib/risk.js.map +1 -0
- package/dist/lib/threats-llm.d.ts +39 -0
- package/dist/lib/threats-llm.js +72 -0
- package/dist/lib/threats-llm.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,944 @@
|
|
|
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.buildPlan = buildPlan;
|
|
47
|
+
exports.lineDiff = lineDiff;
|
|
48
|
+
exports.renderPlan = renderPlan;
|
|
49
|
+
exports.backupFile = backupFile;
|
|
50
|
+
exports.applyPlan = applyPlan;
|
|
51
|
+
exports.registerSetupCommand = registerSetupCommand;
|
|
52
|
+
const fs = __importStar(require("fs"));
|
|
53
|
+
const os = __importStar(require("os"));
|
|
54
|
+
const path = __importStar(require("path"));
|
|
55
|
+
const child_process_1 = require("child_process");
|
|
56
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
57
|
+
const jsonc = __importStar(require("jsonc-parser"));
|
|
58
|
+
const sdk_1 = require("@atbash/sdk");
|
|
59
|
+
const connect_1 = require("./connect");
|
|
60
|
+
/**
|
|
61
|
+
* `atbash setup` — the write half of onboarding.
|
|
62
|
+
*
|
|
63
|
+
* Onboarding registers an agent on chain and issues its certificate. None of
|
|
64
|
+
* that governs anything until the runtime on the agent's machine is pointed at
|
|
65
|
+
* Atbash, and that step used to be four manual operations: move a downloaded
|
|
66
|
+
* key file, install a plugin, hand-merge a JSON block into an existing config
|
|
67
|
+
* "without erasing the existing configurations", restart the gateway. The merge
|
|
68
|
+
* is the one people get wrong, and getting it wrong means an agent that reports
|
|
69
|
+
* registered while enforcing nothing.
|
|
70
|
+
*
|
|
71
|
+
* This command does those steps.
|
|
72
|
+
*
|
|
73
|
+
* ── RELATIONSHIP TO `atbash connect` ─────────────────────────────────────────
|
|
74
|
+
* `connect` is READ-ONLY and must stay that way: the onboarding page promises
|
|
75
|
+
* "installs nothing, changes nothing", and a cautious agent asked to run it is
|
|
76
|
+
* right to verify that. `setup` is the separate, explicitly-named command that
|
|
77
|
+
* writes. Never move write behavior into `connect`, and never describe `setup`
|
|
78
|
+
* with `connect`'s copy.
|
|
79
|
+
*
|
|
80
|
+
* ── WHAT IT WILL NOT DO ──────────────────────────────────────────────────────
|
|
81
|
+
* 1. It never sends the private key anywhere. The key is read locally, written
|
|
82
|
+
* locally, and used locally to derive a public key. The only network call is
|
|
83
|
+
* an optional registration check that transmits the PUBLIC key.
|
|
84
|
+
* 2. It never writes the private key into an MCP client config. The documented
|
|
85
|
+
* `@atbash/mcp` wiring passes the key as an `ATBASH_AGENT_PRIVKEY` env value
|
|
86
|
+
* inside e.g. `claude_desktop_config.json` — a file people screenshot, sync
|
|
87
|
+
* and share. That package has no key-file fallback today (verified against
|
|
88
|
+
* the published 0.1.3), so MCP clients are REPORTED with the snippet to add
|
|
89
|
+
* by hand rather than silently seeded with a secret.
|
|
90
|
+
* 3. It never edits application source. Code-level integrations (LangChain,
|
|
91
|
+
* LangGraph, AutoGen, Eliza, the SDK boundary) are the owner's to write.
|
|
92
|
+
* 4. It never rewrites a config file that uses comments or trailing commas.
|
|
93
|
+
* Reserializing JSONC as JSON silently deletes the owner's comments, so
|
|
94
|
+
* those files fall back to a printed snippet.
|
|
95
|
+
*
|
|
96
|
+
* ── NOTHING HERE IS INVENTED ─────────────────────────────────────────────────
|
|
97
|
+
* The plugin package name, the entry key it registers as, the config field names
|
|
98
|
+
* and the key-file format are all taken from the published integration docs. A
|
|
99
|
+
* plausible-looking config for a plugin that does not exist is worse than no
|
|
100
|
+
* config: it sends someone editing files for a package they cannot install.
|
|
101
|
+
*/
|
|
102
|
+
// ── The canonical resting place for the agent key ───────────────────────────
|
|
103
|
+
// Every Atbash integration reads this path, and `@atbash/sdk`'s resolveKeyPath
|
|
104
|
+
// defaults to it. Whichever way the key arrives — pasted inline, read out of the
|
|
105
|
+
// browser download, already present — it ends up here once, and every runtime
|
|
106
|
+
// config we write REFERENCES this path rather than embedding the secret again.
|
|
107
|
+
const KEY_FILE_REL = [".config", "atbash", "guard-client-key"];
|
|
108
|
+
const OPENCLAW_CONFIG_REL = [".openclaw", "openclaw.json"];
|
|
109
|
+
const OPENCLAW_EXTENSIONS_REL = [".openclaw", "extensions"];
|
|
110
|
+
const HERMES_AGENT_REL = [".hermes", "hermes-agent"];
|
|
111
|
+
/**
|
|
112
|
+
* The OpenClaw plugin, and the entry key it registers itself as.
|
|
113
|
+
*
|
|
114
|
+
* The entry key really is `openclaw` — that is what `@atbash/atbash-openclaw`
|
|
115
|
+
* registers as, not a copy-paste slip. Installs of the earlier
|
|
116
|
+
* `@atbash/atbash-plugin` register under `atbash-plugin`, and the dashboard's
|
|
117
|
+
* capability scan recognizes BOTH. So an existing config carrying the legacy key
|
|
118
|
+
* is already governed and must not be given a second, duplicate entry.
|
|
119
|
+
*/
|
|
120
|
+
const OPENCLAW_PKG = "@atbash/atbash-openclaw";
|
|
121
|
+
const OPENCLAW_ENTRY = "openclaw";
|
|
122
|
+
const OPENCLAW_LEGACY_ENTRY = "atbash-plugin";
|
|
123
|
+
/** File modes: 0700 for the key directory, 0600 for the key itself. */
|
|
124
|
+
const DIR_MODE = 0o700;
|
|
125
|
+
const KEY_MODE = 0o600;
|
|
126
|
+
// ── small filesystem helpers (best-effort; never throw) ─────────────────────
|
|
127
|
+
const exists = (...segs) => {
|
|
128
|
+
try {
|
|
129
|
+
return fs.existsSync(path.join(...segs));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const readTextFile = (file) => {
|
|
136
|
+
try {
|
|
137
|
+
return fs.readFileSync(file, "utf8");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Expand a leading `~/`. `--key-file ~/Downloads/keys.txt` typed inside quotes
|
|
145
|
+
* reaches us unexpanded, and failing on it would look like a missing file.
|
|
146
|
+
*/
|
|
147
|
+
function expandHome(p, home) {
|
|
148
|
+
if (p === "~")
|
|
149
|
+
return home;
|
|
150
|
+
if (p.startsWith("~/"))
|
|
151
|
+
return path.join(home, p.slice(2));
|
|
152
|
+
return p;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Pull key material out of whatever the owner pointed us at.
|
|
156
|
+
*
|
|
157
|
+
* Accepts every shape Atbash itself produces or documents, so "the file I
|
|
158
|
+
* downloaded from the modal" always works:
|
|
159
|
+
* - `privkey=…` / `pubkey=…` lines (what onboarding downloads, and what the
|
|
160
|
+
* plugin parses)
|
|
161
|
+
* - `{"privKey":…,"pubKey":…}` JSON (the alternate documented key-file form)
|
|
162
|
+
* - a bare 64-hex private key on its own line (someone who copied just the key)
|
|
163
|
+
*
|
|
164
|
+
* Returns null rather than throwing: the caller tries several sources in turn and
|
|
165
|
+
* an unparseable one is a reason to move on, not to abort.
|
|
166
|
+
*/
|
|
167
|
+
function parseKeyMaterial(raw) {
|
|
168
|
+
const text = raw.trim();
|
|
169
|
+
if (!text)
|
|
170
|
+
return null;
|
|
171
|
+
if (text.startsWith("{")) {
|
|
172
|
+
try {
|
|
173
|
+
const o = JSON.parse(text);
|
|
174
|
+
const priv = String(o.privKey ?? o.privkey ?? o.privateKey ?? "").trim();
|
|
175
|
+
const pub = String(o.pubKey ?? o.pubkey ?? o.publicKey ?? "").trim();
|
|
176
|
+
const clean = normalizePrivkey(priv);
|
|
177
|
+
return clean ? { privkey: clean, statedPubkey: pub || undefined } : null;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let priv = "";
|
|
184
|
+
let pub = "";
|
|
185
|
+
for (const line of text.split(/\r?\n/)) {
|
|
186
|
+
const trimmed = line.trim();
|
|
187
|
+
if (trimmed.startsWith("#") || !trimmed)
|
|
188
|
+
continue;
|
|
189
|
+
// Tolerate `privkey = x` and `privkey: x` — hand-edited files drift.
|
|
190
|
+
const kv = trimmed.match(/^(privkey|private_key|privatekey|pubkey|public_key|publickey)\s*[:=]\s*(.+)$/i);
|
|
191
|
+
if (kv) {
|
|
192
|
+
const value = kv[2].trim();
|
|
193
|
+
if (/^p(riv|rivate)/i.test(kv[1]))
|
|
194
|
+
priv = value;
|
|
195
|
+
else
|
|
196
|
+
pub = value;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// A bare key on its own line — only if we have not already found a labelled one.
|
|
200
|
+
if (!priv && /^(0x)?[0-9a-fA-F]{64}$/.test(trimmed))
|
|
201
|
+
priv = trimmed;
|
|
202
|
+
}
|
|
203
|
+
const clean = normalizePrivkey(priv);
|
|
204
|
+
return clean ? { privkey: clean, statedPubkey: pub || undefined } : null;
|
|
205
|
+
}
|
|
206
|
+
/** Normalize to the lowercase 64-hex the SDK validates, or "" if it is not one. */
|
|
207
|
+
function normalizePrivkey(raw) {
|
|
208
|
+
const clean = raw.replace(/^0x/i, "").trim().toLowerCase();
|
|
209
|
+
return (0, sdk_1.isValidPrivateKey)(clean) ? clean : "";
|
|
210
|
+
}
|
|
211
|
+
/** Files in a directory that plausibly hold an Atbash agent key, newest first. */
|
|
212
|
+
function keyCandidatesInDir(dir) {
|
|
213
|
+
let names;
|
|
214
|
+
try {
|
|
215
|
+
names = fs.readdirSync(dir);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const matches = names.filter((n) => n === "guard-client-key" ||
|
|
221
|
+
/^agent-keys-.*\.txt$/i.test(n) ||
|
|
222
|
+
/^atbash.*(key|keys).*\.(txt|json)$/i.test(n));
|
|
223
|
+
return matches
|
|
224
|
+
.map((n) => path.join(dir, n))
|
|
225
|
+
.map((file) => {
|
|
226
|
+
let mtime = 0;
|
|
227
|
+
try {
|
|
228
|
+
mtime = fs.statSync(file).mtimeMs;
|
|
229
|
+
}
|
|
230
|
+
catch { /* unreadable — sorts last */ }
|
|
231
|
+
return { file, mtime };
|
|
232
|
+
})
|
|
233
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
234
|
+
.map((e) => e.file);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Read a secret from the terminal without echoing it.
|
|
238
|
+
*
|
|
239
|
+
* Prompting exists so the key does not have to appear in the command line: an
|
|
240
|
+
* inline `--key` is convenient (it is what makes a one-line command copied out of
|
|
241
|
+
* the browser work) but it lands in shell history and in the process list. When
|
|
242
|
+
* there is a TTY and no key was supplied, ask instead.
|
|
243
|
+
*
|
|
244
|
+
* The prompt deliberately accepts a PATH as well as a key, because "where are
|
|
245
|
+
* your keys?" and "paste your key" are the same question from the owner's side.
|
|
246
|
+
*/
|
|
247
|
+
async function promptForKeyOrPath() {
|
|
248
|
+
const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
|
|
249
|
+
return new Promise((resolveP) => {
|
|
250
|
+
const input = process.stdin;
|
|
251
|
+
const rl = createInterface({ input, output: process.stdout, terminal: true });
|
|
252
|
+
// Mute the echo so a pasted key does not sit on screen (or in a scrollback
|
|
253
|
+
// buffer that gets screenshotted). A pasted path is muted too; that is a
|
|
254
|
+
// small cost against leaving a private key visible.
|
|
255
|
+
const asMutable = rl;
|
|
256
|
+
const prompt = "Paste the agent's private key, or the path to its key file: ";
|
|
257
|
+
asMutable._writeToOutput = function (s) {
|
|
258
|
+
if (s.includes(prompt))
|
|
259
|
+
asMutable.output?.write(s);
|
|
260
|
+
else if (s === "\r\n" || s === "\n")
|
|
261
|
+
asMutable.output?.write(s);
|
|
262
|
+
// every other keystroke echo is dropped
|
|
263
|
+
};
|
|
264
|
+
rl.question(prompt, (answer) => { rl.close(); resolveP(answer.trim()); });
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Find the agent key, trying every way an owner could plausibly have it.
|
|
269
|
+
*
|
|
270
|
+
* Order is "most explicit first": a flag the owner typed beats a file we guessed
|
|
271
|
+
* at. The last resort is the interactive prompt, and if there is no TTY the
|
|
272
|
+
* caller gets a clear error listing the flags rather than a hang.
|
|
273
|
+
*/
|
|
274
|
+
async function resolveKeySource(opts) {
|
|
275
|
+
const { home } = opts;
|
|
276
|
+
const fromFile = (file, label) => {
|
|
277
|
+
const text = readTextFile(file);
|
|
278
|
+
if (text === null)
|
|
279
|
+
return { error: `Could not read ${file}` };
|
|
280
|
+
const material = parseKeyMaterial(text);
|
|
281
|
+
if (!material)
|
|
282
|
+
return { error: `${file} does not contain a valid 64-hex agent private key.` };
|
|
283
|
+
return { material, from: `${label} (${file})` };
|
|
284
|
+
};
|
|
285
|
+
// 1. Inline — what a one-line command copied from the onboarding modal uses.
|
|
286
|
+
if (opts.key) {
|
|
287
|
+
const material = parseKeyMaterial(opts.key);
|
|
288
|
+
if (!material)
|
|
289
|
+
return { error: "--key is not a valid agent private key (expected 64 hex characters)." };
|
|
290
|
+
return { material, from: "--key on the command line" };
|
|
291
|
+
}
|
|
292
|
+
// 2. An explicit file.
|
|
293
|
+
if (opts.keyFile)
|
|
294
|
+
return fromFile(expandHome(opts.keyFile, home), "--key-file");
|
|
295
|
+
// 3. A directory to look in — "the keys are in my Downloads folder".
|
|
296
|
+
if (opts.keysDir) {
|
|
297
|
+
const dir = expandHome(opts.keysDir, home);
|
|
298
|
+
const candidates = keyCandidatesInDir(dir);
|
|
299
|
+
if (!candidates.length) {
|
|
300
|
+
return { error: `No agent key file found in ${dir} (looked for guard-client-key and agent-keys-*.txt).` };
|
|
301
|
+
}
|
|
302
|
+
return fromFile(candidates[0], "--keys-dir");
|
|
303
|
+
}
|
|
304
|
+
// 4. The environment variable the rest of the CLI already honours.
|
|
305
|
+
const fromEnv = process.env.ATBASH_AGENT_KEY;
|
|
306
|
+
if (fromEnv) {
|
|
307
|
+
const material = parseKeyMaterial(fromEnv);
|
|
308
|
+
if (material)
|
|
309
|
+
return { material, from: "ATBASH_AGENT_KEY" };
|
|
310
|
+
}
|
|
311
|
+
// 5. Already in the canonical place — a re-run, or a machine set up before.
|
|
312
|
+
const keyFile = path.join(home, ...KEY_FILE_REL);
|
|
313
|
+
if (exists(keyFile)) {
|
|
314
|
+
const found = fromFile(keyFile, "existing key file");
|
|
315
|
+
if (!("error" in found))
|
|
316
|
+
return found;
|
|
317
|
+
}
|
|
318
|
+
// 6. The CLI's own config, populated by `atbash set agent-key`.
|
|
319
|
+
const fromConfig = (0, sdk_1.resolve)("agentKey");
|
|
320
|
+
if (fromConfig) {
|
|
321
|
+
const material = parseKeyMaterial(fromConfig);
|
|
322
|
+
if (material)
|
|
323
|
+
return { material, from: "atbash config (agentKey)" };
|
|
324
|
+
}
|
|
325
|
+
// 7. Ask.
|
|
326
|
+
if (opts.allowPrompt) {
|
|
327
|
+
const answer = await promptForKeyOrPath();
|
|
328
|
+
if (!answer)
|
|
329
|
+
return { error: "No key provided." };
|
|
330
|
+
const direct = parseKeyMaterial(answer);
|
|
331
|
+
if (direct)
|
|
332
|
+
return { material: direct, from: "interactive prompt" };
|
|
333
|
+
const asPath = expandHome(answer, home);
|
|
334
|
+
if (exists(asPath)) {
|
|
335
|
+
const stat = fs.statSync(asPath);
|
|
336
|
+
if (stat.isDirectory()) {
|
|
337
|
+
const candidates = keyCandidatesInDir(asPath);
|
|
338
|
+
if (candidates.length)
|
|
339
|
+
return fromFile(candidates[0], "directory given at the prompt");
|
|
340
|
+
return { error: `No agent key file found in ${asPath}.` };
|
|
341
|
+
}
|
|
342
|
+
return fromFile(asPath, "file given at the prompt");
|
|
343
|
+
}
|
|
344
|
+
return { error: "That is neither a 64-hex private key nor a path that exists." };
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
error: [
|
|
348
|
+
"No agent key found.",
|
|
349
|
+
"Provide one with --key <64-hex>, --key-file <path>, or --keys-dir <dir>,",
|
|
350
|
+
`or place it at ${keyFile} first.`,
|
|
351
|
+
].join("\n"),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/** The key file, in the `key=value` form the plugin and the SDK both parse. */
|
|
355
|
+
function keyFileContents(privkey, pubkey) {
|
|
356
|
+
return [
|
|
357
|
+
"# Atbash agent key",
|
|
358
|
+
"# Keep this file private (chmod 600). Read locally by every Atbash integration.",
|
|
359
|
+
`pubkey=${pubkey}`,
|
|
360
|
+
`privkey=${privkey}`,
|
|
361
|
+
"",
|
|
362
|
+
].join("\n");
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* True when a JSON file uses JSONC features (comments, trailing commas).
|
|
366
|
+
*
|
|
367
|
+
* We must not auto-merge into one: writing it back with JSON.stringify would
|
|
368
|
+
* silently delete the owner's comments. Detected by the disagreement between the
|
|
369
|
+
* strict and tolerant parsers — strict fails, tolerant succeeds.
|
|
370
|
+
*/
|
|
371
|
+
function isJsonc(text) {
|
|
372
|
+
try {
|
|
373
|
+
JSON.parse(text);
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
catch { /* fall through */ }
|
|
377
|
+
const errors = [];
|
|
378
|
+
const value = jsonc.parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
|
379
|
+
return errors.length === 0 && value !== undefined;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Merge the Atbash plugin block into an OpenClaw config object, in place.
|
|
383
|
+
*
|
|
384
|
+
* A MERGE, not a replacement — that distinction is the whole reason this command
|
|
385
|
+
* exists. Other plugins already in `allow`, `load.paths` and `entries` are
|
|
386
|
+
* preserved, and an entry from the legacy `@atbash/atbash-plugin` install is
|
|
387
|
+
* updated where it stands rather than being shadowed by a duplicate: the
|
|
388
|
+
* dashboard scan recognizes both keys, so two entries would mean two hooks.
|
|
389
|
+
*
|
|
390
|
+
* `load.paths` gets the real absolute extension path. The published docs show a
|
|
391
|
+
* `<your-username>` placeholder that people paste verbatim, producing a path that
|
|
392
|
+
* does not exist and a plugin that never loads.
|
|
393
|
+
*/
|
|
394
|
+
function mergeOpenclawConfig(config, home) {
|
|
395
|
+
const out = { ...config };
|
|
396
|
+
const plugins = { ...(isRecord(out.plugins) ? out.plugins : {}) };
|
|
397
|
+
// Which key is this install governed under? Keep an existing legacy entry
|
|
398
|
+
// where it is instead of adding a second one.
|
|
399
|
+
const entries = { ...(isRecord(plugins.entries) ? plugins.entries : {}) };
|
|
400
|
+
const entryKey = OPENCLAW_LEGACY_ENTRY in entries && !(OPENCLAW_ENTRY in entries)
|
|
401
|
+
? OPENCLAW_LEGACY_ENTRY
|
|
402
|
+
: OPENCLAW_ENTRY;
|
|
403
|
+
const allow = Array.isArray(plugins.allow) ? [...plugins.allow] : [];
|
|
404
|
+
if (!allow.includes(entryKey))
|
|
405
|
+
allow.push(entryKey);
|
|
406
|
+
plugins.allow = allow;
|
|
407
|
+
const load = { ...(isRecord(plugins.load) ? plugins.load : {}) };
|
|
408
|
+
const extensionPath = path.join(home, ...OPENCLAW_EXTENSIONS_REL, OPENCLAW_ENTRY);
|
|
409
|
+
const paths = Array.isArray(load.paths) ? [...load.paths] : [];
|
|
410
|
+
if (!paths.includes(extensionPath))
|
|
411
|
+
paths.push(extensionPath);
|
|
412
|
+
load.paths = paths;
|
|
413
|
+
plugins.load = load;
|
|
414
|
+
// Preserve any unrelated fields the owner set on the entry (debug, custom
|
|
415
|
+
// hooks); only the fields Atbash owns are asserted.
|
|
416
|
+
const existing = isRecord(entries[entryKey]) ? entries[entryKey] : {};
|
|
417
|
+
const existingConfig = isRecord(existing.config) ? existing.config : {};
|
|
418
|
+
const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
|
|
419
|
+
entries[entryKey] = {
|
|
420
|
+
...existing,
|
|
421
|
+
enabled: true,
|
|
422
|
+
config: {
|
|
423
|
+
...existingConfig,
|
|
424
|
+
enabled: true,
|
|
425
|
+
enforceDecision: true,
|
|
426
|
+
// The path, not the key. This is the whole point of the canonical location.
|
|
427
|
+
chromiaSecretPath: `~/${KEY_FILE_REL.join("/")}`,
|
|
428
|
+
},
|
|
429
|
+
hooks: {
|
|
430
|
+
...existingHooks,
|
|
431
|
+
allowConversationAccess: true,
|
|
432
|
+
allowPromptInjection: true,
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
plugins.entries = entries;
|
|
436
|
+
out.plugins = plugins;
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
function isRecord(v) {
|
|
440
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
441
|
+
}
|
|
442
|
+
/** Is `openclaw` runnable on this machine? Decides install-for-you vs print-it. */
|
|
443
|
+
function hasExecutable(command) {
|
|
444
|
+
const probe = (0, child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [command], { stdio: "ignore" });
|
|
445
|
+
return probe.status === 0;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Work out everything that needs doing on this machine, without doing any of it.
|
|
449
|
+
*
|
|
450
|
+
* Detection drives the plan rather than a flag the owner picks, for the same
|
|
451
|
+
* reason the scan does: what is actually installed here is knowable, and asking
|
|
452
|
+
* someone to identify their own runtime from a list invites a wrong answer that
|
|
453
|
+
* writes a config for a plugin they do not have.
|
|
454
|
+
*/
|
|
455
|
+
function buildPlan(args) {
|
|
456
|
+
const { home, privkey, pubkey, noInstall, only } = args;
|
|
457
|
+
const steps = [];
|
|
458
|
+
const notes = [];
|
|
459
|
+
const found = [];
|
|
460
|
+
const wanted = (id) => only.length === 0 || only.includes(id);
|
|
461
|
+
// ── 1. The key file. Always, for every runtime: it is the one artifact every
|
|
462
|
+
// integration reads, and the thing the owner would otherwise move by hand.
|
|
463
|
+
const keyFile = path.join(home, ...KEY_FILE_REL);
|
|
464
|
+
const desiredKeyFile = keyFileContents(privkey, pubkey);
|
|
465
|
+
const currentKeyFile = readTextFile(keyFile);
|
|
466
|
+
const alreadyThisKey = currentKeyFile !== null && parseKeyMaterial(currentKeyFile)?.privkey === privkey;
|
|
467
|
+
if (!alreadyThisKey) {
|
|
468
|
+
if (currentKeyFile !== null) {
|
|
469
|
+
notes.push(`${keyFile} already holds a DIFFERENT agent key. It will be backed up before being replaced — check that you meant to re-point this machine at another agent.`);
|
|
470
|
+
}
|
|
471
|
+
steps.push({
|
|
472
|
+
kind: "write",
|
|
473
|
+
label: currentKeyFile === null ? "Save the agent key where every integration looks for it" : "Replace the agent key file",
|
|
474
|
+
file: keyFile,
|
|
475
|
+
mode: KEY_MODE,
|
|
476
|
+
before: currentKeyFile,
|
|
477
|
+
after: desiredKeyFile,
|
|
478
|
+
secret: true,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
notes.push(`${keyFile} already holds this agent's key — left untouched.`);
|
|
483
|
+
}
|
|
484
|
+
// ── 2. OpenClaw: the one runtime governed purely by a config file, so the one
|
|
485
|
+
// this command can finish end to end.
|
|
486
|
+
const openclawConfigFile = path.join(home, ...OPENCLAW_CONFIG_REL);
|
|
487
|
+
if (exists(openclawConfigFile) || exists(home, ".openclaw")) {
|
|
488
|
+
found.push("OpenClaw");
|
|
489
|
+
if (wanted("openclaw")) {
|
|
490
|
+
if (!noInstall) {
|
|
491
|
+
if (hasExecutable("openclaw")) {
|
|
492
|
+
steps.push({ kind: "exec", label: `Install ${OPENCLAW_PKG}`, command: "openclaw", args: ["plugins", "install", OPENCLAW_PKG] });
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
steps.push({
|
|
496
|
+
kind: "manual",
|
|
497
|
+
label: "Install the OpenClaw plugin",
|
|
498
|
+
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:",
|
|
499
|
+
snippet: `openclaw plugins install ${OPENCLAW_PKG}`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const raw = readTextFile(openclawConfigFile);
|
|
504
|
+
if (raw !== null && isJsonc(raw)) {
|
|
505
|
+
// Rewriting this would delete the owner's comments. Print instead.
|
|
506
|
+
steps.push({
|
|
507
|
+
kind: "manual",
|
|
508
|
+
label: `Enable the plugin in ${openclawConfigFile}`,
|
|
509
|
+
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`:",
|
|
510
|
+
snippet: JSON.stringify(mergeOpenclawConfig((jsonc.parse(raw, [], { allowTrailingComma: true, disallowComments: false }) ?? {}), home), null, 2),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
let current = {};
|
|
515
|
+
if (raw !== null) {
|
|
516
|
+
try {
|
|
517
|
+
const parsed = JSON.parse(raw);
|
|
518
|
+
if (isRecord(parsed))
|
|
519
|
+
current = parsed;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
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.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const after = JSON.stringify(mergeOpenclawConfig(current, home), null, 2) + "\n";
|
|
526
|
+
if (raw !== after) {
|
|
527
|
+
steps.push({
|
|
528
|
+
kind: "write",
|
|
529
|
+
label: raw === null
|
|
530
|
+
? "Create ~/.openclaw/openclaw.json with the plugin enabled"
|
|
531
|
+
: "Enable the plugin in ~/.openclaw/openclaw.json (a merge — existing plugins are kept)",
|
|
532
|
+
file: openclawConfigFile,
|
|
533
|
+
before: raw,
|
|
534
|
+
after,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
notes.push(`${openclawConfigFile} already has the plugin enabled — left untouched.`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
notes.push("Restart the OpenClaw gateway. The hook is registered at startup, so the plugin does nothing until it restarts.");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// ── 3. Hermes: not a runtime that merely lacks a plugin. It runs the SAME
|
|
545
|
+
// agent, reads the same skills and the same key — but the Atbash hook lives in
|
|
546
|
+
// the OpenClaw gateway, so anything driven through the Hermes API is never
|
|
547
|
+
// judged. Setup places the key file and says so; it does not pretend to wire it.
|
|
548
|
+
if (exists(home, ...HERMES_AGENT_REL)) {
|
|
549
|
+
found.push("Hermes");
|
|
550
|
+
notes.push("Hermes is installed here. It shares this agent's skills and signing key, but the Atbash hook lives in the OpenClaw gateway — actions driven through the Hermes API are NOT judged, even while the OpenClaw side reports enforcing. Route that work through OpenClaw, or guard it in code with @atbash/sdk.");
|
|
551
|
+
}
|
|
552
|
+
// ── 4. MCP clients: detected and reported, never seeded.
|
|
553
|
+
// The documented @atbash/mcp wiring carries the private key as an
|
|
554
|
+
// ATBASH_AGENT_PRIVKEY env value inside the client's own config file, and that
|
|
555
|
+
// package has no key-file fallback (checked against the published 0.1.3). We
|
|
556
|
+
// will not write a private key into a file people share and sync, so this is
|
|
557
|
+
// the one place setup deliberately stays manual.
|
|
558
|
+
const mcpClients = [];
|
|
559
|
+
const seenClients = new Set();
|
|
560
|
+
for (const { label, segs } of connect_1.MCP_CONFIGS) {
|
|
561
|
+
if (seenClients.has(label))
|
|
562
|
+
continue;
|
|
563
|
+
if (exists(home, ...segs)) {
|
|
564
|
+
seenClients.add(label);
|
|
565
|
+
mcpClients.push(`${label} (${path.join(home, ...segs)})`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Claude Code and Codex live outside MCP_CONFIGS in the scanner too — same paths.
|
|
569
|
+
if (exists(home, ".claude.json"))
|
|
570
|
+
mcpClients.push(`Claude Code (${path.join(home, ".claude.json")})`);
|
|
571
|
+
if (exists(home, ".codex", "config.toml"))
|
|
572
|
+
mcpClients.push(`Codex (${path.join(home, ".codex", "config.toml")})`);
|
|
573
|
+
if (mcpClients.length) {
|
|
574
|
+
found.push(`${mcpClients.length} MCP client config${mcpClients.length === 1 ? "" : "s"}`);
|
|
575
|
+
steps.push({
|
|
576
|
+
kind: "manual",
|
|
577
|
+
label: `Optional: expose Atbash as an MCP server to ${mcpClients.length} detected client${mcpClients.length === 1 ? "" : "s"}`,
|
|
578
|
+
detail: [
|
|
579
|
+
`Detected: ${mcpClients.join(", ")}.`,
|
|
580
|
+
"",
|
|
581
|
+
"This step is NOT done for you, on purpose. The published @atbash/mcp wiring takes the agent's private key as an ATBASH_AGENT_PRIVKEY value inside the client's own config file, and that package has no key-file fallback today. Atbash will not write your private key into a file that gets synced, shared and screenshotted.",
|
|
582
|
+
"",
|
|
583
|
+
"If you want it anyway, add this yourself and fill in the key — and treat that config file as a secret from then on:",
|
|
584
|
+
].join("\n"),
|
|
585
|
+
snippet: JSON.stringify({
|
|
586
|
+
mcpServers: {
|
|
587
|
+
atbash: { command: "npx", args: ["-y", "@atbash/mcp"], env: { ATBASH_AGENT_PRIVKEY: "<your agent private key>" } },
|
|
588
|
+
},
|
|
589
|
+
}, null, 2),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
if (!found.length) {
|
|
593
|
+
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.");
|
|
594
|
+
}
|
|
595
|
+
return { steps, notes, found };
|
|
596
|
+
}
|
|
597
|
+
// ── Showing the plan ────────────────────────────────────────────────────────
|
|
598
|
+
/**
|
|
599
|
+
* A minimal line diff, so the preview shows what CHANGES rather than dumping a
|
|
600
|
+
* whole config and leaving the owner to spot the difference. Standard LCS; these
|
|
601
|
+
* files are small enough that the quadratic table is irrelevant.
|
|
602
|
+
*/
|
|
603
|
+
function lineDiff(before, after) {
|
|
604
|
+
const a = before.split("\n");
|
|
605
|
+
const b = after.split("\n");
|
|
606
|
+
const table = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
607
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
608
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
609
|
+
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const out = [];
|
|
613
|
+
let i = 0;
|
|
614
|
+
let j = 0;
|
|
615
|
+
while (i < a.length && j < b.length) {
|
|
616
|
+
if (a[i] === b[j]) {
|
|
617
|
+
out.push(` ${a[i]}`);
|
|
618
|
+
i++;
|
|
619
|
+
j++;
|
|
620
|
+
}
|
|
621
|
+
else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
622
|
+
out.push(`- ${a[i]}`);
|
|
623
|
+
i++;
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
out.push(`+ ${b[j]}`);
|
|
627
|
+
j++;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (; i < a.length; i++)
|
|
631
|
+
out.push(`- ${a[i]}`);
|
|
632
|
+
for (; j < b.length; j++)
|
|
633
|
+
out.push(`+ ${b[j]}`);
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
/** Drop unchanged runs down to a little context, so a long config stays readable. */
|
|
637
|
+
function condense(diff, context = 2) {
|
|
638
|
+
const keep = new Set();
|
|
639
|
+
diff.forEach((line, index) => {
|
|
640
|
+
if (line.startsWith("+") || line.startsWith("-")) {
|
|
641
|
+
for (let k = index - context; k <= index + context; k++)
|
|
642
|
+
if (k >= 0 && k < diff.length)
|
|
643
|
+
keep.add(k);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
const out = [];
|
|
647
|
+
let skipping = false;
|
|
648
|
+
diff.forEach((line, index) => {
|
|
649
|
+
if (keep.has(index)) {
|
|
650
|
+
out.push(line);
|
|
651
|
+
skipping = false;
|
|
652
|
+
}
|
|
653
|
+
else if (!skipping) {
|
|
654
|
+
out.push(chalk_1.default.dim(" …"));
|
|
655
|
+
skipping = true;
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
return out;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Print the plan. Used for `--dry-run` and for the confirmation prompt, so what
|
|
662
|
+
* the owner is shown and what they agree to cannot diverge.
|
|
663
|
+
*
|
|
664
|
+
* The key file's CONTENTS are never printed — the whole point of the file is that
|
|
665
|
+
* the private key stays put, and echoing it into a terminal scrollback undoes
|
|
666
|
+
* that. The path, mode and the public key are shown instead.
|
|
667
|
+
*/
|
|
668
|
+
function renderPlan(plan, pubkey) {
|
|
669
|
+
console.log();
|
|
670
|
+
console.log(chalk_1.default.bold(" Atbash setup"));
|
|
671
|
+
console.log(chalk_1.default.dim(` Agent public key: ${pubkey}`));
|
|
672
|
+
console.log(chalk_1.default.dim(` Detected on this machine: ${plan.found.length ? plan.found.join(", ") : "no supported runtime"}`));
|
|
673
|
+
console.log();
|
|
674
|
+
const writes = plan.steps.filter((s) => s.kind === "write");
|
|
675
|
+
const execs = plan.steps.filter((s) => s.kind === "exec");
|
|
676
|
+
const manuals = plan.steps.filter((s) => s.kind === "manual");
|
|
677
|
+
if (!writes.length && !execs.length) {
|
|
678
|
+
console.log(chalk_1.default.green(" Nothing to change — this machine is already wired.") + "\n");
|
|
679
|
+
}
|
|
680
|
+
if (writes.length) {
|
|
681
|
+
console.log(chalk_1.default.bold(` Files (${writes.length})`));
|
|
682
|
+
for (const step of writes) {
|
|
683
|
+
console.log(` ${chalk_1.default.cyan(step.file)}${step.mode ? chalk_1.default.dim(` mode ${step.mode.toString(8)}`) : ""}`);
|
|
684
|
+
console.log(` ${step.label}`);
|
|
685
|
+
if (step.secret) {
|
|
686
|
+
// Deliberately not the contents.
|
|
687
|
+
console.log(chalk_1.default.dim(` Contents: pubkey= and privkey= lines for the agent above. Not printed — it is a private key.`));
|
|
688
|
+
}
|
|
689
|
+
else if (step.before === null) {
|
|
690
|
+
for (const line of step.after.split("\n").slice(0, 40))
|
|
691
|
+
console.log(chalk_1.default.dim(` + ${line}`));
|
|
692
|
+
if (step.after.split("\n").length > 40)
|
|
693
|
+
console.log(chalk_1.default.dim(" …"));
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
for (const line of condense(lineDiff(step.before, step.after))) {
|
|
697
|
+
const painted = line.startsWith("+") ? chalk_1.default.green(line) : line.startsWith("-") ? chalk_1.default.red(line) : chalk_1.default.dim(line);
|
|
698
|
+
console.log(` ${painted}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (step.before !== null)
|
|
702
|
+
console.log(chalk_1.default.dim(" The existing file is copied to a .atbash-bak alongside it first."));
|
|
703
|
+
console.log();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (execs.length) {
|
|
707
|
+
console.log(chalk_1.default.bold(` Commands (${execs.length})`));
|
|
708
|
+
for (const step of execs)
|
|
709
|
+
console.log(` ${chalk_1.default.cyan(`${step.command} ${step.args.join(" ")}`)}\n ${step.label}`);
|
|
710
|
+
console.log();
|
|
711
|
+
}
|
|
712
|
+
if (manuals.length) {
|
|
713
|
+
console.log(chalk_1.default.bold(` For you to do (${manuals.length})`));
|
|
714
|
+
for (const step of manuals) {
|
|
715
|
+
console.log(` ${chalk_1.default.yellow("•")} ${chalk_1.default.bold(step.label)}`);
|
|
716
|
+
for (const line of step.detail.split("\n"))
|
|
717
|
+
console.log(` ${chalk_1.default.dim(line)}`);
|
|
718
|
+
if (step.snippet)
|
|
719
|
+
for (const line of step.snippet.split("\n"))
|
|
720
|
+
console.log(chalk_1.default.dim(` ${line}`));
|
|
721
|
+
console.log();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (plan.notes.length) {
|
|
725
|
+
console.log(chalk_1.default.bold(" Notes"));
|
|
726
|
+
for (const note of plan.notes)
|
|
727
|
+
console.log(` ${chalk_1.default.dim("•")} ${chalk_1.default.dim(note)}`);
|
|
728
|
+
console.log();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// ── Doing it ────────────────────────────────────────────────────────────────
|
|
732
|
+
/**
|
|
733
|
+
* Copy a file aside before overwriting it, without ever clobbering an existing
|
|
734
|
+
* backup — a second run must not overwrite the pristine copy from the first.
|
|
735
|
+
*/
|
|
736
|
+
function backupFile(file) {
|
|
737
|
+
if (!fs.existsSync(file))
|
|
738
|
+
return null;
|
|
739
|
+
let target = `${file}.atbash-bak`;
|
|
740
|
+
let n = 1;
|
|
741
|
+
while (fs.existsSync(target))
|
|
742
|
+
target = `${file}.atbash-bak.${n++}`;
|
|
743
|
+
fs.copyFileSync(file, target);
|
|
744
|
+
return target;
|
|
745
|
+
}
|
|
746
|
+
/** Execute the plan. Writes first, then commands, so a failed install still
|
|
747
|
+
* leaves a correct config and key file behind for a manual retry. */
|
|
748
|
+
function applyPlan(plan) {
|
|
749
|
+
const result = { written: [], backups: [], ran: [], failures: [] };
|
|
750
|
+
for (const step of plan.steps) {
|
|
751
|
+
if (step.kind !== "write")
|
|
752
|
+
continue;
|
|
753
|
+
try {
|
|
754
|
+
const backup = backupFile(step.file);
|
|
755
|
+
if (backup)
|
|
756
|
+
result.backups.push(backup);
|
|
757
|
+
fs.mkdirSync(path.dirname(step.file), { recursive: true, mode: step.mode === KEY_MODE ? DIR_MODE : undefined });
|
|
758
|
+
fs.writeFileSync(step.file, step.after, step.mode ? { mode: step.mode } : {});
|
|
759
|
+
// writeFileSync's mode is ignored for a file that already existed, so
|
|
760
|
+
// assert it explicitly — a key file at 0644 is the failure this guards.
|
|
761
|
+
if (step.mode)
|
|
762
|
+
fs.chmodSync(step.file, step.mode);
|
|
763
|
+
result.written.push(step.file);
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
result.failures.push(`${step.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
for (const step of plan.steps) {
|
|
770
|
+
if (step.kind !== "exec")
|
|
771
|
+
continue;
|
|
772
|
+
const label = `${step.command} ${step.args.join(" ")}`;
|
|
773
|
+
const run = (0, child_process_1.spawnSync)(step.command, step.args, { stdio: "inherit" });
|
|
774
|
+
if (run.status === 0)
|
|
775
|
+
result.ran.push(label);
|
|
776
|
+
else
|
|
777
|
+
result.failures.push(`${label} exited ${run.status ?? "on a signal"}`);
|
|
778
|
+
}
|
|
779
|
+
return result;
|
|
780
|
+
}
|
|
781
|
+
// ── Registration check ──────────────────────────────────────────────────────
|
|
782
|
+
/**
|
|
783
|
+
* Confirm the agent this key belongs to is actually registered.
|
|
784
|
+
*
|
|
785
|
+
* Wiring a runtime to an unregistered agent produces the worst outcome available:
|
|
786
|
+
* a machine that looks governed, with a plugin that cannot get a verdict. The
|
|
787
|
+
* check sends only the PUBLIC key (GET /api/ai/exists), never the private one.
|
|
788
|
+
*/
|
|
789
|
+
async function verifyRegistration(privkey, endpoint) {
|
|
790
|
+
try {
|
|
791
|
+
const atbash = new sdk_1.Atbash(privkey, {
|
|
792
|
+
endpoint,
|
|
793
|
+
blockchainRid: (0, sdk_1.resolve)("blockchainRid") || undefined,
|
|
794
|
+
orgName: (0, sdk_1.resolve)("orgName") || undefined,
|
|
795
|
+
});
|
|
796
|
+
return (await atbash.checkAgentExists()) ? { state: "registered" } : { state: "unregistered" };
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
return { state: "unknown", reason: err instanceof Error ? err.message : String(err) };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** y/N confirmation. Anything but an explicit yes is a no. */
|
|
803
|
+
async function confirm(question) {
|
|
804
|
+
const { createInterface } = await Promise.resolve().then(() => __importStar(require("node:readline")));
|
|
805
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
806
|
+
const answer = await new Promise((r) => rl.question(question, (a) => { rl.close(); r(a); }));
|
|
807
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
808
|
+
}
|
|
809
|
+
// ── The command ─────────────────────────────────────────────────────────────
|
|
810
|
+
function registerSetupCommand(program) {
|
|
811
|
+
program
|
|
812
|
+
.command("setup")
|
|
813
|
+
.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)")
|
|
814
|
+
.option("-k, --key <privkey>", "Agent private key (64 hex). Convenient, but it lands in your shell history — prefer the prompt or --key-file")
|
|
815
|
+
.option("--key-file <path>", "Read the key from a file (the agent-keys-*.txt from onboarding, or an existing guard-client-key)")
|
|
816
|
+
.option("--keys-dir <dir>", "Directory holding the key file, e.g. ~/Downloads")
|
|
817
|
+
.option("--host <url>", "Atbash deployment to check the agent's registration against")
|
|
818
|
+
.option("--runtime <ids...>", "Only configure these runtimes (currently: openclaw)")
|
|
819
|
+
.option("--dry-run", "Show exactly which files would change, and the diffs, then exit WITHOUT writing anything")
|
|
820
|
+
.option("-y, --yes", "Do not ask for confirmation before writing")
|
|
821
|
+
.option("--no-install", "Do not install any package; write the key file and configs only")
|
|
822
|
+
.option("--skip-verify", "Do not check the agent's registration (no network calls at all)")
|
|
823
|
+
.option("--allow-unrecognized-host", "Permit a --host that is not a known Atbash deployment")
|
|
824
|
+
.option("--home <dir>", "Home directory to configure (for testing)")
|
|
825
|
+
.action(async (opts) => {
|
|
826
|
+
const home = opts.home || process.env.HOME || os.homedir();
|
|
827
|
+
const dryRun = !!opts.dryRun;
|
|
828
|
+
// A key on argv is in the shell history and in `ps` output. Say so once,
|
|
829
|
+
// rather than silently accepting the convenient-but-leaky path.
|
|
830
|
+
if (opts.key) {
|
|
831
|
+
console.log(chalk_1.default.yellow("\n Note: a key passed with --key is recorded in your shell history.") +
|
|
832
|
+
chalk_1.default.dim("\n Clear it afterwards, or re-run without --key and paste it at the hidden prompt."));
|
|
833
|
+
}
|
|
834
|
+
const keySource = await resolveKeySource({
|
|
835
|
+
key: opts.key,
|
|
836
|
+
keyFile: opts.keyFile,
|
|
837
|
+
keysDir: opts.keysDir,
|
|
838
|
+
home,
|
|
839
|
+
allowPrompt: process.stdin.isTTY === true,
|
|
840
|
+
});
|
|
841
|
+
if ("error" in keySource) {
|
|
842
|
+
console.error(chalk_1.default.red(`\n${keySource.error}\n`));
|
|
843
|
+
process.exit(1);
|
|
844
|
+
}
|
|
845
|
+
const privkey = keySource.material.privkey;
|
|
846
|
+
const pubkey = (0, sdk_1.derivePublicKey)(privkey);
|
|
847
|
+
// A key file whose stated pubkey does not match the private key is either
|
|
848
|
+
// corrupt or two different keypairs spliced together. Either way, wiring a
|
|
849
|
+
// runtime with it produces signatures nobody can attribute.
|
|
850
|
+
const stated = keySource.material.statedPubkey?.replace(/^0x/i, "").toLowerCase();
|
|
851
|
+
if (stated && stated !== pubkey.toLowerCase()) {
|
|
852
|
+
console.error(chalk_1.default.red("\n The key file's `pubkey` does not match the key derived from its `privkey`.") +
|
|
853
|
+
chalk_1.default.dim(`\n File says: ${stated}\n Derived: ${pubkey}\n Fix the file (or re-download it) before wiring anything.\n`));
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
console.log(chalk_1.default.dim(`\n Agent key source: ${keySource.from}`));
|
|
857
|
+
// ── Registration check. Only the public key crosses the network.
|
|
858
|
+
if (!opts.skipVerify) {
|
|
859
|
+
const endpoint = (opts.host || (0, sdk_1.resolve)("judgeEndpoint") || connect_1.DEFAULT_HOST || sdk_1.DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
860
|
+
let hostname = "";
|
|
861
|
+
try {
|
|
862
|
+
hostname = new URL(endpoint).hostname.toLowerCase();
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
console.error(chalk_1.default.red(`\n --host is not a valid URL: ${endpoint}\n`));
|
|
866
|
+
process.exit(1);
|
|
867
|
+
}
|
|
868
|
+
// An unrecognized host could answer "registered" for any key, which is
|
|
869
|
+
// exactly the confirmation this check exists to provide. Exact hostname
|
|
870
|
+
// match, never a suffix — "atbash.ai.evil.com" must not pass.
|
|
871
|
+
if (!connect_1.KNOWN_HOSTS.has(hostname) && !opts.allowUnrecognizedHost) {
|
|
872
|
+
console.error(chalk_1.default.red(`\n ${hostname} is not a recognized Atbash deployment.`) +
|
|
873
|
+
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"));
|
|
874
|
+
process.exit(1);
|
|
875
|
+
}
|
|
876
|
+
const verdict = await verifyRegistration(privkey, endpoint);
|
|
877
|
+
if (verdict.state === "unregistered") {
|
|
878
|
+
console.error(chalk_1.default.red("\n That agent is not registered on this deployment.") +
|
|
879
|
+
chalk_1.default.dim(`\n Public key: ${pubkey}\n Finish onboarding first — wiring a runtime to an unregistered agent leaves it\n looking governed while the plugin can never get a verdict.\n`));
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
if (verdict.state === "unknown") {
|
|
883
|
+
console.log(chalk_1.default.yellow(`\n Could not confirm the agent's registration (${verdict.reason}).`));
|
|
884
|
+
console.log(chalk_1.default.dim(" The wiring below is still correct, but nothing has verified that this agent exists."));
|
|
885
|
+
if (!opts.yes && !dryRun && process.stdin.isTTY && !(await confirm(" Continue anyway? [y/N] "))) {
|
|
886
|
+
console.log(chalk_1.default.dim("\n Nothing was changed.\n"));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
console.log(chalk_1.default.green(` Agent is registered on ${hostname}.`));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// ── Plan, show, then (maybe) apply.
|
|
895
|
+
const plan = buildPlan({
|
|
896
|
+
home,
|
|
897
|
+
privkey,
|
|
898
|
+
pubkey,
|
|
899
|
+
noInstall: opts.install === false,
|
|
900
|
+
only: opts.runtime ?? [],
|
|
901
|
+
});
|
|
902
|
+
renderPlan(plan, pubkey);
|
|
903
|
+
const changes = plan.steps.filter((s) => s.kind === "write" || s.kind === "exec");
|
|
904
|
+
if (dryRun) {
|
|
905
|
+
console.log(chalk_1.default.green(" Dry run — nothing was written.") +
|
|
906
|
+
chalk_1.default.dim(" Re-run without --dry-run to apply.\n"));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (!changes.length) {
|
|
910
|
+
console.log(chalk_1.default.dim(" Nothing to apply.\n"));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (!opts.yes) {
|
|
914
|
+
if (!process.stdin.isTTY) {
|
|
915
|
+
console.error(chalk_1.default.red(" Refusing to write without confirmation.") +
|
|
916
|
+
chalk_1.default.dim(" Re-run with --yes (or --dry-run to preview).\n"));
|
|
917
|
+
process.exit(1);
|
|
918
|
+
}
|
|
919
|
+
if (!(await confirm(` Apply ${changes.length} change${changes.length === 1 ? "" : "s"} to this machine? [y/N] `))) {
|
|
920
|
+
console.log(chalk_1.default.dim("\n Nothing was changed.\n"));
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const result = applyPlan(plan);
|
|
925
|
+
console.log();
|
|
926
|
+
for (const file of result.written)
|
|
927
|
+
console.log(chalk_1.default.green(` ✓ wrote ${file}`));
|
|
928
|
+
for (const file of result.backups)
|
|
929
|
+
console.log(chalk_1.default.dim(` backup: ${file}`));
|
|
930
|
+
for (const cmd of result.ran)
|
|
931
|
+
console.log(chalk_1.default.green(` ✓ ran ${cmd}`));
|
|
932
|
+
for (const failure of result.failures)
|
|
933
|
+
console.log(chalk_1.default.red(` ✗ ${failure}`));
|
|
934
|
+
if (result.failures.length) {
|
|
935
|
+
console.log(chalk_1.default.yellow("\n Finished with failures — this machine is NOT fully wired.") +
|
|
936
|
+
chalk_1.default.dim("\n Everything that did succeed is listed above; the steps that failed can be re-run.\n"));
|
|
937
|
+
process.exitCode = 1;
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
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"));
|
|
941
|
+
console.log(chalk_1.default.dim(" from the agent's page in the dashboard to confirm it reports as enforcing.\n"));
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
//# sourceMappingURL=setup.js.map
|