@drakon-systems/multi-clawd 1.10.1 โ 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 +43 -18
- package/dist/account-env.js +41 -3
- package/dist/account-identity.js +5 -0
- package/dist/credential-store.js +222 -0
- package/dist/direct-route.js +2 -1
- package/dist/explain-core.js +1 -0
- package/dist/health.js +30 -5
- package/dist/index.js +71 -28
- 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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +1 -1
- package/scripts/doctor.mjs +116 -49
package/README.md
CHANGED
|
@@ -253,7 +253,8 @@ claude-cli/claude-fable-5 # main login
|
|
|
253
253
|
your chain: the refusal is swallowed before anything reaches you and the turn
|
|
254
254
|
is re-spawned on a healthy account. Covers ongoing conversations too: the
|
|
255
255
|
session is handed to the sibling as it stood before the refused attempt.
|
|
256
|
-
|
|
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.
|
|
257
258
|
- ๐ **Operator alerts (v0.3)** โ dead logins (probed every 15 min without
|
|
258
259
|
spending quota), pool rotations, whole-pool exhaustion, and watchdog
|
|
259
260
|
restarts surface through your agent's next heartbeat (e.g. straight into
|
|
@@ -261,8 +262,10 @@ claude-cli/claude-fable-5 # main login
|
|
|
261
262
|
- ๐ฉบ **`npm run doctor` (v0.3)** โ one command that says whether a box is
|
|
262
263
|
actually ready: config/manifest agreement (with the exact `--force`
|
|
263
264
|
preflight strip plan), dist freshness, CLI presence, credential health
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
266
269
|
state, **whether every non-Claude chain rung still has a usable auth
|
|
267
270
|
profile**, watchdog presence, optional `--probe` (one turn per account).
|
|
268
271
|
- ๐งฏ **Self-healing config** โ registration re-reads the resolved runtime
|
|
@@ -382,7 +385,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
382
385
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
383
386
|
Code on the target machine already? Paste it this and go make coffee:
|
|
384
387
|
|
|
385
|
-
> 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
|
|
386
389
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
387
390
|
> Claude account โ ask me when you need me to log in.
|
|
388
391
|
|
|
@@ -462,10 +465,17 @@ dir* โ a separate Claude "app" โ so the logins can never clobber each other.
|
|
|
462
465
|
|
|
463
466
|
```bash
|
|
464
467
|
mkdir -p ~/.claw2 && chmod 700 ~/.claw2
|
|
465
|
-
CLAUDE_CONFIG_DIR=~/.claw2 claude setup-token
|
|
466
|
-
|
|
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
|
|
467
471
|
```
|
|
468
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
|
+
|
|
469
479
|
**Windows (native, PowerShell):**
|
|
470
480
|
|
|
471
481
|
```powershell
|
|
@@ -652,10 +662,22 @@ Boundaries:
|
|
|
652
662
|
- **Best-effort.** A failed poll changes nothing: the health file keeps what
|
|
653
663
|
the shim wrote, selection keeps working from it, and the failure is logged
|
|
654
664
|
once per transition, not per tick.
|
|
655
|
-
- **Native and `configDir`
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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.
|
|
659
681
|
macOS native logins kept in the keychain (no `.credentials.json`) are
|
|
660
682
|
likewise not polled.
|
|
661
683
|
- **Off switch:** `"usagePoll": { "enabled": false }`.
|
|
@@ -829,14 +851,17 @@ Housekeeping:
|
|
|
829
851
|
- Prefer a secret reference (`oauthTokenRef`, v0.3) over a plaintext
|
|
830
852
|
file; when a file is used, keep it `0600` (POSIX) or locked to your user
|
|
831
853
|
with `icacls` (Windows).
|
|
832
|
-
- **Credential resolution fails closed (v1.7.3
|
|
833
|
-
`oauthTokenRef` or `oauthTokenFile`
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
account
|
|
839
|
-
|
|
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.
|
|
840
865
|
- Migrating a token file into a vault? `op read` (and most secret CLIs)
|
|
841
866
|
append a trailing newline on output โ resolution trims the resolved
|
|
842
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 [];
|
|
@@ -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
|
@@ -73,6 +73,7 @@ const VERDICT_WORDS = {
|
|
|
73
73
|
near_limit: "NEAR ITS LIMIT โ the pool will hand over before it hard-fails",
|
|
74
74
|
exhausted: "EXHAUSTED",
|
|
75
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",
|
|
76
77
|
};
|
|
77
78
|
export function renderExplanation(model) {
|
|
78
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) {
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
|
|
|
7
7
|
import { decideDegradation, matchesPin } from "./degrade.js";
|
|
8
8
|
import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
|
|
9
9
|
import { resolveBaseModelIds } from "./catalog-source.js";
|
|
10
|
-
import { allCredentialFailed, classifyAccountHealth, overdueProbeAccount, } from "./health.js";
|
|
10
|
+
import { allCredentialFailed, classifyAccountHealth, lastAuthSuccessAt, overdueProbeAccount, } from "./health.js";
|
|
11
11
|
import { decideStickySelection } from "./sticky.js";
|
|
12
12
|
import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
|
|
13
13
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
@@ -19,10 +19,10 @@ import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./
|
|
|
19
19
|
import { healthStateFile, usageStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
20
20
|
import { createUsagePollController, effectiveUsagePollInterval, effectiveUsageWarnThreshold, usageAlertPrefix, usagePoolAlertKey, } from "./usage-poll.js";
|
|
21
21
|
export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
|
|
22
|
-
import { accountConfigDir, buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
22
|
+
import { accountConfigDir, AccountCredentialError, buildAccountChildEnv, credentialFailureText, parseSetupTokenFile, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
23
23
|
import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { assessAccountCredential, createRefProbeTracker, } from "./login-health.js";
|
|
25
|
+
import { createSystemCredentialIo } from "./credential-store.js";
|
|
26
26
|
import { collectDirectMembers } from "./direct-route.js";
|
|
27
27
|
import { createDirectOrderController, isValidAgentId } from "./direct-sync.js";
|
|
28
28
|
import { createOpenclawRunner } from "./openclaw-runner.js";
|
|
@@ -156,21 +156,7 @@ function ingestAlertSpool() {
|
|
|
156
156
|
}
|
|
157
157
|
const LOGIN_PROBE_INTERVAL_MS = 15 * 60 * 1000;
|
|
158
158
|
const LOGIN_PROBE_INITIAL_DELAY_MS = 45 * 1000;
|
|
159
|
-
|
|
160
|
-
readFile: (p) => readFileSync(expandHome(p), "utf8"),
|
|
161
|
-
keychainHasClaudeCredentials: () => keychainHasService("Claude Code-credentials"),
|
|
162
|
-
keychainHasClaudeCredentialsForDir: (dir) => keychainHasService(keychainServiceForConfigDir(expandHome(dir))),
|
|
163
|
-
platform: process.platform,
|
|
164
|
-
};
|
|
165
|
-
function keychainHasService(service) {
|
|
166
|
-
try {
|
|
167
|
-
execFileSync("security", ["find-generic-password", "-s", service], { stdio: "ignore" });
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
159
|
+
let realCredentialIo;
|
|
174
160
|
const refProbeTrackers = new Map();
|
|
175
161
|
const lastProbeStatus = new Map();
|
|
176
162
|
function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
@@ -195,7 +181,8 @@ function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
|
195
181
|
export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
196
182
|
const now = deps.nowMs ?? Date.now();
|
|
197
183
|
const resolver = deps.resolver ?? activeTokenResolver;
|
|
198
|
-
const io = deps.io ?? realCredentialIo;
|
|
184
|
+
const io = deps.io ?? (realCredentialIo ??= createSystemCredentialIo());
|
|
185
|
+
const readHealth = deps.readHealth ?? readHealthState;
|
|
199
186
|
for (const account of accounts) {
|
|
200
187
|
let status;
|
|
201
188
|
let reason;
|
|
@@ -215,9 +202,12 @@ export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
|
215
202
|
cause = outcome.cause;
|
|
216
203
|
}
|
|
217
204
|
else {
|
|
218
|
-
const check =
|
|
205
|
+
const check = assessAccountCredential(account, io, {
|
|
206
|
+
proof: { lastSuccessAt: lastAuthSuccessAt(readHealth(account.id)) },
|
|
207
|
+
nowMs: now,
|
|
208
|
+
});
|
|
219
209
|
status = check.status;
|
|
220
|
-
reason = check.reason;
|
|
210
|
+
reason = check.status === "ok" ? check.detail : check.reason;
|
|
221
211
|
}
|
|
222
212
|
const previous = lastProbeStatus.get(account.id);
|
|
223
213
|
lastProbeStatus.set(account.id, status);
|
|
@@ -243,14 +233,30 @@ export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
|
243
233
|
logger.error(`[multi-clawd] ${text}`);
|
|
244
234
|
raiseAlert({ key: `login:${account.id}`, severity: "error", text });
|
|
245
235
|
}
|
|
236
|
+
else if (status === "unverified" && previous !== "unverified") {
|
|
237
|
+
logger.info(`[multi-clawd] account "${account.id}" login unverified: ${reason ?? "unproven"}`);
|
|
238
|
+
}
|
|
239
|
+
else if (status === "unknown" && previous !== "unknown" && reason) {
|
|
240
|
+
logger.info(`[multi-clawd] account "${account.id}" login could not be checked: ${reason}`);
|
|
241
|
+
}
|
|
246
242
|
else if (status === "degraded" && previous !== "degraded") {
|
|
247
243
|
logger.info(`[multi-clawd] account "${account.id}" login degraded: ${reason ?? "resolver error"}`);
|
|
248
244
|
}
|
|
245
|
+
else if (status === "ok" && previous === "unverified") {
|
|
246
|
+
logger.info(`[multi-clawd] account "${account.id}" login verified: ${reason ?? "ok"}`);
|
|
247
|
+
}
|
|
249
248
|
else if (status === "ok" && (previous === "broken" || previous === "degraded")) {
|
|
250
249
|
logger.info(`[multi-clawd] account "${account.id}" login recovered`);
|
|
251
250
|
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
252
251
|
alertState = clearAlert(alertState, `login-resolver:${account.id}`);
|
|
253
252
|
}
|
|
253
|
+
if (status === "ok") {
|
|
254
|
+
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
255
|
+
alertState = clearAlert(alertState, `login-resolver:${account.id}`);
|
|
256
|
+
}
|
|
257
|
+
else if ((status === "unverified" || status === "unknown") && cause === undefined) {
|
|
258
|
+
alertState = clearAlert(alertState, `login:${account.id}`);
|
|
259
|
+
}
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
262
|
export function startLoginHealthProbe(accounts, logger) {
|
|
@@ -290,7 +296,20 @@ function peekToken(account) {
|
|
|
290
296
|
if (account.oauthTokenFile) {
|
|
291
297
|
const path = expandHome(account.oauthTokenFile);
|
|
292
298
|
warnIfTokenFileExposed(path);
|
|
293
|
-
|
|
299
|
+
let raw;
|
|
300
|
+
try {
|
|
301
|
+
raw = readFileSync(path, "utf8");
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
const code = err.code;
|
|
305
|
+
throw new AccountCredentialError(`[multi-clawd] account "${account.id}": token file ${account.oauthTokenFile} unreadable` +
|
|
306
|
+
(typeof code === "string" ? ` (${code})` : ""));
|
|
307
|
+
}
|
|
308
|
+
const read = parseSetupTokenFile(raw, `token file ${account.oauthTokenFile}`);
|
|
309
|
+
if ("error" in read) {
|
|
310
|
+
throw new AccountCredentialError(`[multi-clawd] account "${account.id}": ${read.error}`);
|
|
311
|
+
}
|
|
312
|
+
return read.token;
|
|
294
313
|
}
|
|
295
314
|
if (isSecretRefShape(account.oauthTokenRef)) {
|
|
296
315
|
return activeTokenResolver?.peek(account.oauthTokenRef);
|
|
@@ -450,13 +469,16 @@ export function buildRetryRoster(members, launchedId) {
|
|
|
450
469
|
for (const member of members) {
|
|
451
470
|
if (member.id === launchedId)
|
|
452
471
|
continue;
|
|
453
|
-
|
|
472
|
+
const tokenFile = !member.native && member.oauthTokenFile ? expandHome(member.oauthTokenFile) : undefined;
|
|
473
|
+
if (!member.native && !tokenFile && member.oauthTokenRef)
|
|
474
|
+
continue;
|
|
475
|
+
if (!member.native && !tokenFile && !member.configDir)
|
|
454
476
|
continue;
|
|
455
477
|
const stateFile = healthStateFile(member.id);
|
|
456
|
-
const env = buildAccountChildEnv(member, undefined, stateFile);
|
|
478
|
+
const env = buildAccountChildEnv({ ...member, oauthTokenFile: undefined, oauthTokenRef: undefined }, undefined, stateFile);
|
|
457
479
|
delete env.MULTI_CLAWD_ACCOUNT_ID;
|
|
458
480
|
delete env.MULTI_CLAWD_STATE_FILE;
|
|
459
|
-
roster.push({ id: member.id, stateFile, env });
|
|
481
|
+
roster.push({ id: member.id, stateFile, env, ...(tokenFile ? { tokenFile } : {}) });
|
|
460
482
|
}
|
|
461
483
|
return roster;
|
|
462
484
|
}
|
|
@@ -716,6 +738,10 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
716
738
|
`launching on ${decision.account} to re-test a rejection last seen ` +
|
|
717
739
|
`${Math.round((now - observedAt) / 3600000)}h ago`);
|
|
718
740
|
}
|
|
741
|
+
else if (poolVerdicts.find((v) => v.id === decision.account)?.verdict === "credential_unverified") {
|
|
742
|
+
logger.info(`[multi-clawd] pool ${poolId}: no proven account can serve ${requestedModel} โ ` +
|
|
743
|
+
`launching on ${decision.account} to re-test a login last rejected by the Claude CLI`);
|
|
744
|
+
}
|
|
719
745
|
else if (decision.account !== previousAccount) {
|
|
720
746
|
const home = verdicts[0];
|
|
721
747
|
const line = decision.account === members[0].id
|
|
@@ -760,6 +786,13 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
760
786
|
text: `pool ${poolId}: account "${v.id}" is excluded โ ${v.health.reason ?? "its login was rejected by the Claude CLI"}. Fix with \`multi-clawd login ${v.id}\`.`,
|
|
761
787
|
});
|
|
762
788
|
}
|
|
789
|
+
else if (v.health.verdict === "credential_unverified") {
|
|
790
|
+
raiseAlert({
|
|
791
|
+
key,
|
|
792
|
+
severity: "error",
|
|
793
|
+
text: `pool ${poolId}: account "${v.id}" is unproven โ ${v.health.reason ?? "its login was last rejected by the Claude CLI"}. Fix with \`multi-clawd login ${v.id}\`.`,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
763
796
|
else {
|
|
764
797
|
alertState = clearAlert(alertState, key);
|
|
765
798
|
}
|
|
@@ -774,6 +807,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
774
807
|
try {
|
|
775
808
|
env = await buildAccountEnv(candidate, deps?.resolver);
|
|
776
809
|
launched = candidate;
|
|
810
|
+
alertState = clearAlert(alertState, `credential-unresolved:${candidate.id}`);
|
|
777
811
|
if (candidate.id !== chosen.id) {
|
|
778
812
|
logger.warn(`[multi-clawd] pool ${poolId}: ${chosen.id}'s credential did not resolve โ ` +
|
|
779
813
|
`launching on ${candidate.id} instead (account not benched; secret provider may be down)`);
|
|
@@ -781,7 +815,14 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
781
815
|
break;
|
|
782
816
|
}
|
|
783
817
|
catch (err) {
|
|
784
|
-
|
|
818
|
+
const why = credentialFailureText(err);
|
|
819
|
+
unresolved.push(`${candidate.id} (${why})`);
|
|
820
|
+
raiseAlert({
|
|
821
|
+
key: `credential-unresolved:${candidate.id}`,
|
|
822
|
+
severity: "error",
|
|
823
|
+
text: `pool ${poolId}: account "${candidate.id}" was skipped โ its declared credential did ` +
|
|
824
|
+
`not resolve, so it will not launch on any other login (${why})`,
|
|
825
|
+
});
|
|
785
826
|
}
|
|
786
827
|
}
|
|
787
828
|
if (!env) {
|
|
@@ -927,9 +968,11 @@ export function stopUsagePoll() {
|
|
|
927
968
|
usageInitial = undefined;
|
|
928
969
|
usageController = undefined;
|
|
929
970
|
}
|
|
971
|
+
export const USAGE_TOKEN_ACCOUNT_REASON = "setup-token login โ the usage endpoint needs the user:profile scope and setup-tokens carry only " +
|
|
972
|
+
"user:inference, so usage comes from stream telemetry only";
|
|
930
973
|
export function usagePollCredentialsFile(account) {
|
|
931
974
|
if (account.oauthTokenFile || account.oauthTokenRef) {
|
|
932
|
-
return { reason:
|
|
975
|
+
return { reason: USAGE_TOKEN_ACCOUNT_REASON };
|
|
933
976
|
}
|
|
934
977
|
if (!account.native && !account.configDir) {
|
|
935
978
|
return { reason: "no login dir to read credentials from" };
|
package/dist/login-health.js
CHANGED
|
@@ -1,8 +1,98 @@
|
|
|
1
|
+
import { parseSetupTokenFile } from "./account-env.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
4
|
+
export function claudeKeychainAccount(env, userInfo) {
|
|
5
|
+
try {
|
|
6
|
+
return env.USER || userInfo().username;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return "claude-code-user";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
2
12
|
export function keychainServiceForConfigDir(absDir) {
|
|
3
13
|
const hash = createHash("sha256").update(absDir).digest("hex").slice(0, 8);
|
|
4
14
|
return `Claude Code-credentials-${hash}`;
|
|
5
15
|
}
|
|
16
|
+
export const AUTH_PROOF_FRESH_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
export const ACCESS_TOKEN_EXPIRY_SKEW_MS = 60 * 1000;
|
|
18
|
+
const MAX_PLAUSIBLE_EXPIRY_MS = 253402300799000;
|
|
19
|
+
export function parseOauthCredential(raw) {
|
|
20
|
+
let text = raw.trim();
|
|
21
|
+
if (/^(?:[0-9a-f]{2})+$/i.test(text)) {
|
|
22
|
+
text = Buffer.from(text, "hex").toString("utf8").trim();
|
|
23
|
+
}
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(text);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const oauth = parsed?.claudeAiOauth;
|
|
32
|
+
if (!oauth || typeof oauth !== "object")
|
|
33
|
+
return undefined;
|
|
34
|
+
const o = oauth;
|
|
35
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
36
|
+
let expiresAt;
|
|
37
|
+
if (typeof o.expiresAt === "number" && Number.isFinite(o.expiresAt) && o.expiresAt > 0) {
|
|
38
|
+
const ms = o.expiresAt < 1e12 ? o.expiresAt * 1000 : o.expiresAt;
|
|
39
|
+
if (ms < MAX_PLAUSIBLE_EXPIRY_MS)
|
|
40
|
+
expiresAt = ms;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
hasAccessToken: present(o.accessToken),
|
|
44
|
+
hasRefreshToken: present(o.refreshToken),
|
|
45
|
+
expiresAt,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function ago(ms) {
|
|
49
|
+
const min = Math.max(0, Math.round(ms / 60000));
|
|
50
|
+
return min < 90 ? `${min}m` : `${Math.round(min / 60)}h`;
|
|
51
|
+
}
|
|
52
|
+
export function judgeOauthCredential(meta, source, proof, nowMs) {
|
|
53
|
+
const base = { source, expiresAt: meta.expiresAt };
|
|
54
|
+
if (!meta.hasAccessToken) {
|
|
55
|
+
return { ...base, status: "broken", reason: `${source}: access token is blank` };
|
|
56
|
+
}
|
|
57
|
+
if (meta.expiresAt !== undefined && meta.expiresAt - ACCESS_TOKEN_EXPIRY_SKEW_MS > nowMs) {
|
|
58
|
+
return {
|
|
59
|
+
...base,
|
|
60
|
+
status: "ok",
|
|
61
|
+
detail: `access token valid until ${new Date(meta.expiresAt).toISOString()}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (meta.expiresAt === undefined) {
|
|
65
|
+
return {
|
|
66
|
+
...base,
|
|
67
|
+
status: "unverified",
|
|
68
|
+
reason: `${source}: records no access-token expiry, so it cannot be judged at rest`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const expiry = `access token expired ${new Date(meta.expiresAt).toISOString()}`;
|
|
72
|
+
const success = proof.lastSuccessAt;
|
|
73
|
+
if (success !== undefined &&
|
|
74
|
+
success <= nowMs &&
|
|
75
|
+
nowMs - success <= AUTH_PROOF_FRESH_MS &&
|
|
76
|
+
success >= meta.expiresAt) {
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
status: "ok",
|
|
80
|
+
detail: `${expiry}; a successful turn ${ago(nowMs - success)} ago proves the refresh works`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!meta.hasRefreshToken) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
status: "broken",
|
|
87
|
+
reason: `${source}: ${expiry} and no refresh token is stored`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
status: "unverified",
|
|
93
|
+
reason: `${source}: ${expiry} and no successful turn since proves the refresh works`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
6
96
|
export function createRefProbeTracker(options = {}) {
|
|
7
97
|
const deadAfterConsecutive = options.deadAfterConsecutive ?? 3;
|
|
8
98
|
const deadAfterMs = options.deadAfterMs ?? 10 * 60 * 1000;
|
|
@@ -42,29 +132,40 @@ export function createRefProbeTracker(options = {}) {
|
|
|
42
132
|
},
|
|
43
133
|
};
|
|
44
134
|
}
|
|
45
|
-
function
|
|
46
|
-
return /^sk-ant-[a-z0-9]+-/.test(value.trim());
|
|
47
|
-
}
|
|
48
|
-
function checkCredentialsJson(io, dir) {
|
|
135
|
+
function assessCredentialsJson(io, path, proof, nowMs) {
|
|
49
136
|
let raw;
|
|
50
137
|
try {
|
|
51
|
-
raw = io.readFile(
|
|
138
|
+
raw = io.readFile(path);
|
|
52
139
|
}
|
|
53
140
|
catch {
|
|
54
|
-
return
|
|
141
|
+
return undefined;
|
|
55
142
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (typeof token === "string" && token.trim().length > 0)
|
|
60
|
-
return { status: "ok" };
|
|
61
|
-
return { status: "broken", reason: `${dir}/.credentials.json access token is blank` };
|
|
143
|
+
const meta = parseOauthCredential(raw);
|
|
144
|
+
if (!meta) {
|
|
145
|
+
return { status: "broken", reason: `${path} holds no Claude OAuth credential`, source: path };
|
|
62
146
|
}
|
|
63
|
-
|
|
64
|
-
|
|
147
|
+
return judgeOauthCredential(meta, path, proof, nowMs);
|
|
148
|
+
}
|
|
149
|
+
function assessKeychain(io, service, proof, nowMs) {
|
|
150
|
+
const read = io.readKeychainItem(service, io.keychainAccount);
|
|
151
|
+
if (read.status === "absent")
|
|
152
|
+
return undefined;
|
|
153
|
+
const source = `keychain "${service}"`;
|
|
154
|
+
if (read.status === "unreadable") {
|
|
155
|
+
return {
|
|
156
|
+
status: "unknown",
|
|
157
|
+
reason: `${source} could not be read (Keychain locked, access refused or timed out) โ not judged`,
|
|
158
|
+
source,
|
|
159
|
+
};
|
|
65
160
|
}
|
|
161
|
+
const meta = parseOauthCredential(read.data);
|
|
162
|
+
if (!meta)
|
|
163
|
+
return { status: "broken", reason: `${source} holds no Claude OAuth credential`, source };
|
|
164
|
+
return judgeOauthCredential(meta, source, proof, nowMs);
|
|
66
165
|
}
|
|
67
|
-
export function
|
|
166
|
+
export function assessAccountCredential(account, io, options = {}) {
|
|
167
|
+
const proof = options.proof ?? {};
|
|
168
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
68
169
|
if (account.oauthTokenFile) {
|
|
69
170
|
let raw;
|
|
70
171
|
try {
|
|
@@ -73,35 +174,31 @@ export function checkAccountCredential(account, io) {
|
|
|
73
174
|
catch {
|
|
74
175
|
return { status: "broken", reason: `${account.oauthTokenFile} unreadable` };
|
|
75
176
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
reason: `${account.oauthTokenFile} does not contain a setup-token`,
|
|
81
|
-
};
|
|
177
|
+
const read = parseSetupTokenFile(raw, account.oauthTokenFile);
|
|
178
|
+
if ("token" in read)
|
|
179
|
+
return { status: "ok", detail: "setup-token present" };
|
|
180
|
+
return { status: "broken", reason: read.error };
|
|
82
181
|
}
|
|
83
182
|
if (account.oauthTokenRef) {
|
|
84
183
|
return { status: "unknown" };
|
|
85
184
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return checkCredentialsJson(io, "~/.claude");
|
|
93
|
-
}
|
|
94
|
-
if (account.configDir) {
|
|
95
|
-
if (io.platform === "darwin" && io.keychainHasClaudeCredentialsForDir(account.configDir)) {
|
|
96
|
-
return { status: "ok" };
|
|
97
|
-
}
|
|
98
|
-
const file = checkCredentialsJson(io, account.configDir);
|
|
99
|
-
if (file.status === "ok" || io.platform !== "darwin")
|
|
100
|
-
return file;
|
|
101
|
-
return {
|
|
185
|
+
const dir = account.native ? "~/.claude" : account.configDir;
|
|
186
|
+
if (!dir)
|
|
187
|
+
return { status: "unknown" };
|
|
188
|
+
const path = `${dir.replace(/\/+$/, "")}/.credentials.json`;
|
|
189
|
+
if (io.platform !== "darwin") {
|
|
190
|
+
return (assessCredentialsJson(io, path, proof, nowMs) ?? {
|
|
102
191
|
status: "broken",
|
|
103
|
-
reason:
|
|
104
|
-
|
|
192
|
+
reason: `${path} unreadable`,
|
|
193
|
+
source: path,
|
|
194
|
+
});
|
|
105
195
|
}
|
|
106
|
-
|
|
196
|
+
const service = account.native
|
|
197
|
+
? CLAUDE_KEYCHAIN_SERVICE
|
|
198
|
+
: keychainServiceForConfigDir(io.expandHome(dir));
|
|
199
|
+
return (assessKeychain(io, service, proof, nowMs) ??
|
|
200
|
+
assessCredentialsJson(io, path, proof, nowMs) ?? {
|
|
201
|
+
status: "broken",
|
|
202
|
+
reason: `keychain has no Claude Code credentials for ${account.native ? "the default login" : dir} and ${path} unreadable`,
|
|
203
|
+
});
|
|
107
204
|
}
|
package/dist/retry-plan.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { classifyAccountHealth, overdueProbeAccount, } from "./health.js";
|
|
2
|
+
import { parseSetupTokenFile } from "./account-env.js";
|
|
2
3
|
import { resumeSessionId } from "./session-handover.js";
|
|
3
4
|
export const RETRY_ROSTER_ENV = "MULTI_CLAWD_RETRY_ACCOUNTS";
|
|
4
5
|
const CREDENTIAL_ENV_KEYS = ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
6
|
+
const ROSTER_ENV_KEYS = ["CLAUDE_CONFIG_DIR"];
|
|
5
7
|
export function parseRetryRoster(raw) {
|
|
6
8
|
if (!raw)
|
|
7
9
|
return [];
|
|
@@ -26,11 +28,12 @@ export function parseRetryRoster(raw) {
|
|
|
26
28
|
const env = {};
|
|
27
29
|
if (e.env && typeof e.env === "object") {
|
|
28
30
|
for (const [k, v] of Object.entries(e.env)) {
|
|
29
|
-
if (typeof v === "string")
|
|
31
|
+
if (typeof v === "string" && ROSTER_ENV_KEYS.includes(k))
|
|
30
32
|
env[k] = v;
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
|
-
|
|
35
|
+
const tokenFile = typeof e.tokenFile === "string" && e.tokenFile ? e.tokenFile : undefined;
|
|
36
|
+
out.push({ id: e.id, stateFile: e.stateFile, env, ...(tokenFile ? { tokenFile } : {}) });
|
|
34
37
|
}
|
|
35
38
|
return out;
|
|
36
39
|
}
|
|
@@ -40,7 +43,7 @@ export function isResumeLaunch(argv) {
|
|
|
40
43
|
}
|
|
41
44
|
export function retryArming(argv, roster, opts = {}) {
|
|
42
45
|
if (roster.length === 0) {
|
|
43
|
-
return { armed: false, reason: "no
|
|
46
|
+
return { armed: false, reason: "no sibling account the shim can retry onto" };
|
|
44
47
|
}
|
|
45
48
|
if (isResumeLaunch(argv) && !opts.resumeReady) {
|
|
46
49
|
return {
|
|
@@ -77,3 +80,35 @@ export function buildRetryEnv(base, account) {
|
|
|
77
80
|
env[RETRY_ROSTER_ENV] = "";
|
|
78
81
|
return env;
|
|
79
82
|
}
|
|
83
|
+
export function materializeRetryAccount(account, readFile) {
|
|
84
|
+
if (!account.tokenFile)
|
|
85
|
+
return { account };
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = readFile(account.tokenFile);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
const code = err.code;
|
|
92
|
+
return { error: `token file unreadable${typeof code === "string" && /^E[A-Z]+$/.test(code) ? ` (${code})` : ""}` };
|
|
93
|
+
}
|
|
94
|
+
const read = parseSetupTokenFile(raw, "token file");
|
|
95
|
+
if ("error" in read)
|
|
96
|
+
return { error: read.error };
|
|
97
|
+
return {
|
|
98
|
+
account: { ...account, env: { ...account.env, CLAUDE_CODE_OAUTH_TOKEN: read.token } },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function chooseLaunchableRetryAccount(params) {
|
|
102
|
+
let remaining = params.roster;
|
|
103
|
+
while (remaining.length > 0) {
|
|
104
|
+
const pick = chooseRetryAccount({ ...params, roster: remaining });
|
|
105
|
+
if (!pick)
|
|
106
|
+
return undefined;
|
|
107
|
+
const ready = params.materialize(pick);
|
|
108
|
+
if ("account" in ready)
|
|
109
|
+
return ready.account;
|
|
110
|
+
params.onSkip?.(pick.id, ready.error);
|
|
111
|
+
remaining = remaining.filter((a) => a !== pick);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
package/dist/shim-core.js
CHANGED
|
@@ -137,7 +137,9 @@ export function mergeHealthStates(disk, live, now, pruneAfterMs = PRUNE_AFTER_MS
|
|
|
137
137
|
if (live.credential && (!credential || live.credential.seenAt >= credential.seenAt)) {
|
|
138
138
|
credential = live.credential;
|
|
139
139
|
}
|
|
140
|
-
if (now !== undefined &&
|
|
140
|
+
if (now !== undefined &&
|
|
141
|
+
credential?.status === "ok" &&
|
|
142
|
+
now - credential.seenAt > pruneAfterMs) {
|
|
141
143
|
credential = undefined;
|
|
142
144
|
}
|
|
143
145
|
const updatedAt = Math.max(disk.updatedAt ?? 0, live.updatedAt ?? 0);
|
package/dist/shim.js
CHANGED
|
@@ -5,7 +5,7 @@ import { classifyStateReadFailure, clearCredentialFailure, clearModelRejection,
|
|
|
5
5
|
import { rewriteModelArg } from "./degrade.js";
|
|
6
6
|
import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
|
|
7
7
|
import { canonicalModelId } from "./models.js";
|
|
8
|
-
import { buildRetryEnv,
|
|
8
|
+
import { buildRetryEnv, chooseLaunchableRetryAccount, materializeRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
|
|
9
9
|
import { effectiveConfigDir, handoverForLaunch, handoverSnapshotTo, parseSessionDirs, SESSION_DIRS_ENV, snapshotResumeTranscript, } from "./session-handover.js";
|
|
10
10
|
function resolveClaudeCommand() {
|
|
11
11
|
const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
|
|
@@ -95,6 +95,7 @@ if (modelOverride) {
|
|
|
95
95
|
process.stderr.write(`[multi-clawd shim] ${note}\n`);
|
|
96
96
|
}
|
|
97
97
|
const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
|
|
98
|
+
delete process.env[RETRY_ROSTER_ENV];
|
|
98
99
|
const resumeSnapshot = retryRoster.length > 0 && parseSessionDirs(process.env[SESSION_DIRS_ENV]).length > 0
|
|
99
100
|
? snapshotResumeTranscript(childArgs, process.env)
|
|
100
101
|
: undefined;
|
|
@@ -233,11 +234,13 @@ function attemptRetry() {
|
|
|
233
234
|
if (!retryArmed || retryUsed)
|
|
234
235
|
return false;
|
|
235
236
|
const model = effectiveModelId();
|
|
236
|
-
const target =
|
|
237
|
+
const target = chooseLaunchableRetryAccount({
|
|
237
238
|
roster: retryRoster,
|
|
238
239
|
readState: readSiblingState,
|
|
239
240
|
modelId: model,
|
|
240
241
|
nowMs: Date.now(),
|
|
242
|
+
materialize: (account) => materializeRetryAccount(account, (path) => readFileSync(path, "utf8")),
|
|
243
|
+
onSkip: (id, reason) => process.stderr.write(`[multi-clawd shim] not retrying onto ${id}: its declared credential did not resolve (${reason})\n`),
|
|
241
244
|
});
|
|
242
245
|
if (!target) {
|
|
243
246
|
process.stderr.write(`[multi-clawd shim] model limit on ${accountId} with no healthy sibling to retry onto โ ` +
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
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",
|
package/scripts/cli.mjs
CHANGED
|
@@ -753,7 +753,7 @@ async function usageCommand(args) {
|
|
|
753
753
|
for (const a of accounts) {
|
|
754
754
|
const row = { id: a.id, label: a.label };
|
|
755
755
|
if (a.oauthTokenFile || a.oauthTokenRef) {
|
|
756
|
-
row.skipped = "token
|
|
756
|
+
row.skipped = "setup-token login โ not polled: the usage endpoint needs the user:profile scope, setup-tokens carry only user:inference (stream telemetry only)";
|
|
757
757
|
} else if (!a.native && !a.configDir) {
|
|
758
758
|
row.skipped = "no login dir";
|
|
759
759
|
} else {
|
package/scripts/doctor.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 1. plugin install + manifest/config key agreement (the --force trap)
|
|
7
7
|
* 2. compiled-artifact freshness (stale dist detection)
|
|
8
8
|
* 3. claude CLI availability + PATH sanity
|
|
9
|
-
* 4. per-account credential
|
|
9
|
+
* 4. per-account credential health (judged on expiry and proof, never on
|
|
10
|
+
* presence), credential split-store detection, and which Claude login each
|
|
10
11
|
* account actually authenticates as (and whether two share one)
|
|
11
12
|
* 5. per-account rate-limit telemetry (state files, age, windows)
|
|
12
13
|
* 6. pool configuration, sticky state, and the account the next turn runs on
|
|
@@ -280,55 +281,73 @@ try {
|
|
|
280
281
|
|
|
281
282
|
// โโ 4. account credentials (values never printed) โโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
282
283
|
console.log("account credentials");
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
"
|
|
289
|
-
"
|
|
290
|
-
|
|
291
|
-
|
|
284
|
+
// "cli": judging a credential source is doctor's own diagnostic (see the
|
|
285
|
+
// identity import below). The store module carries the system I/O; both must
|
|
286
|
+
// come from the same build so the io matches the judge.
|
|
287
|
+
const { assessAccountCredential } = await importDist(
|
|
288
|
+
"login-health.js",
|
|
289
|
+
["assessAccountCredential"],
|
|
290
|
+
"cli",
|
|
291
|
+
);
|
|
292
|
+
const { createSystemCredentialIo, findCredentialCopies, describeSplitStore } = await importDist(
|
|
293
|
+
"credential-store.js",
|
|
294
|
+
["createSystemCredentialIo", "findCredentialCopies", "describeSplitStore"],
|
|
295
|
+
"cli",
|
|
296
|
+
);
|
|
297
|
+
const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount, lastAuthSuccessAt } =
|
|
298
|
+
await importDist("health.js", [
|
|
299
|
+
"summarizeWindowUsage",
|
|
300
|
+
"classifyAccountHealth",
|
|
301
|
+
"overdueProbeAccount",
|
|
302
|
+
"lastAuthSuccessAt",
|
|
303
|
+
]);
|
|
292
304
|
// "cli", like the chain audits: resolving WHICH login backs an account is
|
|
293
305
|
// doctor's own diagnostic, not a description of the running plugin's
|
|
294
306
|
// behaviour. Asking the installed copy meant a fix to the resolver could not
|
|
295
307
|
// take effect until the plugin itself was reinstalled โ so the CLI reported a
|
|
296
308
|
// bug it had already been taught not to make.
|
|
297
|
-
const {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
309
|
+
const {
|
|
310
|
+
resolveAccountIdentity,
|
|
311
|
+
describeIdentity,
|
|
312
|
+
findDuplicateLogins,
|
|
313
|
+
maskEmail,
|
|
314
|
+
nativeDefaultConfigDir,
|
|
315
|
+
} = await importDist(
|
|
316
|
+
"account-identity.js",
|
|
317
|
+
["resolveAccountIdentity", "describeIdentity", "findDuplicateLogins", "maskEmail", "nativeDefaultConfigDir"],
|
|
318
|
+
"cli",
|
|
319
|
+
);
|
|
303
320
|
const { decideStickySelection } = await importDist("sticky.js", ["decideStickySelection"]);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
321
|
+
// Keychain reads name the account the Claude CLI uses (`-a`), never just the
|
|
322
|
+
// service. Token values are reduced to expiry metadata where they are read and
|
|
323
|
+
// are never printed.
|
|
324
|
+
const io = createSystemCredentialIo();
|
|
325
|
+
// A native account's child runs with CLAUDE_CONFIG_DIR CLEARED (the pool strips
|
|
326
|
+
// it before every launch), so its login lives under the real home whatever
|
|
327
|
+
// this shell says. Doctor run inside an agent session served by the pool
|
|
328
|
+
// inherits the serving account's dir; honouring it resolved "native" from that
|
|
329
|
+
// account and reported two distinct logins as one.
|
|
330
|
+
const nativeDir = nativeDefaultConfigDir(process.env, HOME);
|
|
331
|
+
if (nativeDir.ignored) {
|
|
332
|
+
note(
|
|
333
|
+
`CLAUDE_CONFIG_DIR=${nativeDir.ignored} is set in this shell โ ignored: a native account runs ` +
|
|
334
|
+
`with it cleared, so native is resolved from ${nativeDir.dir}`,
|
|
335
|
+
);
|
|
319
336
|
}
|
|
320
|
-
// A native account's child sets no CLAUDE_CONFIG_DIR, so it authenticates
|
|
321
|
-
// against whatever the default dir is in the ENV THE GATEWAY RUNS IN. Doctor
|
|
322
|
-
// reads the same variable and prints the path it used, so a box that exports
|
|
323
|
-
// CLAUDE_CONFIG_DIR globally shows its real source rather than a guess.
|
|
324
337
|
const identityIo = {
|
|
325
338
|
readFile: (p) => readFileSync(expandHome(p), "utf8"),
|
|
326
339
|
expandHome,
|
|
327
|
-
defaultConfigDir:
|
|
328
|
-
? expandHome(process.env.CLAUDE_CONFIG_DIR)
|
|
329
|
-
: join(HOME, ".claude"),
|
|
340
|
+
defaultConfigDir: nativeDir.dir,
|
|
330
341
|
homeDir: HOME,
|
|
331
342
|
};
|
|
343
|
+
/**
|
|
344
|
+
* Last time the CLI's login for this account was accepted. The shim's file
|
|
345
|
+
* only: the usage poll authenticates with the credentials FILE, which on a
|
|
346
|
+
* split store is not the copy the CLI reads, so it proves nothing here.
|
|
347
|
+
*/
|
|
348
|
+
function authProofAt(accountId) {
|
|
349
|
+
return lastAuthSuccessAt(readJson(join(STATE_DIR, `${accountId}.json`)));
|
|
350
|
+
}
|
|
332
351
|
const accounts = pluginConfig.accounts ?? [];
|
|
333
352
|
const identities = [];
|
|
334
353
|
if (accounts.length === 0) warn("no accounts configured");
|
|
@@ -361,24 +380,51 @@ for (const account of accounts) {
|
|
|
361
380
|
// credential EXISTS, and #8 is exactly the case where a present credential
|
|
362
381
|
// is a session the Claude CLI has already rejected. A recorded runtime
|
|
363
382
|
// failure is the stronger evidence, so it is reported as such.
|
|
383
|
+
// The record stands until a successful turn (or a re-login) clears it, so
|
|
384
|
+
// its age changes the wording, never the verdict.
|
|
364
385
|
const recorded = readJson(join(STATE_DIR, `${account.id}.json`))?.credential;
|
|
365
386
|
if (recorded?.status === "failed") {
|
|
366
|
-
const ageMin = Math.round((Date.now() - recorded.seenAt) / 60000);
|
|
387
|
+
const ageMin = Math.max(0, Math.round((Date.now() - recorded.seenAt) / 60000));
|
|
388
|
+
const age = ageMin < 90 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`;
|
|
367
389
|
bad(
|
|
368
|
-
`${account.id}: the Claude CLI rejected this login ${
|
|
390
|
+
`${account.id}: the Claude CLI rejected this login ${age} ago${
|
|
369
391
|
recorded.reason ? ` (${recorded.reason})` : ""
|
|
370
|
-
}
|
|
392
|
+
} and no successful turn has been seen since โ ` +
|
|
393
|
+
`fix with \`multi-clawd login ${account.id}\`, or prove an out-of-band fix with ` +
|
|
394
|
+
`\`multi-clawd doctor --probe\``,
|
|
371
395
|
);
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
if (account.oauthTokenRef) {
|
|
396
|
+
} else if (account.oauthTokenRef) {
|
|
375
397
|
warn(`${account.id}: oauthTokenRef โ validated by the gateway's async probe, not doctor`);
|
|
376
|
-
|
|
398
|
+
} else {
|
|
399
|
+
const check = assessAccountCredential(account, io, { proof: { lastSuccessAt: authProofAt(account.id) } });
|
|
400
|
+
if (check.status === "ok") ok(`${account.id}: credential verified โ ${check.detail ?? "ok"}`);
|
|
401
|
+
else if (check.status === "unverified") {
|
|
402
|
+
// Not a failure (an idle account's access token expires routinely), and
|
|
403
|
+
// never "alive": a dead refresh token looks exactly like this at rest.
|
|
404
|
+
warn(
|
|
405
|
+
`${account.id}: credential UNVERIFIED โ ${check.reason}. The next turn on it refreshes or ` +
|
|
406
|
+
`fails; prove it now with \`multi-clawd doctor --probe\``,
|
|
407
|
+
);
|
|
408
|
+
} else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
|
|
409
|
+
else bad(`${account.id}: ${check.reason}`);
|
|
410
|
+
}
|
|
411
|
+
// Credential split-store: more than one copy of this login's OAuth
|
|
412
|
+
// credential. Refresh tokens rotate, so once the copies diverge only the one
|
|
413
|
+
// refreshed last can still refresh โ and the copy a tool happens to read
|
|
414
|
+
// decides whether it sees a live login or a dead one. Warn-level: the CLI's
|
|
415
|
+
// own copy may be fine today; this is the early warning.
|
|
416
|
+
const split = findCredentialCopies(account, io);
|
|
417
|
+
if (split.status === "split") {
|
|
418
|
+
const text = describeSplitStore(split, Date.now());
|
|
419
|
+
warn(`credential split-store โ ${text.headline}`);
|
|
420
|
+
for (const line of text.copies) console.log(` ยท ${line}`);
|
|
421
|
+
console.log(` remedy: ${text.remedy}`);
|
|
422
|
+
} else if (split.status !== "n/a" && VERBOSE) {
|
|
423
|
+
note(`${account.id}: credential split-store โ ${split.status === "single" ? "one copy" : "no stored copy"}`);
|
|
424
|
+
}
|
|
425
|
+
if (split.incomplete) {
|
|
426
|
+
note(`${account.id}: credential split-store check incomplete โ the Keychain could not be listed, so same-service duplicates may be missed`);
|
|
377
427
|
}
|
|
378
|
-
const check = checkAccountCredential(account, io);
|
|
379
|
-
if (check.status === "ok") ok(`${account.id}: credential source looks alive`);
|
|
380
|
-
else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
|
|
381
|
-
else bad(`${account.id}: ${check.reason}`);
|
|
382
428
|
}
|
|
383
429
|
|
|
384
430
|
// Two ids, one login. This is the failure the rest of doctor cannot see: every
|
|
@@ -449,6 +495,27 @@ else {
|
|
|
449
495
|
const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
|
|
450
496
|
if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs โฅ 2`);
|
|
451
497
|
else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" โ ")}`);
|
|
498
|
+
// Setup-token members: what they do and do not get, said once, plainly.
|
|
499
|
+
const tokenMembers = members
|
|
500
|
+
.map((id) => accounts.find((a) => a.id === id))
|
|
501
|
+
.filter((a) => a && !a.native && (a.oauthTokenFile || a.oauthTokenRef));
|
|
502
|
+
if (tokenMembers.length > 0 && pool.usagePoll?.enabled !== false) {
|
|
503
|
+
note(
|
|
504
|
+
`usage poll: ${tokenMembers.map((a) => a.id).join(", ")} not polled โ a setup-token carries only the ` +
|
|
505
|
+
`user:inference scope and the usage endpoint needs user:profile (upstream claude-code #11985). ` +
|
|
506
|
+
`Rotation for ${tokenMembers.length === 1 ? "it" : "them"} runs on stream telemetry: each turn's ` +
|
|
507
|
+
`rate-limit events still trigger the threshold and 5-hour-warning rules, but an idle account is ` +
|
|
508
|
+
`not re-measured between turns and there is no 95% usage alert.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const refOnly = tokenMembers.filter((a) => !a.oauthTokenFile);
|
|
512
|
+
if (refOnly.length > 0) {
|
|
513
|
+
note(
|
|
514
|
+
`in-turn retry: ${refOnly.map((a) => a.id).join(", ")} (oauthTokenRef) cannot be a mid-turn retry ` +
|
|
515
|
+
`target โ the shim never receives secret values, and a reference has no file it could read. ` +
|
|
516
|
+
`${refOnly.length === 1 ? "It rotates" : "They rotate"} at the next launch instead.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
452
519
|
const sticky = readJson(join(STATE_DIR, `pool-${pool.id ?? "clawd"}.sticky.json`));
|
|
453
520
|
|
|
454
521
|
// WHICH ACCOUNT IS ACTUALLY BEING USED. Everything above describes the pool
|