@drakon-systems/multi-clawd 1.9.8 → 2.0.0
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 +121 -17
- package/dist/account-env.js +41 -3
- package/dist/account-identity.js +5 -0
- package/dist/credential-state.js +3 -0
- package/dist/credential-store.js +222 -0
- package/dist/direct-route.js +2 -1
- package/dist/explain-core.js +3 -0
- package/dist/health.js +30 -5
- package/dist/index.js +218 -30
- package/dist/login-health.js +137 -40
- package/dist/retry-plan.js +38 -3
- package/dist/shim-core.js +3 -1
- package/dist/shim.js +5 -2
- package/dist/usage-poll.js +372 -0
- package/openclaw.plugin.json +20 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +113 -0
- package/scripts/doctor.mjs +116 -49
package/README.md
CHANGED
|
@@ -223,6 +223,12 @@ claude-cli/claude-fable-5 # main login
|
|
|
223
223
|
stream-json and records per-window health to
|
|
224
224
|
`~/.openclaw/state/multi-clawd/<account>.json`. Passthrough-first: a state
|
|
225
225
|
write can never break a live turn.
|
|
226
|
+
- 📡 **Live usage (v1.10)** — the pool also *asks* the provider what is left,
|
|
227
|
+
on a timer, using the login each account already holds. Both windows come
|
|
228
|
+
back as real percentages with reset times — including the 5-hour window the
|
|
229
|
+
stream never puts a number on — so hand-over happens at 85% of the session
|
|
230
|
+
window, not at the error. Passing 95% raises an operator alert through the
|
|
231
|
+
heartbeat; `multi-clawd usage` shows it on demand.
|
|
226
232
|
- 🧰 **Full harness on every hop** — each backend is a genuine Claude Code
|
|
227
233
|
subprocess: native tools, skills, MCP bridge, and native compaction all
|
|
228
234
|
stay intact when failover steps across accounts.
|
|
@@ -247,7 +253,8 @@ claude-cli/claude-fable-5 # main login
|
|
|
247
253
|
your chain: the refusal is swallowed before anything reaches you and the turn
|
|
248
254
|
is re-spawned on a healthy account. Covers ongoing conversations too: the
|
|
249
255
|
session is handed to the sibling as it stood before the refused attempt.
|
|
250
|
-
|
|
256
|
+
One retry. Works for config-dir, native, and setup-token-file accounts; the
|
|
257
|
+
shim is handed paths only and reads a token file at the moment it retries.
|
|
251
258
|
- 📟 **Operator alerts (v0.3)** — dead logins (probed every 15 min without
|
|
252
259
|
spending quota), pool rotations, whole-pool exhaustion, and watchdog
|
|
253
260
|
restarts surface through your agent's next heartbeat (e.g. straight into
|
|
@@ -255,8 +262,10 @@ claude-cli/claude-fable-5 # main login
|
|
|
255
262
|
- 🩺 **`npm run doctor` (v0.3)** — one command that says whether a box is
|
|
256
263
|
actually ready: config/manifest agreement (with the exact `--force`
|
|
257
264
|
preflight strip plan), dist freshness, CLI presence, credential health
|
|
258
|
-
|
|
259
|
-
|
|
265
|
+
judged on evidence — an unexpired token or a successful turn, never mere
|
|
266
|
+
presence (values never printed), a **credential split-store** warning when a
|
|
267
|
+
login's OAuth credential exists in more than one place, **which Claude login
|
|
268
|
+
each account authenticates as and which one the next turn will run on**, telemetry age, pool + sticky
|
|
260
269
|
state, **whether every non-Claude chain rung still has a usable auth
|
|
261
270
|
profile**, watchdog presence, optional `--probe` (one turn per account).
|
|
262
271
|
- 🧯 **Self-healing config** — registration re-reads the resolved runtime
|
|
@@ -376,7 +385,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
376
385
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
377
386
|
Code on the target machine already? Paste it this and go make coffee:
|
|
378
387
|
|
|
379
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/
|
|
388
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.0.0/SETUP-AGENT.md
|
|
380
389
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
381
390
|
> Claude account — ask me when you need me to log in.
|
|
382
391
|
|
|
@@ -456,10 +465,17 @@ dir* — a separate Claude "app" — so the logins can never clobber each other.
|
|
|
456
465
|
|
|
457
466
|
```bash
|
|
458
467
|
mkdir -p ~/.claw2 && chmod 700 ~/.claw2
|
|
459
|
-
CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token
|
|
460
|
-
|
|
468
|
+
CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token # log in as the 2nd account; it prints the token
|
|
469
|
+
install -m 600 /dev/null ~/.claw2/oauth-token
|
|
470
|
+
nano ~/.claw2/oauth-token # paste ONLY the printed sk-ant-oat01-… token, on one line; save
|
|
461
471
|
```
|
|
462
472
|
|
|
473
|
+
Do not redirect `claude setup-token > file`: that captures the whole
|
|
474
|
+
screen (sign-in prompts and instructions) around the token. multi-clawd
|
|
475
|
+
refuses a token file holding anything but the single token line — the
|
|
476
|
+
account is skipped with an alert naming the file, and `multi-clawd doctor`
|
|
477
|
+
says exactly what to fix — rather than guess which part is the token.
|
|
478
|
+
|
|
463
479
|
**Windows (native, PowerShell):**
|
|
464
480
|
|
|
465
481
|
```powershell
|
|
@@ -574,9 +590,11 @@ How it decides, per launch (all data from each account's live
|
|
|
574
590
|
| no data / stale data | used — never rotate on missing evidence |
|
|
575
591
|
| whole pool exhausted | home account anyway → real limit error → your chain drops provider |
|
|
576
592
|
|
|
577
|
-
**Why the short-window rule exists (v1.7.2).**
|
|
578
|
-
|
|
579
|
-
|
|
593
|
+
**Why the short-window rule exists (v1.7.2).** (v1.10's live usage poll now
|
|
594
|
+
supplies the missing number — see below — so this rule is the fallback for
|
|
595
|
+
hosts where polling is off or the account cannot be polled.) The threshold
|
|
596
|
+
rule needs a utilization number, and Anthropic does not send one for the
|
|
597
|
+
5-hour session window — across every observation we hold from two accounts it arrives as a
|
|
580
598
|
bare status plus a reset time, while the weekly windows carry percentages. A
|
|
581
599
|
rule that waits for a number therefore could never pre-empt the session
|
|
582
600
|
limit: the pool would take the hit and rotate afterwards. So on hour-scoped
|
|
@@ -596,6 +614,89 @@ Notes:
|
|
|
596
614
|
which runs on every launch on every turn path. Details in
|
|
597
615
|
[`DESIGN.md`](./DESIGN.md).
|
|
598
616
|
|
|
617
|
+
## Live usage: ask, don't wait to be told (v1.10)
|
|
618
|
+
|
|
619
|
+
Everything above learns about quota from the stream — the `rate_limit_event`
|
|
620
|
+
records the CLI emits at the top of a turn. That is reactive by construction:
|
|
621
|
+
nothing is observed between turns, and the 5-hour session window arrives with
|
|
622
|
+
a status but no percentage, so the pool could see "warning" but never "how
|
|
623
|
+
close". v1.10 closes both gaps by reading each pooled account's **live usage
|
|
624
|
+
from the provider** on a timer — the same figures the Claude CLI's own
|
|
625
|
+
`/usage` command shows — with the OAuth session the CLI already holds.
|
|
626
|
+
|
|
627
|
+
```jsonc
|
|
628
|
+
"pool": {
|
|
629
|
+
"id": "clawd",
|
|
630
|
+
"accounts": ["claw1", "claw2"],
|
|
631
|
+
"utilizationThreshold": 0.85, // hand over here (unchanged)
|
|
632
|
+
"usagePoll": { // v1.10 — on by default
|
|
633
|
+
"intervalMs": 120000, // every 2 min (minimum 60000)
|
|
634
|
+
"warnThreshold": 0.95 // operator alert from here
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
What a poll does, per account:
|
|
640
|
+
|
|
641
|
+
| Provider says | Health file gets | Pool does |
|
|
642
|
+
|---|---|---|
|
|
643
|
+
| 5-hour at 40%, weekly at 20% | `usage:five_hour` / `usage:seven_day`, `allowed` with the number and reset | nothing — home account serves |
|
|
644
|
+
| 5-hour at 86% | the same, `utilization 0.86` | **next launch rotates** to the spare (threshold rule, now with a real number on the session window) |
|
|
645
|
+
| 5-hour at 96% | the same | rotates, **and raises an alert**: account, window, reset time, where launches now go |
|
|
646
|
+
| 5-hour at 100% | `rejected` with the provider's reset | `exhausted` until that reset — for every model, before any turn is refused |
|
|
647
|
+
| every account past 95% | — | **pool-wide alert**: at 100% the ladder or the host's chain takes over; soonest reset named |
|
|
648
|
+
| model-scoped limit (e.g. one family at 97%) | — | alert naming the model; the shim's reactive capture still handles the refusal |
|
|
649
|
+
|
|
650
|
+
Polled records live under their own keys (`usage:<window>`) so they never race
|
|
651
|
+
the shim's `five_hour` / `seven_day` for the same slot — the shim's next bare
|
|
652
|
+
`allowed` cannot erase the number the poll just wrote, and `explain` shows
|
|
653
|
+
both ("5-hour (live) 40%"). The health rules are the existing ones; the poll
|
|
654
|
+
only improves what they can see.
|
|
655
|
+
|
|
656
|
+
Boundaries:
|
|
657
|
+
|
|
658
|
+
- **Read-only on credentials.** The poll reads the access token from the
|
|
659
|
+
account's `.credentials.json` and sends it to the usage endpoint — nowhere
|
|
660
|
+
else. It never refreshes, rewrites, or copies a token; an expired one is a
|
|
661
|
+
skipped tick (the next CLI launch refreshes it, as it always has).
|
|
662
|
+
- **Best-effort.** A failed poll changes nothing: the health file keeps what
|
|
663
|
+
the shim wrote, selection keeps working from it, and the failure is logged
|
|
664
|
+
once per transition, not per tick.
|
|
665
|
+
- **Native and `configDir` logins only — not setup-token accounts.** A
|
|
666
|
+
`claude setup-token` token carries only the `user:inference` scope, and the
|
|
667
|
+
usage endpoint requires `user:profile`: it answers `403
|
|
668
|
+
oauth_scope_insufficient` (checked against the live endpoint; upstream
|
|
669
|
+
anthropics/claude-code#11985). Reading a credentials file from the
|
|
670
|
+
account's dir instead would read a different login than the one it runs
|
|
671
|
+
on. So token accounts (`oauthTokenFile` / `oauthTokenRef`) are not polled,
|
|
672
|
+
and `doctor`, `usage` and the gateway log say so. What that means in
|
|
673
|
+
practice for a pool of token accounts:
|
|
674
|
+
- **Still works:** rotation from stream telemetry — every turn's
|
|
675
|
+
`rate_limit_event` feeds the same threshold rule, the numberless 5-hour
|
|
676
|
+
warning rule (v1.7.2), and rejection-with-reset handling in the table
|
|
677
|
+
above; and the in-turn retry still catches a limit that lands mid-turn.
|
|
678
|
+
- **Missing:** an idle account is not re-measured between turns, the
|
|
679
|
+
5-hour window usually has no percentage (only a warning status), and
|
|
680
|
+
there is no 95% usage alert.
|
|
681
|
+
macOS native logins kept in the keychain (no `.credentials.json`) are
|
|
682
|
+
likewise not polled.
|
|
683
|
+
- **Off switch:** `"usagePoll": { "enabled": false }`.
|
|
684
|
+
|
|
685
|
+
On demand:
|
|
686
|
+
|
|
687
|
+
```
|
|
688
|
+
multi-clawd usage # live figures per account + what the next launch would do
|
|
689
|
+
multi-clawd usage --json
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
**What "seamless" means here.** Rotation is decided per launch, so a turn that
|
|
693
|
+
starts after the poll has seen 85% simply runs on the other account — the user
|
|
694
|
+
sees nothing. A turn already in flight when its account runs out is covered by
|
|
695
|
+
the shim's in-turn retry (v1.9): the refusal is held back, the sibling is
|
|
696
|
+
spawned, the conversation is handed over, and the turn completes there. The
|
|
697
|
+
poll's job is to make that second path rare by moving work off an account
|
|
698
|
+
before it reaches the cliff, and to tell you when every account is nearing one.
|
|
699
|
+
|
|
599
700
|
## Direct route: the pool for `anthropic/*` too (v1.9)
|
|
600
701
|
|
|
601
702
|
OpenClaw reaches Claude two ways. The pool above drives **Claude Code CLI**
|
|
@@ -750,14 +851,17 @@ Housekeeping:
|
|
|
750
851
|
- Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
|
|
751
852
|
file; when a file is used, keep it `0600` (POSIX) or locked to your user
|
|
752
853
|
with `icacls` (Windows).
|
|
753
|
-
- **Credential resolution fails closed (v1.7.3
|
|
754
|
-
`oauthTokenRef` or `oauthTokenFile`
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
account
|
|
760
|
-
|
|
854
|
+
- **Credential resolution fails closed (v1.7.3; configDir no longer an
|
|
855
|
+
exception).** An account that declares `oauthTokenRef` or `oauthTokenFile`
|
|
856
|
+
is authenticated by that token and nothing else. If it resolves to nothing
|
|
857
|
+
— provider briefly unavailable, empty secret, truncated file, or a file
|
|
858
|
+
holding anything besides the single token line — the launch on that
|
|
859
|
+
account is refused with `declares a token source but none resolved`, even
|
|
860
|
+
when the account also has a `configDir` (whatever login sits in that dir
|
|
861
|
+
is not the credential you declared). In a pool the account is skipped, the
|
|
862
|
+
turn runs on a sibling, and an operator alert names the account and why;
|
|
863
|
+
only when no member resolves does the launch fail. Native accounts are
|
|
864
|
+
exempt: the default login *is* their credential.
|
|
761
865
|
- Migrating a token file into a vault? `op read` (and most secret CLIs)
|
|
762
866
|
append a trailing newline on output — resolution trims the resolved
|
|
763
867
|
value (guaranteed in `token-resolution.ts`), so a file-vs-vault diff
|
package/dist/account-env.js
CHANGED
|
@@ -7,6 +7,18 @@ export function expandHomePath(p) {
|
|
|
7
7
|
return resolve(homedir(), p.slice(2));
|
|
8
8
|
return resolve(p);
|
|
9
9
|
}
|
|
10
|
+
export class AccountCredentialError extends Error {
|
|
11
|
+
name = "AccountCredentialError";
|
|
12
|
+
}
|
|
13
|
+
export function credentialFailureText(err) {
|
|
14
|
+
if (err instanceof AccountCredentialError)
|
|
15
|
+
return err.message.replace(/^\[multi-clawd\] /, "");
|
|
16
|
+
const code = err?.code;
|
|
17
|
+
if (typeof code === "string" && /^E[A-Z]+$/.test(code))
|
|
18
|
+
return `credential file unreadable (${code})`;
|
|
19
|
+
const cls = err instanceof Error ? err.constructor.name : typeof err;
|
|
20
|
+
return `credential resolution failed (${cls})`;
|
|
21
|
+
}
|
|
10
22
|
export function buildAccountChildEnv(account, token, stateFile) {
|
|
11
23
|
const env = {
|
|
12
24
|
MULTI_CLAWD_ACCOUNT_ID: account.id,
|
|
@@ -19,12 +31,38 @@ export function buildAccountChildEnv(account, token, stateFile) {
|
|
|
19
31
|
env.CLAUDE_CONFIG_DIR = expandHomePath(account.configDir);
|
|
20
32
|
}
|
|
21
33
|
const declaresToken = Boolean(account.oauthTokenFile) || Boolean(account.oauthTokenRef);
|
|
22
|
-
if (!account.native && declaresToken && !usableToken
|
|
23
|
-
throw new
|
|
24
|
-
`refusing to launch on
|
|
34
|
+
if (!account.native && declaresToken && !usableToken) {
|
|
35
|
+
throw new AccountCredentialError(`[multi-clawd] account "${account.id}" declares a token source but none resolved — ` +
|
|
36
|
+
`refusing to launch it on any other login`);
|
|
25
37
|
}
|
|
26
38
|
return env;
|
|
27
39
|
}
|
|
40
|
+
const TOKEN_LINE_RE = /^sk-ant-oat[0-9]{2,}-[A-Za-z0-9_-]+$/;
|
|
41
|
+
const TOKEN_ANYWHERE_RE = /sk-ant-oat[0-9]+-/g;
|
|
42
|
+
export function parseSetupTokenFile(raw, label = "token file") {
|
|
43
|
+
const lines = raw
|
|
44
|
+
.split(/\r?\n/)
|
|
45
|
+
.map((l) => l.trim())
|
|
46
|
+
.filter((l) => l.length > 0);
|
|
47
|
+
if (lines.length === 0)
|
|
48
|
+
return { error: `${label} is empty` };
|
|
49
|
+
if (lines.length === 1 && TOKEN_LINE_RE.test(lines[0]))
|
|
50
|
+
return { token: lines[0] };
|
|
51
|
+
const runs = lines.reduce((n, l) => n + (l.match(TOKEN_ANYWHERE_RE)?.length ?? 0), 0);
|
|
52
|
+
if (runs === 0) {
|
|
53
|
+
return {
|
|
54
|
+
error: `${label} does not contain a setup-token (expected one line: sk-ant-oat01- then letters, ` +
|
|
55
|
+
`digits, - or _)` +
|
|
56
|
+
(lines.length > 1 ? `; it has ${lines.length} lines of other text` : ""),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
error: `${label} has text besides the token (${lines.length} non-empty line(s), ${runs} token-like) — ` +
|
|
61
|
+
`it must hold ONLY the token. This is what \`claude setup-token > file\` produces: it captures the on-screen ` +
|
|
62
|
+
`instructions too. Edit the file down to the single sk-ant-oat01-… line (check it is not ` +
|
|
63
|
+
`wrapped across two lines), keep it chmod 600, and nothing else is needed.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
28
66
|
export function tokenFileModeWarning(path, mode) {
|
|
29
67
|
const perms = mode & 0o777;
|
|
30
68
|
if ((perms & 0o077) === 0)
|
package/dist/account-identity.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export function nativeDefaultConfigDir(env, homeDir) {
|
|
2
|
+
const dir = `${homeDir.replace(/\/+$/, "")}/.claude`;
|
|
3
|
+
const inherited = env.CLAUDE_CONFIG_DIR?.trim();
|
|
4
|
+
return inherited ? { dir, ignored: inherited } : { dir };
|
|
5
|
+
}
|
|
1
6
|
export function identityFilePaths(account, io) {
|
|
2
7
|
if (account.oauthTokenFile || account.oauthTokenRef)
|
|
3
8
|
return [];
|
package/dist/credential-state.js
CHANGED
|
@@ -5,6 +5,9 @@ import { parseStoredState, mergeHealthStates, clearCredentialFailure, } from "./
|
|
|
5
5
|
export function healthStateFile(accountId) {
|
|
6
6
|
return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
|
|
7
7
|
}
|
|
8
|
+
export function usageStateFile(accountId) {
|
|
9
|
+
return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.usage.json`);
|
|
10
|
+
}
|
|
8
11
|
export function clearAccountCredentialFailure(accountId) {
|
|
9
12
|
const file = healthStateFile(accountId);
|
|
10
13
|
let state;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir, userInfo } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { CLAUDE_KEYCHAIN_SERVICE, claudeKeychainAccount, keychainServiceForConfigDir, parseOauthCredential, } from "./login-health.js";
|
|
6
|
+
export function parseKeychainDump(text) {
|
|
7
|
+
const items = [];
|
|
8
|
+
for (const block of text.split(/^keychain: /m)) {
|
|
9
|
+
if (!/^class: "genp"/m.test(block))
|
|
10
|
+
continue;
|
|
11
|
+
const attr = (name) => {
|
|
12
|
+
const m = block.match(new RegExp(`^\\s*"${name}"<[a-z]+>=(.*)$`, "m"));
|
|
13
|
+
if (!m)
|
|
14
|
+
return undefined;
|
|
15
|
+
const value = m[1].trim();
|
|
16
|
+
if (value === "<NULL>")
|
|
17
|
+
return undefined;
|
|
18
|
+
const hex = value.match(/^0x([0-9A-Fa-f]+)/);
|
|
19
|
+
if (hex)
|
|
20
|
+
return Buffer.from(hex[1], "hex").toString("utf8").replace(/\0+$/, "");
|
|
21
|
+
const quoted = value.match(/^"(.*)"$/);
|
|
22
|
+
return quoted ? quoted[1] : value;
|
|
23
|
+
};
|
|
24
|
+
const service = attr("svce");
|
|
25
|
+
if (service === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
const mdat = attr("mdat")?.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z/);
|
|
28
|
+
items.push({
|
|
29
|
+
service,
|
|
30
|
+
account: attr("acct") ?? "",
|
|
31
|
+
modifiedAt: mdat
|
|
32
|
+
? Date.UTC(+mdat[1], +mdat[2] - 1, +mdat[3], +mdat[4], +mdat[5], +mdat[6])
|
|
33
|
+
: undefined,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return items;
|
|
37
|
+
}
|
|
38
|
+
export function credentialLocations(account, io) {
|
|
39
|
+
if (account.oauthTokenFile || account.oauthTokenRef)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (account.native) {
|
|
42
|
+
return { service: CLAUDE_KEYCHAIN_SERVICE, file: io.expandHome("~/.claude/.credentials.json") };
|
|
43
|
+
}
|
|
44
|
+
if (!account.configDir)
|
|
45
|
+
return undefined;
|
|
46
|
+
const dir = io.expandHome(account.configDir).replace(/\/+$/, "");
|
|
47
|
+
return { service: keychainServiceForConfigDir(dir), file: `${dir}/.credentials.json` };
|
|
48
|
+
}
|
|
49
|
+
export function findCredentialCopies(account, io) {
|
|
50
|
+
const where = credentialLocations(account, io);
|
|
51
|
+
if (!where)
|
|
52
|
+
return { accountId: account.id, status: "n/a", copies: [], activeKnown: true };
|
|
53
|
+
const copies = [];
|
|
54
|
+
let cliItem = "absent";
|
|
55
|
+
let incomplete = false;
|
|
56
|
+
if (io.platform === "darwin") {
|
|
57
|
+
const listed = io.listKeychainItems();
|
|
58
|
+
incomplete = listed === undefined;
|
|
59
|
+
const items = (listed ?? []).filter((i) => i.service === where.service);
|
|
60
|
+
if (!items.some((i) => i.account === io.keychainAccount)) {
|
|
61
|
+
items.unshift({ service: where.service, account: io.keychainAccount });
|
|
62
|
+
}
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const read = io.readKeychainItem(where.service, item.account);
|
|
65
|
+
const isCli = item.account === io.keychainAccount;
|
|
66
|
+
if (isCli)
|
|
67
|
+
cliItem = read.status;
|
|
68
|
+
if (read.status === "absent")
|
|
69
|
+
continue;
|
|
70
|
+
const meta = read.status === "found" ? parseOauthCredential(read.data) : undefined;
|
|
71
|
+
copies.push({
|
|
72
|
+
kind: "keychain",
|
|
73
|
+
location: `keychain "${where.service}" acct=${item.account || "(empty)"}`,
|
|
74
|
+
keychainAccount: item.account,
|
|
75
|
+
expiresAt: meta?.expiresAt,
|
|
76
|
+
unreadable: read.status === "unreadable"
|
|
77
|
+
? "could not be read (Keychain locked, access refused or timed out)"
|
|
78
|
+
: meta
|
|
79
|
+
? undefined
|
|
80
|
+
: "not a Claude OAuth credential",
|
|
81
|
+
active: isCli && read.status === "found",
|
|
82
|
+
modifiedAt: item.modifiedAt,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let fileRaw;
|
|
87
|
+
let fileUnreadable = false;
|
|
88
|
+
try {
|
|
89
|
+
fileRaw = io.readFile(where.file);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const code = err?.code;
|
|
93
|
+
fileUnreadable = code !== "ENOENT" && code !== "ENOTDIR";
|
|
94
|
+
}
|
|
95
|
+
if (fileRaw !== undefined || fileUnreadable) {
|
|
96
|
+
const meta = fileRaw === undefined ? undefined : parseOauthCredential(fileRaw);
|
|
97
|
+
copies.push({
|
|
98
|
+
kind: "file",
|
|
99
|
+
location: where.file,
|
|
100
|
+
expiresAt: meta?.expiresAt,
|
|
101
|
+
unreadable: fileUnreadable
|
|
102
|
+
? "could not be read"
|
|
103
|
+
: meta
|
|
104
|
+
? undefined
|
|
105
|
+
: "not a Claude OAuth credential",
|
|
106
|
+
active: cliItem === "absent",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
accountId: account.id,
|
|
111
|
+
status: copies.length > 1 ? "split" : copies.length === 1 ? "single" : "none",
|
|
112
|
+
copies,
|
|
113
|
+
activeKnown: cliItem !== "unreadable",
|
|
114
|
+
incomplete: incomplete || undefined,
|
|
115
|
+
service: where.service,
|
|
116
|
+
cliAccount: io.platform === "darwin" ? io.keychainAccount : undefined,
|
|
117
|
+
file: where.file,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function shellQuote(s) {
|
|
121
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
122
|
+
}
|
|
123
|
+
export function describeSplitStore(report, nowMs) {
|
|
124
|
+
const fmt = (c) => {
|
|
125
|
+
const expiry = c.expiresAt !== undefined
|
|
126
|
+
? `${c.expiresAt > nowMs ? "expires" : "expired"} ${new Date(c.expiresAt).toISOString()}`
|
|
127
|
+
: (c.unreadable ?? "no expiry recorded");
|
|
128
|
+
const modified = c.modifiedAt !== undefined ? `, last written ${new Date(c.modifiedAt).toISOString()}` : "";
|
|
129
|
+
return `${c.location} — ${expiry}${modified}${c.active ? " ← the copy the Claude CLI reads" : ""}`;
|
|
130
|
+
};
|
|
131
|
+
const active = report.copies.find((c) => c.active);
|
|
132
|
+
const latest = Math.max(...report.copies.map((c) => c.expiresAt ?? Number.NEGATIVE_INFINITY));
|
|
133
|
+
const newerElsewhere = active?.expiresAt !== undefined &&
|
|
134
|
+
Number.isFinite(latest) &&
|
|
135
|
+
latest > active.expiresAt;
|
|
136
|
+
const headline = `${report.accountId}: ${report.copies.length} copies of this login's OAuth credential — ` +
|
|
137
|
+
`refresh tokens rotate, so once the copies diverge only the one refreshed last can still ` +
|
|
138
|
+
`refresh, and a tool that reads another sees a login that is not the one in use` +
|
|
139
|
+
(!report.activeKnown
|
|
140
|
+
? "; the Claude CLI's own Keychain item could not be read, so which copy it uses is unknown"
|
|
141
|
+
: newerElsewhere
|
|
142
|
+
? "; a NEWER copy exists than the one the Claude CLI reads"
|
|
143
|
+
: "");
|
|
144
|
+
if (!report.activeKnown) {
|
|
145
|
+
return {
|
|
146
|
+
headline,
|
|
147
|
+
copies: report.copies.map(fmt),
|
|
148
|
+
remedy: `unlock the Keychain (or re-authenticate with \`multi-clawd login ${report.accountId}\`), ` +
|
|
149
|
+
`confirm with \`multi-clawd doctor --probe\`, then re-run doctor for cleanup steps. ` +
|
|
150
|
+
`Remove nothing until doctor can tell which copy the Claude CLI reads.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const strays = report.copies.filter((c) => !c.active && !(c.kind === "keychain" && c.keychainAccount === report.cliAccount));
|
|
154
|
+
const files = strays.filter((c) => c.kind === "file");
|
|
155
|
+
const items = strays.filter((c) => c.kind === "keychain");
|
|
156
|
+
const steps = [
|
|
157
|
+
`first prove the copy the Claude CLI reads with \`multi-clawd doctor --probe\` ` +
|
|
158
|
+
`(if it fails, re-authenticate with \`multi-clawd login ${report.accountId}\` and probe again)`,
|
|
159
|
+
];
|
|
160
|
+
if (files.length > 0) {
|
|
161
|
+
steps.push(`after a passing probe, move ${files.map((c) => shellQuote(c.location)).join(" and ")} aside ` +
|
|
162
|
+
`(e.g. rename to .credentials.json.stale — reversible)`);
|
|
163
|
+
}
|
|
164
|
+
if (items.length > 0) {
|
|
165
|
+
steps.push(`delete a stray Keychain item only after a fresh \`multi-clawd login ${report.accountId}\` ` +
|
|
166
|
+
`and a passing probe (a probe can pass on an unexpired access token while its refresh ` +
|
|
167
|
+
`token is already dead): ` +
|
|
168
|
+
items
|
|
169
|
+
.map((c) => `\`security delete-generic-password -s ${shellQuote(report.service ?? "")} -a ${shellQuote(c.keychainAccount ?? "")}\``)
|
|
170
|
+
.join(", ") +
|
|
171
|
+
` (irreversible)`);
|
|
172
|
+
}
|
|
173
|
+
const remedy = `${steps.join("; ")}. Never remove the copy the Claude CLI reads: another copy may hold ` +
|
|
174
|
+
`the only live refresh token until the CLI's own copy is proven.`;
|
|
175
|
+
return { headline, copies: report.copies.map(fmt), remedy };
|
|
176
|
+
}
|
|
177
|
+
export function expandHomeDir(p) {
|
|
178
|
+
if (p === "~")
|
|
179
|
+
return homedir();
|
|
180
|
+
if (p.startsWith("~/"))
|
|
181
|
+
return join(homedir(), p.slice(2));
|
|
182
|
+
return resolve(p);
|
|
183
|
+
}
|
|
184
|
+
const SECURITY_TIMEOUT_MS = 10_000;
|
|
185
|
+
const SECURITY_ITEM_NOT_FOUND = 44;
|
|
186
|
+
export function createSystemCredentialIo(deps = {}) {
|
|
187
|
+
const exec = deps.exec ?? ((file, args, options) => execFileSync(file, args, options));
|
|
188
|
+
return {
|
|
189
|
+
readFile: (p) => readFileSync(expandHomeDir(p), "utf8"),
|
|
190
|
+
expandHome: expandHomeDir,
|
|
191
|
+
keychainAccount: claudeKeychainAccount(deps.env ?? process.env, userInfo),
|
|
192
|
+
platform: deps.platform ?? process.platform,
|
|
193
|
+
readKeychainItem: (service, account) => {
|
|
194
|
+
try {
|
|
195
|
+
const data = exec("security", ["find-generic-password", "-s", service, "-a", account, "-w"], {
|
|
196
|
+
encoding: "utf8",
|
|
197
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
198
|
+
timeout: SECURITY_TIMEOUT_MS,
|
|
199
|
+
});
|
|
200
|
+
return { status: "found", data: data.replace(/\n$/, "") };
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const status = err?.status;
|
|
204
|
+
return status === SECURITY_ITEM_NOT_FOUND ? { status: "absent" } : { status: "unreadable" };
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
listKeychainItems: () => {
|
|
208
|
+
try {
|
|
209
|
+
const out = exec("security", ["dump-keychain"], {
|
|
210
|
+
encoding: "utf8",
|
|
211
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
212
|
+
timeout: SECURITY_TIMEOUT_MS * 3,
|
|
213
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
214
|
+
});
|
|
215
|
+
return parseKeychainDump(out);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
package/dist/direct-route.js
CHANGED
package/dist/explain-core.js
CHANGED
|
@@ -2,6 +2,8 @@ const WINDOW_LABELS = {
|
|
|
2
2
|
five_hour: "5-hour",
|
|
3
3
|
seven_day: "weekly",
|
|
4
4
|
seven_day_overage_included: "weekly incl. overage",
|
|
5
|
+
"usage:five_hour": "5-hour (live)",
|
|
6
|
+
"usage:seven_day": "weekly (live)",
|
|
5
7
|
};
|
|
6
8
|
export function relativeUntil(ms, nowMs) {
|
|
7
9
|
const mins = Math.max(0, Math.round((ms - nowMs) / 60000));
|
|
@@ -71,6 +73,7 @@ const VERDICT_WORDS = {
|
|
|
71
73
|
near_limit: "NEAR ITS LIMIT — the pool will hand over before it hard-fails",
|
|
72
74
|
exhausted: "EXHAUSTED",
|
|
73
75
|
credential_failed: "LOGIN REJECTED — excluded from the pool until it is re-authenticated",
|
|
76
|
+
credential_unverified: "LOGIN UNPROVEN — last rejected and not proven since; used only as a re-test when nothing proven can serve",
|
|
74
77
|
};
|
|
75
78
|
export function renderExplanation(model) {
|
|
76
79
|
const lines = [];
|
package/dist/health.js
CHANGED
|
@@ -43,14 +43,36 @@ export function credentialFailureFor(state, nowMs) {
|
|
|
43
43
|
const credential = state?.credential;
|
|
44
44
|
if (!credential || credential.status !== "failed")
|
|
45
45
|
return undefined;
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const age = Math.max(0, nowMs - credential.seenAt);
|
|
47
|
+
const ageText = age < 90 * 60000 ? `${Math.round(age / 60000)}m` : `${Math.round(age / 3600000)}h`;
|
|
48
|
+
const detail = credential.reason ? `: ${credential.reason}` : "";
|
|
49
|
+
if (age <= CREDENTIAL_FAILED_TTL_MS) {
|
|
50
|
+
return {
|
|
51
|
+
verdict: "credential_failed",
|
|
52
|
+
resumeAt: credential.seenAt + CREDENTIAL_FAILED_TTL_MS,
|
|
53
|
+
reason: `login rejected by the Claude CLI ${ageText} ago${detail} — re-authenticate this account`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
48
56
|
return {
|
|
49
|
-
verdict: "
|
|
50
|
-
|
|
51
|
-
reason: `login rejected by the Claude CLI ${
|
|
57
|
+
verdict: "credential_unverified",
|
|
58
|
+
observedAt: credential.seenAt,
|
|
59
|
+
reason: `login rejected by the Claude CLI ${ageText} ago${detail} and not proven since — ` +
|
|
60
|
+
`re-authenticate, or prove it with \`multi-clawd doctor --probe\``,
|
|
52
61
|
};
|
|
53
62
|
}
|
|
63
|
+
const POLL_WINDOW_PREFIX = "usage:";
|
|
64
|
+
export function lastAuthSuccessAt(state) {
|
|
65
|
+
if (!state || state.credential?.status === "failed")
|
|
66
|
+
return undefined;
|
|
67
|
+
let latest = state.credential?.status === "ok" ? state.credential.seenAt : undefined;
|
|
68
|
+
for (const [key, w] of Object.entries(state.windows ?? {})) {
|
|
69
|
+
if (key.startsWith(POLL_WINDOW_PREFIX))
|
|
70
|
+
continue;
|
|
71
|
+
if (typeof w.seenAt === "number" && (latest === undefined || w.seenAt > latest))
|
|
72
|
+
latest = w.seenAt;
|
|
73
|
+
}
|
|
74
|
+
return latest;
|
|
75
|
+
}
|
|
54
76
|
export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
55
77
|
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
56
78
|
const threshold = options.utilizationThreshold ?? DEFAULT_UTILIZATION_THRESHOLD;
|
|
@@ -219,6 +241,9 @@ export function fallbackPoolAccount(pool, nowMs) {
|
|
|
219
241
|
const probe = nowMs === undefined ? undefined : overdueProbeAccount(pool, nowMs);
|
|
220
242
|
if (probe)
|
|
221
243
|
return probe;
|
|
244
|
+
const retest = pool.find((a) => a.verdict === "credential_unverified");
|
|
245
|
+
if (retest)
|
|
246
|
+
return retest.id;
|
|
222
247
|
return (pool.find((a) => a.verdict !== "credential_failed") ?? pool[0]).id;
|
|
223
248
|
}
|
|
224
249
|
export function pickPoolAccountForLaunch(pool, nowMs) {
|