@drakon-systems/multi-clawd 1.7.2 → 1.7.3
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 +141 -4
- package/dist/account-env.js +8 -2
- package/dist/chain-audit.js +2 -2
- package/dist/health.js +1 -1
- package/dist/hermes-core.js +258 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/scripts/cli.mjs +5 -0
- package/scripts/hermes.mjs +469 -0
- package/scripts/hermes_bridge.py +745 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Secret-safe multi-clawd → Hermes credential-pool adapter. */
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
accessSync,
|
|
6
|
+
closeSync,
|
|
7
|
+
constants,
|
|
8
|
+
existsSync,
|
|
9
|
+
fstatSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
statSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const BRIDGE = join(HERE, "hermes_bridge.py");
|
|
21
|
+
const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
const MAX_SETUP_TOKEN_BYTES = 8 * 1024;
|
|
23
|
+
const STRATEGIES = "fill_first|round_robin|random|least_used";
|
|
24
|
+
// Imported by discoverHermes so a Hermes whose credential-pool API — or its
|
|
25
|
+
// home/root layout — has moved fails loudly at discovery instead of opaquely
|
|
26
|
+
// at write time.
|
|
27
|
+
const REQUIRED_HERMES_SYMBOLS = [
|
|
28
|
+
"from hermes_cli.config import read_raw_config,save_config",
|
|
29
|
+
"from hermes_cli.auth import read_credential_pool,write_credential_pool",
|
|
30
|
+
"from agent.credential_pool import PooledCredential",
|
|
31
|
+
"from hermes_constants import get_hermes_home,get_default_hermes_root",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function usage() {
|
|
35
|
+
console.log(`Usage:
|
|
36
|
+
multi-clawd hermes sync [--dry-run] [--profile <name>] [--strategy ${STRATEGIES}] [--config <openclaw.json>]
|
|
37
|
+
multi-clawd hermes doctor [--profile <name>] [--config <openclaw.json>]
|
|
38
|
+
|
|
39
|
+
Only accounts with an oauthTokenFile (a stable \`claude setup-token\`) are
|
|
40
|
+
imported. A native login needs nothing imported — Hermes' own claude_code
|
|
41
|
+
credential source already reads that same native ~/.claude/.credentials.json.
|
|
42
|
+
A configDir login has no Hermes-native equivalent (claude_code only reads the
|
|
43
|
+
native path) — give it its own oauthTokenFile, or leave it OpenClaw-only.
|
|
44
|
+
Omitting --strategy leaves Hermes' configured strategy alone.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fail(message) {
|
|
48
|
+
throw new Error(message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safePath(raw, label) {
|
|
52
|
+
if (typeof raw !== "string" || !raw || raw.includes("\0")) fail(`${label} path is invalid`);
|
|
53
|
+
const expanded = raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw;
|
|
54
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class ReadLimitedError extends Error {}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `requirePrivate` rejects a POSIX file readable or writable by group/other
|
|
61
|
+
* (mode & 0o077 != 0) — checked via fstat on the already-open fd, so nothing
|
|
62
|
+
* can be swapped between the check and the read. Windows has no equivalent
|
|
63
|
+
* bit layout, so the check is skipped there; the config JSON is read through
|
|
64
|
+
* here too but never with this flag, since it holds no secret.
|
|
65
|
+
*/
|
|
66
|
+
function readLimited(path, limit, label, { requirePrivate = false } = {}) {
|
|
67
|
+
let fd;
|
|
68
|
+
try {
|
|
69
|
+
fd = openSync(path, "r");
|
|
70
|
+
const stat = fstatSync(fd);
|
|
71
|
+
if (!stat.isFile() || stat.size > limit) throw new ReadLimitedError(`${label} is unavailable or too large`);
|
|
72
|
+
if (requirePrivate && process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
|
73
|
+
throw new ReadLimitedError(
|
|
74
|
+
`${label} at ${path} is readable or writable by group/other — chmod 600 ${path}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const data = readFileSync(fd);
|
|
78
|
+
if (data.byteLength > limit) throw new ReadLimitedError(`${label} is unavailable or too large`);
|
|
79
|
+
return data.toString("utf8");
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof ReadLimitedError) fail(error.message);
|
|
82
|
+
fail(`${label} could not be read`);
|
|
83
|
+
} finally {
|
|
84
|
+
if (fd !== undefined) closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseOptions(command, args, core) {
|
|
89
|
+
if (command !== "sync" && command !== "doctor") fail("Hermes command must be sync or doctor");
|
|
90
|
+
const result = { dryRun: false, profile: "default", strategy: undefined, config: undefined };
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (let index = 0; index < args.length; index++) {
|
|
93
|
+
const option = args[index];
|
|
94
|
+
if (option === "--help" || option === "-h") return { help: true };
|
|
95
|
+
if (!option.startsWith("--")) fail("unexpected positional argument");
|
|
96
|
+
if (seen.has(option)) fail("duplicate option");
|
|
97
|
+
seen.add(option);
|
|
98
|
+
if (option === "--dry-run") {
|
|
99
|
+
if (command !== "sync") fail("--dry-run is only valid for hermes sync");
|
|
100
|
+
result.dryRun = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!["--profile", "--strategy", "--config"].includes(option)) fail("unknown option");
|
|
104
|
+
if (option === "--strategy" && command !== "sync") fail("--strategy is only valid for hermes sync");
|
|
105
|
+
const value = args[++index];
|
|
106
|
+
if (value === undefined || value.startsWith("--")) fail(`missing value for ${option}`);
|
|
107
|
+
if (value.includes("\0")) fail(`invalid value for ${option}`);
|
|
108
|
+
if (option === "--profile") result.profile = core.validateHermesProfileName(value);
|
|
109
|
+
else if (option === "--strategy") result.strategy = core.validateHermesStrategy(value);
|
|
110
|
+
else result.config = safePath(value, "config");
|
|
111
|
+
}
|
|
112
|
+
result.profile = core.validateHermesProfileName(result.profile);
|
|
113
|
+
result.config ??= join(homedir(), ".openclaw", "openclaw.json");
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function executable(path) {
|
|
118
|
+
try {
|
|
119
|
+
accessSync(path, constants.X_OK);
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Guards a Hermes-reported home/root path before it is ever joined or used. */
|
|
127
|
+
function validHermesPath(raw) {
|
|
128
|
+
return typeof raw === "string" && raw && !raw.includes("\0") && isAbsolute(raw) ? raw : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findOnPath(name) {
|
|
132
|
+
const extensions = process.platform === "win32"
|
|
133
|
+
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
134
|
+
: [""];
|
|
135
|
+
for (const dir of (process.env.PATH || "").split(delimiter)) {
|
|
136
|
+
if (!dir) continue;
|
|
137
|
+
for (const extension of extensions) {
|
|
138
|
+
const candidate = join(dir, process.platform === "win32" ? `${name}${extension}` : name);
|
|
139
|
+
if (executable(candidate)) return candidate;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A setup token is piped to this interpreter, so flag a bin directory any local
|
|
147
|
+
* user could swap it out in. World-writable only: a group-writable venv under a
|
|
148
|
+
* umask of 002 is the norm and warning on it would be noise.
|
|
149
|
+
*/
|
|
150
|
+
function worldWritable(dir) {
|
|
151
|
+
if (process.platform === "win32") return false;
|
|
152
|
+
try {
|
|
153
|
+
return (statSync(dir).mode & 0o002) !== 0;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function discoverHermes() {
|
|
160
|
+
const launcher = findOnPath("hermes");
|
|
161
|
+
if (!launcher) return { ok: false, message: "Hermes executable is not on PATH; install Hermes Agent first" };
|
|
162
|
+
let resolvedLauncher;
|
|
163
|
+
try {
|
|
164
|
+
resolvedLauncher = realpathSync(launcher);
|
|
165
|
+
} catch {
|
|
166
|
+
return { ok: false, message: "Hermes launcher could not be resolved" };
|
|
167
|
+
}
|
|
168
|
+
const bin = dirname(resolvedLauncher);
|
|
169
|
+
const names = process.platform === "win32" ? ["python.exe", "python3.exe"] : ["python3", "python"];
|
|
170
|
+
const candidates = names.map((name) => join(bin, name)).filter(executable);
|
|
171
|
+
if (candidates.length === 0) {
|
|
172
|
+
return { ok: false, launcher: resolvedLauncher, message: "Hermes venv Python was not found beside its launcher; reinstall Hermes Agent" };
|
|
173
|
+
}
|
|
174
|
+
const probeCode = [
|
|
175
|
+
"import importlib.metadata,json,sys",
|
|
176
|
+
...REQUIRED_HERMES_SYMBOLS,
|
|
177
|
+
"print(json.dumps({'version':importlib.metadata.version('hermes-agent'),'python':sys.executable," +
|
|
178
|
+
"'root':str(get_default_hermes_root().resolve()),'home':str(get_hermes_home().resolve())}))",
|
|
179
|
+
].join(";");
|
|
180
|
+
for (const python of candidates) {
|
|
181
|
+
const probe = spawnSync(python, ["-c", probeCode], {
|
|
182
|
+
encoding: "utf8",
|
|
183
|
+
timeout: 15000,
|
|
184
|
+
maxBuffer: 64 * 1024,
|
|
185
|
+
env: process.env,
|
|
186
|
+
});
|
|
187
|
+
if (probe.status !== 0) continue;
|
|
188
|
+
try {
|
|
189
|
+
const info = JSON.parse(probe.stdout.trim());
|
|
190
|
+
const root = validHermesPath(info.root);
|
|
191
|
+
const home = validHermesPath(info.home);
|
|
192
|
+
if (typeof info.version === "string" && root && home) {
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
launcher: resolvedLauncher,
|
|
196
|
+
python,
|
|
197
|
+
version: info.version,
|
|
198
|
+
root,
|
|
199
|
+
home,
|
|
200
|
+
insecureBin: worldWritable(bin),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Try the next sibling interpreter.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { ok: false, launcher: resolvedLauncher, message: "Hermes Python cannot import the required credential-pool APIs; repair or update Hermes Agent" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Hermes itself owns the platform layout (native Windows `%LOCALAPPDATA%\hermes`,
|
|
212
|
+
* POSIX `~/.hermes`, or a Docker/custom `HERMES_HOME` root) — this never
|
|
213
|
+
* reimplements it, it only joins onto the root Hermes' own
|
|
214
|
+
* `hermes_constants.get_default_hermes_root` reported. That root resolution
|
|
215
|
+
* already collapses a `HERMES_HOME` pointed at a named profile
|
|
216
|
+
* (`<root>/profiles/other`) back to the root, so an explicit
|
|
217
|
+
* `--profile default` targets the root, never the active named profile.
|
|
218
|
+
*/
|
|
219
|
+
function targetHome(profile, root) {
|
|
220
|
+
return profile === "default" ? root : join(root, "profiles", profile);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Hermes refuses to mkdir a named profile home on purpose, so a deleted profile
|
|
225
|
+
* is not resurrected as an empty skeleton. The adapter never creates one either.
|
|
226
|
+
*/
|
|
227
|
+
function assertProfileExists(profile, home) {
|
|
228
|
+
if (profile === "default") return;
|
|
229
|
+
if (!existsSync(home)) {
|
|
230
|
+
fail(`Hermes profile "${profile}" does not exist; run \`hermes profile create ${profile}\` first`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function invokeBridge(hermes, home, request) {
|
|
235
|
+
const result = spawnSync(hermes.python, [BRIDGE], {
|
|
236
|
+
input: JSON.stringify(request),
|
|
237
|
+
encoding: "utf8",
|
|
238
|
+
timeout: 30000,
|
|
239
|
+
maxBuffer: 3 * 1024 * 1024,
|
|
240
|
+
env: { ...process.env, HERMES_HOME: home },
|
|
241
|
+
});
|
|
242
|
+
let response;
|
|
243
|
+
try {
|
|
244
|
+
response = JSON.parse(result.stdout || "");
|
|
245
|
+
} catch {
|
|
246
|
+
fail("Hermes bridge returned an invalid response");
|
|
247
|
+
}
|
|
248
|
+
if (result.status !== 0 || !response?.ok) {
|
|
249
|
+
const code = typeof response?.error?.code === "string" ? response.error.code : "bridge_failed";
|
|
250
|
+
const message = typeof response?.error?.message === "string" ? response.error.message : "Hermes bridge failed safely";
|
|
251
|
+
fail(`${code}: ${message}`);
|
|
252
|
+
}
|
|
253
|
+
return response;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function loadPluginConfig(configPath) {
|
|
257
|
+
const text = readLimited(configPath, MAX_CONFIG_BYTES, "OpenClaw config");
|
|
258
|
+
let config;
|
|
259
|
+
try {
|
|
260
|
+
config = JSON.parse(text);
|
|
261
|
+
} catch {
|
|
262
|
+
fail("OpenClaw config is malformed JSON");
|
|
263
|
+
}
|
|
264
|
+
return config?.plugins?.entries?.["multi-clawd"]?.config;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function tokenPathFor(account) {
|
|
268
|
+
return account.oauthTokenFile
|
|
269
|
+
? safePath(account.oauthTokenFile, `account ${account.id} oauthTokenFile`)
|
|
270
|
+
: undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveAccount(account, priority, core) {
|
|
274
|
+
const tokenPath = tokenPathFor(account);
|
|
275
|
+
const source = core.chooseHermesCredentialSource(account, {
|
|
276
|
+
oauthTokenFilePath: tokenPath,
|
|
277
|
+
existingPaths: [tokenPath].filter(Boolean).filter(existsSync),
|
|
278
|
+
});
|
|
279
|
+
const token = core.parseClaudeSetupToken(
|
|
280
|
+
readLimited(source.path, MAX_SETUP_TOKEN_BYTES, `setup token for account ${account.id}`, {
|
|
281
|
+
requirePrivate: true,
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
return {
|
|
285
|
+
account,
|
|
286
|
+
source,
|
|
287
|
+
ok: true,
|
|
288
|
+
credential: core.buildHermesManagedCredential(account, token, priority),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function diagnoseAccount(account, priority, core) {
|
|
293
|
+
try {
|
|
294
|
+
return resolveAccount(account, priority, core);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const tokenPath = (() => {
|
|
297
|
+
try {
|
|
298
|
+
return tokenPathFor(account);
|
|
299
|
+
} catch {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
return {
|
|
304
|
+
account,
|
|
305
|
+
source: { kind: "oauthTokenFile", path: tokenPath ?? "(not configured)" },
|
|
306
|
+
exists: Boolean(tokenPath) && existsSync(tokenPath),
|
|
307
|
+
ok: false,
|
|
308
|
+
error: error instanceof Error ? error.message : "credential source is unhealthy",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderSources(rows, unsupported) {
|
|
314
|
+
console.log("Account setup-token sources:");
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
if (row.ok) {
|
|
317
|
+
console.log(` ✓ ${row.account.id}: oauthTokenFile (${row.source.path}), priority ${row.credential.priority}`);
|
|
318
|
+
} else {
|
|
319
|
+
const state = row.exists ? "exists but is unhealthy" : "missing";
|
|
320
|
+
console.log(` ✗ ${row.account.id}: oauthTokenFile (${row.source.path}) ${state} — ${row.error}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const row of unsupported) console.log(` – ${row.id}: not importable — ${row.reason}`);
|
|
324
|
+
if (rows.length === 0) console.log(" (no importable accounts)");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderFindings(findings) {
|
|
328
|
+
const errors = Object.entries(findings?.errors || {}).filter(([, rows]) => rows?.length);
|
|
329
|
+
const warnings = Object.entries(findings?.warnings || {}).filter(([, rows]) => rows?.length);
|
|
330
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
331
|
+
console.log("Pool safety: no findings");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
for (const [name, rows] of errors) console.log(` ✗ ${name}: ${rows.length}`);
|
|
335
|
+
for (const [name, rows] of warnings) console.log(` ! ${name}: ${rows.length} (warning only; not multi-clawd's to fix)`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderPool(response) {
|
|
339
|
+
if (response.dryRun) {
|
|
340
|
+
// Nothing was written, so report today's value and what a real run would set.
|
|
341
|
+
const now = response.currentStrategy ?? "not configured";
|
|
342
|
+
console.log(
|
|
343
|
+
`Anthropic pool strategy: ${now}` +
|
|
344
|
+
(response.strategyChanged ? ` → ${response.strategy} (projected)` : ""),
|
|
345
|
+
);
|
|
346
|
+
} else {
|
|
347
|
+
const strategy = response.strategy ?? "not configured";
|
|
348
|
+
const effective = response.effectiveStrategy;
|
|
349
|
+
console.log(
|
|
350
|
+
`Anthropic pool strategy: ${strategy}${effective && effective !== strategy ? ` (effective: ${effective})` : ""}`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const rows = response.resultingRows || response.localRows || [];
|
|
354
|
+
const managed = rows.filter((row) => row?.managed);
|
|
355
|
+
console.log(`Managed rows: ${managed.length} of ${rows.length} profile-local rows`);
|
|
356
|
+
for (const row of managed) console.log(` ${row.label || "multi-clawd managed row"}: ${row.lastStatus || "ready"}`);
|
|
357
|
+
if (response.effectiveIncludesGlobalFallback) {
|
|
358
|
+
console.log(
|
|
359
|
+
" note: this profile owns no Anthropic rows yet, so Hermes currently reads the " +
|
|
360
|
+
`global pool (${response.effectiveRowCount} rows) as a read-only fallback; sync writes only here`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (response.orphanManagedRowCount > 0) {
|
|
364
|
+
console.log(
|
|
365
|
+
` note: ${response.orphanManagedRowCount} managed row(s) belong to accounts no longer ` +
|
|
366
|
+
"configured; they are preserved — remove them with Hermes if unwanted",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
renderFindings(response.findings);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function main() {
|
|
373
|
+
let core;
|
|
374
|
+
try {
|
|
375
|
+
core = await import(resolve(HERE, "..", "dist", "hermes-core.js"));
|
|
376
|
+
} catch {
|
|
377
|
+
fail("built dist/hermes-core.js is missing; reinstall the package or run npm run build");
|
|
378
|
+
}
|
|
379
|
+
const [command, ...args] = process.argv.slice(2);
|
|
380
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
381
|
+
usage();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const options = parseOptions(command, args, core);
|
|
385
|
+
if (options.help) {
|
|
386
|
+
usage();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Validate every account and read every token before anything is mutated.
|
|
391
|
+
const pluginConfig = loadPluginConfig(options.config);
|
|
392
|
+
const { accounts, unsupported } = core.collectHermesAccounts(pluginConfig?.accounts);
|
|
393
|
+
const priorities = core.hermesAccountPriorities(pluginConfig, accounts);
|
|
394
|
+
const sourceRows = accounts.map((account) =>
|
|
395
|
+
command === "doctor"
|
|
396
|
+
? diagnoseAccount(account, priorities.get(account.id) ?? 0, core)
|
|
397
|
+
: resolveAccount(account, priorities.get(account.id) ?? 0, core),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// Report what the OpenClaw side looks like before anything depends on the
|
|
401
|
+
// Hermes install, so diagnostics survive a machine without Hermes on it.
|
|
402
|
+
renderSources(sourceRows, unsupported);
|
|
403
|
+
|
|
404
|
+
const hermes = discoverHermes();
|
|
405
|
+
console.log(`Hermes: ${hermes.ok ? `v${hermes.version}` : "not ready"}`);
|
|
406
|
+
if (!hermes.ok) fail(hermes.message);
|
|
407
|
+
if (hermes.insecureBin) {
|
|
408
|
+
console.log(` ! ${dirname(hermes.launcher)} is writable by other users — anyone who can write there can replace the interpreter this sends tokens to`);
|
|
409
|
+
}
|
|
410
|
+
const home = targetHome(options.profile, hermes.root);
|
|
411
|
+
console.log(`Profile: ${options.profile} (${home})`);
|
|
412
|
+
assertProfileExists(options.profile, home);
|
|
413
|
+
|
|
414
|
+
if (command === "doctor") {
|
|
415
|
+
const response = invokeBridge(
|
|
416
|
+
hermes,
|
|
417
|
+
home,
|
|
418
|
+
core.buildHermesBridgeRequest({ operation: "doctor", targetHome: home }),
|
|
419
|
+
);
|
|
420
|
+
renderPool(response);
|
|
421
|
+
if (sourceRows.some((row) => !row.ok) || unsupported.length > 0 || !response.healthy) {
|
|
422
|
+
fail("Hermes doctor found unhealthy or unsupported credential sources, or unsafe pool state");
|
|
423
|
+
}
|
|
424
|
+
console.log("Hermes integration: healthy");
|
|
425
|
+
console.log(
|
|
426
|
+
" note: this checks files, pool/config structure, and integration safety only — it makes " +
|
|
427
|
+
"no live Anthropic request and cannot prove a setup token is still accepted; that surfaces " +
|
|
428
|
+
"at runtime through Hermes' own native 401/429 handling.",
|
|
429
|
+
);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (unsupported.length > 0) {
|
|
434
|
+
fail(
|
|
435
|
+
`${unsupported.length} configured account(s) cannot be imported into Hermes; ` +
|
|
436
|
+
"fix or remove them (listed above) and re-run — nothing was written",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
// Probe first so safety/config failures are reported before an apply request.
|
|
440
|
+
invokeBridge(hermes, home, core.buildHermesBridgeRequest({ operation: "probe", targetHome: home }));
|
|
441
|
+
const response = invokeBridge(
|
|
442
|
+
hermes,
|
|
443
|
+
home,
|
|
444
|
+
core.buildHermesBridgeRequest({
|
|
445
|
+
operation: "apply",
|
|
446
|
+
targetHome: home,
|
|
447
|
+
strategy: options.strategy,
|
|
448
|
+
dryRun: options.dryRun,
|
|
449
|
+
credentials: sourceRows.map((row) => row.credential),
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
const adds = response.actions.filter((row) => row.action === "add").length;
|
|
453
|
+
const updates = response.actions.filter((row) => row.action === "update").length;
|
|
454
|
+
const noops = response.actions.filter((row) => row.action === "noop").length;
|
|
455
|
+
const strategyNote = response.strategyChanged
|
|
456
|
+
? " (changed)"
|
|
457
|
+
: response.requestedStrategy === null
|
|
458
|
+
? " (preserved)"
|
|
459
|
+
: "";
|
|
460
|
+
console.log(`Sync: add ${adds}, update ${updates}, noop ${noops}; strategy ${response.strategy}${strategyNote}`);
|
|
461
|
+
if (options.dryRun) console.log(`DRY RUN: no files were written${response.wouldWrite ? "; changes would be made" : "; already in sync"}.`);
|
|
462
|
+
else console.log(response.wrote ? "Hermes credentials synchronized." : "Hermes credentials already in sync; no files written.");
|
|
463
|
+
renderPool(response);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
main().catch((error) => {
|
|
467
|
+
console.error(`hermes: ${error instanceof Error ? error.message : "operation failed safely"}`);
|
|
468
|
+
process.exitCode = 1;
|
|
469
|
+
});
|