@drakon-systems/multi-clawd 1.7.1 → 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 +171 -5
- package/dist/account-env.js +8 -2
- package/dist/chain-audit.js +2 -2
- package/dist/health.js +29 -1
- package/dist/hermes-core.js +258 -0
- package/dist/index.js +1 -1
- package/dist/update-core.js +19 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/scripts/cli.mjs +5 -0
- package/scripts/doctor.mjs +37 -1
- package/scripts/hermes.mjs +469 -0
- package/scripts/hermes_bridge.py +745 -0
package/README.md
CHANGED
|
@@ -54,6 +54,136 @@ install anything? Every command also runs as
|
|
|
54
54
|
|
|
55
55
|
Then `multi-clawd explain` shows you exactly what you built.
|
|
56
56
|
|
|
57
|
+
## Hermes Agent integration
|
|
58
|
+
|
|
59
|
+
multi-clawd can also import the configured Claude subscriptions into Hermes
|
|
60
|
+
Agent's native Anthropic credential pool. Install and set up
|
|
61
|
+
[Hermes Agent](https://hermes-agent.nousresearch.com/docs/) first, keep
|
|
62
|
+
`hermes` on `PATH`, then preview and apply the import:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
multi-clawd hermes sync --dry-run
|
|
66
|
+
multi-clawd hermes sync --strategy round_robin
|
|
67
|
+
multi-clawd hermes doctor
|
|
68
|
+
|
|
69
|
+
# An existing Hermes profile (create it first: hermes profile create work):
|
|
70
|
+
multi-clawd hermes sync --profile work --strategy least_used
|
|
71
|
+
|
|
72
|
+
# Read a non-default OpenClaw configuration:
|
|
73
|
+
multi-clawd hermes doctor --config ~/configs/openclaw.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Validated against Hermes Agent **0.19.1**.
|
|
77
|
+
|
|
78
|
+
### Only stable setup tokens are imported
|
|
79
|
+
|
|
80
|
+
The one credential this adapter will copy is an account's `oauthTokenFile` — a
|
|
81
|
+
plain `claude setup-token` value, the same string multi-clawd already passes to
|
|
82
|
+
Claude Code as `CLAUDE_CODE_OAUTH_TOKEN`. It carries no refresh token and
|
|
83
|
+
nothing rotates it, so a second copy in Hermes stays valid.
|
|
84
|
+
|
|
85
|
+
Everything else is refused, on purpose:
|
|
86
|
+
|
|
87
|
+
- **`native` logins are not importable, but need nothing imported.** A native
|
|
88
|
+
login is a `.credentials.json` file holding a *rotating* OAuth grant whose
|
|
89
|
+
refresh token is single-use, so copying it into Hermes and the next refresh —
|
|
90
|
+
by Claude Code, or by Hermes itself — would invalidate the other copy, which
|
|
91
|
+
Hermes then marks dead and drops from rotation. It doesn't need copying
|
|
92
|
+
anyway: Hermes' own `claude_code` credential source already reads that exact
|
|
93
|
+
same native `~/.claude/.credentials.json` directly, so leave a native account
|
|
94
|
+
on that source instead of duplicating the grant.
|
|
95
|
+
- **`configDir` logins are not importable, and Hermes cannot be pointed at
|
|
96
|
+
them either.** The same single-use-refresh-token problem applies, and unlike
|
|
97
|
+
a native login there is no Hermes-side fallback: as of Hermes Agent 0.19.1,
|
|
98
|
+
its `claude_code` credential source reads only the native path above, never
|
|
99
|
+
an arbitrary `configDir`. A `configDir` account can only reach Hermes' pool
|
|
100
|
+
by getting its own `oauthTokenFile` (a `claude setup-token`, same as above),
|
|
101
|
+
or it stays OpenClaw-only.
|
|
102
|
+
- **`oauthTokenRef` is never resolved.** Reading a gateway secret reference
|
|
103
|
+
here would turn a reference into a copied plaintext secret in a second store;
|
|
104
|
+
the adapter fails closed and says so. An account may carry a `configDir`
|
|
105
|
+
alongside its `oauthTokenFile` — it is simply ignored for Hermes.
|
|
106
|
+
|
|
107
|
+
`doctor` names every account it cannot import and why; `sync` refuses to write
|
|
108
|
+
anything until they are fixed or removed. **Re-run `sync` whenever a
|
|
109
|
+
setup-token file changes** — nothing propagates a new token automatically.
|
|
110
|
+
|
|
111
|
+
### What sync does
|
|
112
|
+
|
|
113
|
+
Accounts are read from `plugins.entries["multi-clawd"].config.accounts` in
|
|
114
|
+
`~/.openclaw/openclaw.json` by default. Every account is validated and every
|
|
115
|
+
token file read and parsed before Hermes is asked to write anything.
|
|
116
|
+
|
|
117
|
+
- Only deterministic rows labelled `multi-clawd:<account-id>` are added or
|
|
118
|
+
updated, and only those rows are sent to Hermes — it merges them into the
|
|
119
|
+
pool under its own lock, so unrelated credentials are never read, copied, or
|
|
120
|
+
rewritten. The operation is idempotent.
|
|
121
|
+
- Row priority follows your `pool.accounts` preference order (home account
|
|
122
|
+
first), which is the order Hermes' `fill_first` drains in. Accounts absent
|
|
123
|
+
from `pool.accounts` follow in `accounts[]` order.
|
|
124
|
+
- `--strategy` sets Hermes' Anthropic pool strategy to one of `fill_first`,
|
|
125
|
+
`round_robin`, `random`, `least_used`. **Omit it and your configured strategy
|
|
126
|
+
is left alone** (`fill_first` only when nothing is set at all).
|
|
127
|
+
- `--dry-run` runs the same validation and planning and writes nothing.
|
|
128
|
+
- Planning always reads the target home's *own* pool. A Hermes profile with no
|
|
129
|
+
Anthropic rows yet falls back to reading the global pool — `doctor` labels
|
|
130
|
+
that as an effective-only view, and sync never copies those rows into the
|
|
131
|
+
profile.
|
|
132
|
+
- Named profiles must already exist; the adapter never creates one, so a
|
|
133
|
+
deleted profile is not resurrected as an empty skeleton. Run
|
|
134
|
+
`hermes profile create <name>` first. The target home is whatever Hermes
|
|
135
|
+
itself reports as its root (via its own `hermes_constants` module) — never
|
|
136
|
+
reimplemented here — which is `~/.hermes` on POSIX, `%LOCALAPPDATA%\hermes`
|
|
137
|
+
on native Windows, or a Docker/custom `HERMES_HOME` root; a named profile is
|
|
138
|
+
`<root>/profiles/<name>`. `--profile default` always targets that root, even
|
|
139
|
+
when the active `HERMES_HOME` currently points at another profile.
|
|
140
|
+
- Only multi-clawd's own broken state (duplicate or malformed managed rows)
|
|
141
|
+
blocks a sync. Several `claude_code` rows, or another tool's malformed row,
|
|
142
|
+
are reported as warnings — they are legitimate states and not this plugin's
|
|
143
|
+
to fix.
|
|
144
|
+
|
|
145
|
+
The pool and the config are two separate Hermes files. Each write is atomic on
|
|
146
|
+
its own and both are verified by re-reading afterwards; the pair is not atomic,
|
|
147
|
+
so the pool is written first and both are idempotent — an interruption between
|
|
148
|
+
them leaves a state that re-running `sync` repairs, rather than one needing a
|
|
149
|
+
rollback.
|
|
150
|
+
|
|
151
|
+
### What this integration is (and is not)
|
|
152
|
+
|
|
153
|
+
Hermes talks to the **native Anthropic Messages API** with Hermes' own tool
|
|
154
|
+
system. It does not run the OpenClaw plugin backend or the Claude Code harness,
|
|
155
|
+
so OpenClaw skills/MCP wiring and Claude Code's built-in harness are not copied
|
|
156
|
+
across. multi-clawd supplies the account credentials and the configured Hermes
|
|
157
|
+
pool distribution only. Hermes then provides native reactive failover and the
|
|
158
|
+
selected distribution strategy. Proactive near-limit telemetry/rotation from
|
|
159
|
+
multi-clawd's OpenClaw pool is **not implemented for Hermes yet**.
|
|
160
|
+
|
|
161
|
+
### Security model
|
|
162
|
+
|
|
163
|
+
Tokens are read from real files with strict size limits, parsed locally, and
|
|
164
|
+
sent to the bundled Python bridge only as one JSON document on **stdin**. They
|
|
165
|
+
are never placed in argv, exported as environment variables, or printed to
|
|
166
|
+
stdout/stderr — no token, no prefix, no fingerprint — and command errors use
|
|
167
|
+
fixed safe messages. On POSIX, a token source file must be private —
|
|
168
|
+
group/other-readable or -writable (any `chmod` bits beyond `600`) is refused
|
|
169
|
+
outright, not just warned about, checked via `fstat` on the already-open file
|
|
170
|
+
descriptor so there is no gap between the check and the read (Windows has no
|
|
171
|
+
equivalent bit layout, so the check is a no-op there; the config JSON is read
|
|
172
|
+
through the same code path but never needs to be private). The bridge uses
|
|
173
|
+
Hermes' installed Python APIs directly (no shell-string execution), writes
|
|
174
|
+
only inside the selected `HERMES_HOME`, and never echoes identifiers or other
|
|
175
|
+
fields belonging to a row it doesn't manage — an unrelated row is reported by
|
|
176
|
+
position only, even when its own source/label/status fields are stuffed with
|
|
177
|
+
a token-like string. Paths reject NUL bytes, expand only a leading `~/`, and
|
|
178
|
+
profile names cannot contain traversal or separators.
|
|
179
|
+
|
|
180
|
+
`doctor` validates files, account configuration, and the shape of Hermes' pool
|
|
181
|
+
and config on disk — it makes **no live request to Anthropic** and cannot
|
|
182
|
+
prove a setup token is still accepted. A token can pass every doctor check and
|
|
183
|
+
still be expired or revoked; that is discovered at runtime, where Hermes' own
|
|
184
|
+
native 401/429 handling takes over exactly as it does for any other credential
|
|
185
|
+
in its pool.
|
|
186
|
+
|
|
57
187
|
## Why
|
|
58
188
|
|
|
59
189
|
OpenClaw's bundled `claude-cli` backend runs Claude Code on a **single**
|
|
@@ -182,6 +312,21 @@ restart, and finishes with a doctor health check.
|
|
|
182
312
|
> plugin*, which is a confusing way to be told nothing is wrong. `multi-clawd
|
|
183
313
|
> version` shows both halves, and since v1.6.0 says plainly whether the pair
|
|
184
314
|
> is a problem.
|
|
315
|
+
>
|
|
316
|
+
> ### ⚠️ `openclaw plugins update --all` cannot move this plugin
|
|
317
|
+
>
|
|
318
|
+
> It will tell you the plugin is up to date when it is not. We install with
|
|
319
|
+
> `--pin` (OpenClaw's own security audit raises a HIGH finding for unpinned
|
|
320
|
+
> install specs, so pinning is the right side of that trade), and OpenClaw
|
|
321
|
+
> resolves registry metadata *for the pinned spec* — comparing 1.6.0 against
|
|
322
|
+
> 1.6.0 and returning "up to date" while a newer version sits on npm. The
|
|
323
|
+
> honest "pinned to X; registry default resolves to Y" message exists in
|
|
324
|
+
> OpenClaw but is built inside its `--dry-run` branch, so a real update run
|
|
325
|
+
> never prints it.
|
|
326
|
+
>
|
|
327
|
+
> **Use `multi-clawd update`.** Since v1.7.2 `doctor` also checks the registry
|
|
328
|
+
> itself (cached, and silent when offline), so a lagging plugin is reported
|
|
329
|
+
> rather than left to a command that reassures you wrongly.
|
|
185
330
|
|
|
186
331
|
Prefer the raw form?
|
|
187
332
|
|
|
@@ -219,7 +364,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
219
364
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
220
365
|
Code on the target machine already? Paste it this and go make coffee:
|
|
221
366
|
|
|
222
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.7.
|
|
367
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.7.3/SETUP-AGENT.md
|
|
223
368
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
224
369
|
> Claude account — ask me when you need me to log in.
|
|
225
370
|
|
|
@@ -295,9 +440,8 @@ dir* — a separate Claude "app" — so the logins can never clobber each other.
|
|
|
295
440
|
|
|
296
441
|
```bash
|
|
297
442
|
mkdir -p ~/.claw2 && chmod 700 ~/.claw2
|
|
298
|
-
CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token # log in as the 2nd account
|
|
299
|
-
|
|
300
|
-
# ~/.claw2/oauth-token
|
|
443
|
+
CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token > ~/.claw2/oauth-token # log in as the 2nd account
|
|
444
|
+
chmod 600 ~/.claw2/oauth-token
|
|
301
445
|
```
|
|
302
446
|
|
|
303
447
|
**Windows (native, PowerShell):**
|
|
@@ -405,11 +549,25 @@ How it decides, per launch (all data from each account's live
|
|
|
405
549
|
| Account state | Effect |
|
|
406
550
|
|---|---|
|
|
407
551
|
| `rejected` + reset in the future | skipped until `resetsAt` passes |
|
|
552
|
+
| `rejected` with no reset, on a named window | skipped for an hour, then re-probed (v1.7.2) |
|
|
553
|
+
| `rejected` on the `unknown` window | still used — that is where a single-model limit lands, and it must not strand the account |
|
|
408
554
|
| any window utilization ≥ threshold | skipped (nearly maxed — the point of the pool) |
|
|
409
|
-
| `allowed_warning`
|
|
555
|
+
| `allowed_warning` with a number below threshold | still used — trust the number |
|
|
556
|
+
| `allowed_warning` and **no** number, on a short window | skipped (v1.7.2) — see below |
|
|
557
|
+
| `allowed_warning` and no number, on the weekly window | still used — weekly warns from ~30% and would flap |
|
|
410
558
|
| no data / stale data | used — never rotate on missing evidence |
|
|
411
559
|
| whole pool exhausted | home account anyway → real limit error → your chain drops provider |
|
|
412
560
|
|
|
561
|
+
**Why the short-window rule exists (v1.7.2).** The threshold rule needs a
|
|
562
|
+
utilization number, and Anthropic does not send one for the 5-hour session
|
|
563
|
+
window — across every observation we hold from two accounts it arrives as a
|
|
564
|
+
bare status plus a reset time, while the weekly windows carry percentages. A
|
|
565
|
+
rule that waits for a number therefore could never pre-empt the session
|
|
566
|
+
limit: the pool would take the hit and rotate afterwards. So on hour-scoped
|
|
567
|
+
windows the warning counts on its own. It stays narrow deliberately — a
|
|
568
|
+
reported number always wins over the status, and a warning from a window that
|
|
569
|
+
has since reset is void, exactly like a stale percentage.
|
|
570
|
+
|
|
413
571
|
Notes:
|
|
414
572
|
|
|
415
573
|
- Rotation happens at limit boundaries only. A mid-conversation handover
|
|
@@ -504,6 +662,14 @@ Housekeeping:
|
|
|
504
662
|
- Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
|
|
505
663
|
file; when a file is used, keep it `0600` (POSIX) or locked to your user
|
|
506
664
|
with `icacls` (Windows).
|
|
665
|
+
- **Credential resolution fails closed (v1.7.3).** An account that declares
|
|
666
|
+
`oauthTokenRef` or `oauthTokenFile` is authenticated by that token. If it
|
|
667
|
+
resolves to nothing — provider briefly unavailable, empty secret, truncated
|
|
668
|
+
file — and the account has no `configDir` to fall back on, the launch is
|
|
669
|
+
refused with `declares a token source but none resolved` rather than
|
|
670
|
+
allowed to proceed on the machine's default login and spend a different
|
|
671
|
+
account's quota under this account's name. Native accounts are exempt: the
|
|
672
|
+
default login *is* their credential.
|
|
507
673
|
- Migrating a token file into a vault? `op read` (and most secret CLIs)
|
|
508
674
|
append a trailing newline on output — resolution trims the resolved
|
|
509
675
|
value (guaranteed in `token-resolution.ts`), so a file-vs-vault diff
|
package/dist/account-env.js
CHANGED
|
@@ -12,11 +12,17 @@ export function buildAccountChildEnv(account, token, stateFile) {
|
|
|
12
12
|
MULTI_CLAWD_ACCOUNT_ID: account.id,
|
|
13
13
|
MULTI_CLAWD_STATE_FILE: stateFile,
|
|
14
14
|
};
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const usableToken = token?.trim() ? token : undefined;
|
|
16
|
+
if (usableToken)
|
|
17
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = usableToken;
|
|
17
18
|
if (!account.native && account.configDir) {
|
|
18
19
|
env.CLAUDE_CONFIG_DIR = expandHomePath(account.configDir);
|
|
19
20
|
}
|
|
21
|
+
const declaresToken = Boolean(account.oauthTokenFile) || Boolean(account.oauthTokenRef);
|
|
22
|
+
if (!account.native && declaresToken && !usableToken && !account.configDir) {
|
|
23
|
+
throw new Error(`[multi-clawd] account "${account.id}" declares a token source but none resolved — ` +
|
|
24
|
+
`refusing to launch on the default login`);
|
|
25
|
+
}
|
|
20
26
|
return env;
|
|
21
27
|
}
|
|
22
28
|
export function tokenFileModeWarning(path, mode) {
|
package/dist/chain-audit.js
CHANGED
|
@@ -25,7 +25,7 @@ function parseRef(ref) {
|
|
|
25
25
|
const idx = ref.indexOf("/");
|
|
26
26
|
if (idx < 0)
|
|
27
27
|
return { modelId: ref };
|
|
28
|
-
return { provider: ref.slice(0, idx), modelId: ref.slice(idx + 1) };
|
|
28
|
+
return { provider: ref.slice(0, idx).trim().toLowerCase(), modelId: ref.slice(idx + 1) };
|
|
29
29
|
}
|
|
30
30
|
export function offPoolClaudeRef(ref, poolId) {
|
|
31
31
|
if (typeof ref !== "string" || ref.length === 0)
|
|
@@ -35,7 +35,7 @@ export function offPoolClaudeRef(ref, poolId) {
|
|
|
35
35
|
return null;
|
|
36
36
|
if (provider === undefined)
|
|
37
37
|
return null;
|
|
38
|
-
if (provider === poolId)
|
|
38
|
+
if (provider === poolId.trim().toLowerCase())
|
|
39
39
|
return null;
|
|
40
40
|
if (provider === "anthropic" || provider === "claude-cli")
|
|
41
41
|
return "strong";
|
package/dist/health.js
CHANGED
|
@@ -3,6 +3,17 @@ const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
|
|
|
3
3
|
const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
|
4
4
|
export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
5
5
|
const MODEL_WINDOW_PREFIX = "model:";
|
|
6
|
+
const SHORT_WINDOW_PATTERN = /(^|_)hours?(_|$)/;
|
|
7
|
+
export function isShortWindow(window) {
|
|
8
|
+
return SHORT_WINDOW_PATTERN.test(window);
|
|
9
|
+
}
|
|
10
|
+
const PERIOD_WINDOW_PATTERN = /(^|_)(minutes?|hours?|days?|weeks?|months?)(_|$)/;
|
|
11
|
+
export function isPeriodWindow(window) {
|
|
12
|
+
return PERIOD_WINDOW_PATTERN.test(window);
|
|
13
|
+
}
|
|
14
|
+
export function isWarningStatus(status) {
|
|
15
|
+
return /warning/i.test(status);
|
|
16
|
+
}
|
|
6
17
|
export const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
|
|
7
18
|
export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
8
19
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
@@ -51,13 +62,20 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
51
62
|
if (!fresh)
|
|
52
63
|
continue;
|
|
53
64
|
hasLiveEvidence = true;
|
|
54
|
-
if (w.status === "rejected" && resetBearing) {
|
|
65
|
+
if (w.status === "rejected" && resetBearing && isPeriodWindow(window)) {
|
|
55
66
|
return {
|
|
56
67
|
verdict: "exhausted",
|
|
57
68
|
resumeAt: resetMs,
|
|
58
69
|
reason: `${window} rejected until ${new Date(resetMs).toISOString()}`,
|
|
59
70
|
};
|
|
60
71
|
}
|
|
72
|
+
if (w.status === "rejected" && resetMs === undefined && isPeriodWindow(window)) {
|
|
73
|
+
return {
|
|
74
|
+
verdict: "exhausted",
|
|
75
|
+
resumeAt: w.seenAt + MODEL_REJECTED_TTL_MS,
|
|
76
|
+
reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
61
79
|
if (worst.verdict === "ok" &&
|
|
62
80
|
typeof w.utilization === "number" &&
|
|
63
81
|
w.utilization >= threshold &&
|
|
@@ -67,6 +85,16 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
67
85
|
reason: `${window} utilization ${w.utilization} >= ${threshold}`,
|
|
68
86
|
};
|
|
69
87
|
}
|
|
88
|
+
if (worst.verdict === "ok" &&
|
|
89
|
+
typeof w.utilization !== "number" &&
|
|
90
|
+
isShortWindow(window) &&
|
|
91
|
+
isWarningStatus(w.status) &&
|
|
92
|
+
(resetBearing || resetMs === undefined)) {
|
|
93
|
+
worst = {
|
|
94
|
+
verdict: "near_limit",
|
|
95
|
+
reason: `${window} reported ${w.status} with no utilization — short window, treated as near-limit`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
70
98
|
}
|
|
71
99
|
if (worst.verdict === "ok" && !hasLiveEvidence)
|
|
72
100
|
return { verdict: "no_data" };
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const HERMES_MANAGED_ID_PREFIX = "multi-clawd-";
|
|
3
|
+
export const HERMES_MANAGED_SOURCE = "manual:multi-clawd";
|
|
4
|
+
export const HERMES_SUPPORTED_STRATEGIES = [
|
|
5
|
+
"fill_first",
|
|
6
|
+
"round_robin",
|
|
7
|
+
"random",
|
|
8
|
+
"least_used",
|
|
9
|
+
];
|
|
10
|
+
export const HERMES_DEFAULT_STRATEGY = "fill_first";
|
|
11
|
+
export const HERMES_MAX_SETUP_TOKEN_BYTES = 8 * 1024;
|
|
12
|
+
export class HermesAdapterError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "HermesAdapterError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
21
|
+
const ACCOUNT_RE = PROFILE_RE;
|
|
22
|
+
const RESERVED_PROFILES = new Set(["hermes", "test", "tmp", "root", "sudo"]);
|
|
23
|
+
const SETUP_TOKEN_RE = /^sk-ant-oat\d{2,}-[!-~]+$/;
|
|
24
|
+
const API_KEY_PREFIX = "sk-ant-api";
|
|
25
|
+
const NATIVE_SETUP_TOKEN_GUIDANCE = "add an oauthTokenFile holding a `claude setup-token` value, or let Hermes read the native " +
|
|
26
|
+
"login itself — its own claude_code credential source reads ~/.claude/.credentials.json directly";
|
|
27
|
+
const CONFIGDIR_SETUP_TOKEN_GUIDANCE = "Hermes' claude_code credential source only reads the native ~/.claude/.credentials.json, never " +
|
|
28
|
+
"an arbitrary configDir, so this account cannot be pointed at Hermes directly — add its own " +
|
|
29
|
+
"oauthTokenFile holding a `claude setup-token` value, or leave it OpenClaw-only";
|
|
30
|
+
function fail(code, message) {
|
|
31
|
+
throw new HermesAdapterError(code, message);
|
|
32
|
+
}
|
|
33
|
+
function asRecord(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
35
|
+
? value
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
38
|
+
function optionalNonEmptyString(value, code) {
|
|
39
|
+
if (value === undefined || value === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof value !== "string" || !value.trim())
|
|
42
|
+
fail(code, "account configuration is malformed");
|
|
43
|
+
return value.trim();
|
|
44
|
+
}
|
|
45
|
+
export function validateHermesStrategy(value) {
|
|
46
|
+
if (typeof value !== "string") {
|
|
47
|
+
fail("invalid_strategy", `strategy must be one of: ${HERMES_SUPPORTED_STRATEGIES.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
const normalized = value.trim().toLowerCase();
|
|
50
|
+
if (!HERMES_SUPPORTED_STRATEGIES.includes(normalized)) {
|
|
51
|
+
fail("invalid_strategy", `strategy must be one of: ${HERMES_SUPPORTED_STRATEGIES.join(", ")}`);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
export function validateHermesProfileName(value) {
|
|
56
|
+
if (typeof value !== "string")
|
|
57
|
+
fail("invalid_profile", "profile name is invalid");
|
|
58
|
+
const normalized = value.trim().toLowerCase();
|
|
59
|
+
if (!PROFILE_RE.test(normalized) || (normalized !== "default" && RESERVED_PROFILES.has(normalized))) {
|
|
60
|
+
fail("invalid_profile", "profile name must be a safe Hermes profile identifier");
|
|
61
|
+
}
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
export function validateHermesAccount(value) {
|
|
65
|
+
const row = asRecord(value);
|
|
66
|
+
if (!row || typeof row.id !== "string" || !row.id.trim()) {
|
|
67
|
+
fail("malformed_account", "account configuration is malformed");
|
|
68
|
+
}
|
|
69
|
+
const oauthTokenRef = asRecord(row.oauthTokenRef);
|
|
70
|
+
if (row.oauthTokenRef !== undefined && !oauthTokenRef) {
|
|
71
|
+
fail("malformed_account", "account configuration is malformed");
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
id: row.id.trim().toLowerCase(),
|
|
75
|
+
label: optionalNonEmptyString(row.label, "malformed_account"),
|
|
76
|
+
native: row.native === true || undefined,
|
|
77
|
+
configDir: optionalNonEmptyString(row.configDir, "malformed_account"),
|
|
78
|
+
oauthTokenFile: optionalNonEmptyString(row.oauthTokenFile, "malformed_account"),
|
|
79
|
+
oauthTokenRef,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function describeHermesAccountSupport(account) {
|
|
83
|
+
if (!ACCOUNT_RE.test(account.id)) {
|
|
84
|
+
return {
|
|
85
|
+
supported: false,
|
|
86
|
+
code: "unsupported_account_id",
|
|
87
|
+
reason: "account id must be lowercase letters, digits, '_' or '-' (max 64 chars) to become a Hermes credential id",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (account.oauthTokenFile)
|
|
91
|
+
return { supported: true };
|
|
92
|
+
if (account.native) {
|
|
93
|
+
return {
|
|
94
|
+
supported: false,
|
|
95
|
+
code: "native_not_supported",
|
|
96
|
+
reason: "a native Claude login is a rotating single-use grant, unsafe to duplicate — " +
|
|
97
|
+
NATIVE_SETUP_TOKEN_GUIDANCE,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (account.oauthTokenRef) {
|
|
101
|
+
return {
|
|
102
|
+
supported: false,
|
|
103
|
+
code: "secret_ref_not_supported",
|
|
104
|
+
reason: "oauthTokenRef is never resolved here — a secret reference must not be turned into a " +
|
|
105
|
+
`copied plaintext secret; ${NATIVE_SETUP_TOKEN_GUIDANCE}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (account.configDir) {
|
|
109
|
+
return {
|
|
110
|
+
supported: false,
|
|
111
|
+
code: "setup_token_file_required",
|
|
112
|
+
reason: "a configDir login is a rotating single-use grant, unsafe to duplicate, and " +
|
|
113
|
+
CONFIGDIR_SETUP_TOKEN_GUIDANCE,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
supported: false,
|
|
118
|
+
code: "setup_token_file_required",
|
|
119
|
+
reason: `no importable Hermes credential source — ${NATIVE_SETUP_TOKEN_GUIDANCE}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function collectHermesAccounts(values) {
|
|
123
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
124
|
+
fail("no_accounts", "no multi-clawd accounts are configured");
|
|
125
|
+
}
|
|
126
|
+
const accounts = [];
|
|
127
|
+
const unsupported = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
for (const value of values) {
|
|
130
|
+
const account = validateHermesAccount(value);
|
|
131
|
+
if (seen.has(account.id)) {
|
|
132
|
+
unsupported.push({
|
|
133
|
+
id: account.id,
|
|
134
|
+
code: "duplicate_account",
|
|
135
|
+
reason: "duplicate account id — remove the repeated entry from the OpenClaw config",
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
seen.add(account.id);
|
|
140
|
+
const support = describeHermesAccountSupport(account);
|
|
141
|
+
if (support.supported)
|
|
142
|
+
accounts.push(account);
|
|
143
|
+
else
|
|
144
|
+
unsupported.push({ id: account.id, code: support.code, reason: support.reason });
|
|
145
|
+
}
|
|
146
|
+
return { accounts, unsupported };
|
|
147
|
+
}
|
|
148
|
+
export function hermesAccountPriorities(pluginConfig, accounts) {
|
|
149
|
+
const known = new Set(accounts.map((account) => account.id));
|
|
150
|
+
const ordered = [];
|
|
151
|
+
const push = (raw) => {
|
|
152
|
+
if (typeof raw !== "string")
|
|
153
|
+
return;
|
|
154
|
+
const id = raw.trim().toLowerCase();
|
|
155
|
+
if (!known.has(id) || ordered.includes(id))
|
|
156
|
+
return;
|
|
157
|
+
ordered.push(id);
|
|
158
|
+
};
|
|
159
|
+
const config = asRecord(pluginConfig);
|
|
160
|
+
const pools = Array.isArray(config?.pool) ? config.pool : [config?.pool];
|
|
161
|
+
for (const entry of pools) {
|
|
162
|
+
const preference = asRecord(entry)?.accounts;
|
|
163
|
+
if (Array.isArray(preference))
|
|
164
|
+
for (const id of preference)
|
|
165
|
+
push(id);
|
|
166
|
+
}
|
|
167
|
+
for (const account of accounts)
|
|
168
|
+
push(account.id);
|
|
169
|
+
return new Map(ordered.map((id, index) => [id, index]));
|
|
170
|
+
}
|
|
171
|
+
export function chooseHermesCredentialSource(accountValue, candidates) {
|
|
172
|
+
const account = validateHermesAccount(accountValue);
|
|
173
|
+
const support = describeHermesAccountSupport(account);
|
|
174
|
+
if (!support.supported)
|
|
175
|
+
fail(support.code, support.reason);
|
|
176
|
+
if (!candidates.oauthTokenFilePath) {
|
|
177
|
+
fail("setup_token_file_required", `no importable Hermes credential source — ${NATIVE_SETUP_TOKEN_GUIDANCE}`);
|
|
178
|
+
}
|
|
179
|
+
if (!candidates.existingPaths.includes(candidates.oauthTokenFilePath)) {
|
|
180
|
+
fail("setup_token_file_missing", "the configured oauthTokenFile does not exist");
|
|
181
|
+
}
|
|
182
|
+
return { kind: "oauthTokenFile", path: candidates.oauthTokenFilePath };
|
|
183
|
+
}
|
|
184
|
+
export function parseClaudeSetupToken(text) {
|
|
185
|
+
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > HERMES_MAX_SETUP_TOKEN_BYTES) {
|
|
186
|
+
fail("malformed_setup_token", "the setup-token file is unreadable or too large");
|
|
187
|
+
}
|
|
188
|
+
const lines = text.split("\n");
|
|
189
|
+
if (lines.length > 1 && lines[lines.length - 1] === "")
|
|
190
|
+
lines.pop();
|
|
191
|
+
if (lines.length !== 1) {
|
|
192
|
+
fail("malformed_setup_token", "the setup-token file must hold exactly one line");
|
|
193
|
+
}
|
|
194
|
+
let token = lines[0];
|
|
195
|
+
if (token.endsWith("\r"))
|
|
196
|
+
token = token.slice(0, -1);
|
|
197
|
+
if (!token)
|
|
198
|
+
fail("malformed_setup_token", "the setup-token file is empty");
|
|
199
|
+
if (token.startsWith("{") || token.startsWith("[")) {
|
|
200
|
+
fail("rotating_grant_not_supported", "that file holds a JSON Claude credentials grant, not a setup token — rotating grants are " +
|
|
201
|
+
"single-use and unsafe to duplicate. If this is your native ~/.claude login, it needs no " +
|
|
202
|
+
"copy at all — Hermes' own claude_code credential source already reads that file directly. " +
|
|
203
|
+
"A configDir login has no such fallback (claude_code cannot be pointed at a configDir) and " +
|
|
204
|
+
"needs its own oauthTokenFile setup token instead");
|
|
205
|
+
}
|
|
206
|
+
if (token.startsWith(API_KEY_PREFIX)) {
|
|
207
|
+
fail("malformed_setup_token", "that looks like a Claude API key, not a setup token — setup tokens start with sk-ant-oat");
|
|
208
|
+
}
|
|
209
|
+
if (!SETUP_TOKEN_RE.test(token)) {
|
|
210
|
+
fail("malformed_setup_token", "the setup token does not match the expected sk-ant-oat… shape");
|
|
211
|
+
}
|
|
212
|
+
return token;
|
|
213
|
+
}
|
|
214
|
+
export function stableHermesCredentialId(accountId) {
|
|
215
|
+
const account = validateHermesAccount({ id: accountId });
|
|
216
|
+
if (!ACCOUNT_RE.test(account.id)) {
|
|
217
|
+
fail("unsupported_account_id", "account id cannot be turned into a Hermes credential id");
|
|
218
|
+
}
|
|
219
|
+
const digest = createHash("sha256").update(`multi-clawd/hermes/${account.id}`, "utf8").digest("hex");
|
|
220
|
+
return `${HERMES_MANAGED_ID_PREFIX}${digest.slice(0, 16)}`;
|
|
221
|
+
}
|
|
222
|
+
export function buildHermesManagedCredential(accountValue, setupToken, priority) {
|
|
223
|
+
const account = validateHermesAccount(accountValue);
|
|
224
|
+
if (typeof setupToken !== "string" || !setupToken) {
|
|
225
|
+
fail("malformed_setup_token", "the setup token is missing");
|
|
226
|
+
}
|
|
227
|
+
if (!Number.isSafeInteger(priority) || priority < 0) {
|
|
228
|
+
fail("invalid_priority", "credential priority must be a non-negative integer");
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
accountId: account.id,
|
|
232
|
+
id: stableHermesCredentialId(account.id),
|
|
233
|
+
label: `multi-clawd:${account.id}`,
|
|
234
|
+
source: HERMES_MANAGED_SOURCE,
|
|
235
|
+
authType: "oauth",
|
|
236
|
+
accessToken: setupToken,
|
|
237
|
+
priority,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
export function buildHermesBridgeRequest(input) {
|
|
241
|
+
if (!["probe", "doctor", "apply"].includes(input.operation)) {
|
|
242
|
+
fail("unsupported_operation", "operation must be probe, doctor, or apply");
|
|
243
|
+
}
|
|
244
|
+
if (typeof input.targetHome !== "string" || !input.targetHome) {
|
|
245
|
+
fail("invalid_target_home", "the target Hermes home is invalid");
|
|
246
|
+
}
|
|
247
|
+
const request = {
|
|
248
|
+
operation: input.operation,
|
|
249
|
+
targetHome: input.targetHome,
|
|
250
|
+
};
|
|
251
|
+
if (input.operation !== "apply")
|
|
252
|
+
return request;
|
|
253
|
+
request.dryRun = input.dryRun === true;
|
|
254
|
+
request.credentials = (input.credentials ?? []).map((credential) => ({ ...credential }));
|
|
255
|
+
if (input.strategy !== undefined)
|
|
256
|
+
request.strategy = validateHermesStrategy(input.strategy);
|
|
257
|
+
return request;
|
|
258
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -471,7 +471,7 @@ function writeStickyEntry(file, entry, logger) {
|
|
|
471
471
|
logger.warn(`[multi-clawd] sticky state write failed: ${String(err)}`);
|
|
472
472
|
}
|
|
473
473
|
}
|
|
474
|
-
function registerPoolBackend(api, pool, accounts, registeredIds, execMode) {
|
|
474
|
+
export function registerPoolBackend(api, pool, accounts, registeredIds, execMode) {
|
|
475
475
|
const logger = api.logger;
|
|
476
476
|
if (!pool)
|
|
477
477
|
return;
|
package/dist/update-core.js
CHANGED
|
@@ -57,6 +57,25 @@ export function formatCliSkew(opts) {
|
|
|
57
57
|
`the plugin serving your turns is behind. Fix: multi-clawd update`);
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
export const REGISTRY_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
61
|
+
export function registryCacheIsFresh(checkedAt, nowMs, ttlMs = REGISTRY_CHECK_TTL_MS) {
|
|
62
|
+
if (typeof checkedAt !== "number")
|
|
63
|
+
return false;
|
|
64
|
+
return checkedAt <= nowMs && nowMs - checkedAt < ttlMs;
|
|
65
|
+
}
|
|
66
|
+
export function formatRegistryLag(opts) {
|
|
67
|
+
if (!opts.installed || !opts.latest)
|
|
68
|
+
return undefined;
|
|
69
|
+
if (compareVersions(opts.installed, opts.latest) >= 0) {
|
|
70
|
+
return { level: "ok", text: `plugin v${opts.installed} is the latest published version` };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
level: "warn",
|
|
74
|
+
text: `plugin v${opts.installed} — npm publishes v${opts.latest}. The install is pinned, so ` +
|
|
75
|
+
`\`openclaw plugins update --all\` reports it up to date and will not move it. ` +
|
|
76
|
+
`Fix: multi-clawd update`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
60
79
|
export function formatUpdateBanner(opts) {
|
|
61
80
|
const action = decideUpdateAction(opts);
|
|
62
81
|
switch (action) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.3",
|
|
5
5
|
"description": "Register additional Claude Code logins (Max/Pro accounts) as first-class OpenClaw CLI backends for cross-account failover, keeping the full skills/MCP harness on every account.",
|
|
6
6
|
"cliBackends": [
|
|
7
7
|
"claw1",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.7.
|
|
4
|
-
"description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account.",
|
|
3
|
+
"version": "1.7.3",
|
|
4
|
+
"description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account. Also imports those accounts' setup tokens into Hermes Agent's Anthropic credential pool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Drakon Systems Ltd",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"files": [
|
|
45
45
|
"dist",
|
|
46
46
|
"scripts",
|
|
47
|
+
"!scripts/__pycache__",
|
|
48
|
+
"!**/*.py[cod]",
|
|
47
49
|
"openclaw.plugin.json",
|
|
48
50
|
"README.md",
|
|
49
51
|
"SECURITY.md",
|
package/scripts/cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* npx @drakon-systems/multi-clawd setup guided setup wizard
|
|
6
6
|
* npx @drakon-systems/multi-clawd update update to the latest version
|
|
7
7
|
* npx @drakon-systems/multi-clawd doctor health check
|
|
8
|
+
* npx @drakon-systems/multi-clawd hermes sync/diagnose Hermes credentials
|
|
8
9
|
* npx @drakon-systems/multi-clawd version versions (CLI + installed plugin)
|
|
9
10
|
*
|
|
10
11
|
* (Installed globally via `npm i -g @drakon-systems/multi-clawd`, the same
|
|
@@ -38,6 +39,7 @@ ${BOLD}🦞 multi-clawd${RESET} — multi-account Claude failover for OpenClaw
|
|
|
38
39
|
${BOLD}chain${RESET} audit your model routing — what actually serves each turn
|
|
39
40
|
${BOLD}update${RESET} update the plugin to the latest version
|
|
40
41
|
${BOLD}doctor${RESET} health check (add --probe for a live turn)
|
|
42
|
+
${BOLD}hermes${RESET} sync or diagnose Hermes Agent's Anthropic credential pool
|
|
41
43
|
${BOLD}version${RESET} show CLI + installed plugin versions
|
|
42
44
|
|
|
43
45
|
Run via npx (${DIM}npx ${PKG} <command>${RESET}) or install globally
|
|
@@ -554,6 +556,9 @@ switch (cmd) {
|
|
|
554
556
|
case "doctor":
|
|
555
557
|
runSibling("doctor.mjs", rest);
|
|
556
558
|
break;
|
|
559
|
+
case "hermes":
|
|
560
|
+
runSibling("hermes.mjs", rest);
|
|
561
|
+
break;
|
|
557
562
|
case "update":
|
|
558
563
|
await update();
|
|
559
564
|
break;
|