@drakon-systems/multi-clawd 1.10.1 → 2.1.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 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
- Secret-free sibling accounts, one retry.
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
- (values never printed), **which Claude login each account authenticates as
265
- and which one the next turn will run on**, telemetry age, pool + sticky
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/v1.10.1/SETUP-AGENT.md
388
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.1.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
 
@@ -394,7 +397,7 @@ therefore executes — is fixed at a version you chose, not whatever `master`
394
397
  happens to say today. Swap the tag if you want a different release, but
395
398
  prefer a tag over a branch.
396
399
 
397
- **Requirements:** OpenClaw ≥ 2026.6, the `claude` CLI on `PATH`, and a
400
+ **Requirements:** OpenClaw ≥ 2026.8.1, the `claude` CLI on `PATH`, and a
398
401
  second Claude subscription you own. The current plugin SDK contract and runtime
399
402
  registration path are tested against OpenClaw **2026.8.1**. The standalone
400
403
  `multi-clawd` CLI and Hermes commands do not require the optional OpenClaw peer
@@ -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 > ~/.claw2/oauth-token # log in as the 2nd account
466
- chmod 600 ~/.claw2/oauth-token
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` accounts only.** A token-based account
656
- (`oauthTokenFile` / `oauthTokenRef`) runs in the default login dir on a
657
- token of its own, so the credentials file there belongs to a different
658
- account; those accounts keep stream telemetry only and `explain` says so.
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,20 +851,45 @@ 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).** An account that declares
833
- `oauthTokenRef` or `oauthTokenFile` is authenticated by that token. If it
834
- resolves to nothing — provider briefly unavailable, empty secret, truncated
835
- file — and the account has no `configDir` to fall back on, the launch is
836
- refused with `declares a token source but none resolved` rather than
837
- allowed to proceed on the machine's default login and spend a different
838
- account's quota under this account's name. Native accounts are exempt: the
839
- default login *is* their credential.
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
843
868
  showing only a trailing-newline mismatch is a false alarm.
844
869
  - Use only accounts you own, within your provider's terms of service.
845
870
 
871
+ ### Security notes: verified launch (OpenClaw ≥ 2026.8.1)
872
+
873
+ - **Same verification bar as the built-in backend, no stronger.** Each
874
+ backend's command is the `claude` executable found on the gateway's
875
+ `PATH`. It declares exactly the runtime artifact OpenClaw's built-in
876
+ `claude-cli` backend declares (`@anthropic-ai/claude-code`, native
877
+ executable names `claude` / `claude.exe`). For a native install, OpenClaw
878
+ verifies that executable by its basename, the same check it applies to its
879
+ own `claude-cli`. multi-clawd adds no package or binary of its own to that
880
+ authority and never lists `node` as a native executable.
881
+ - **The shim runs only the verified binary.** Agent turns go through the
882
+ shim, which is given the realpath checked when the turn starts and runs
883
+ that file under the registered command name. A different command, a
884
+ different `argv0`, or an interpreter prefix is refused rather than
885
+ wrapped.
886
+ - **`MULTI_CLAWD_CLAUDE_BIN` is operator-trusted.** When it is set in the
887
+ gateway's environment, the plugin uses it in place of the `PATH` lookup.
888
+ Whoever can set the gateway's environment already controls which binary
889
+ runs, so the variable widens nothing. Only point it at an executable you
890
+ trust. If it is set but not executable, the launch fails; nothing falls
891
+ back to another `claude`.
892
+
846
893
  ## Status & roadmap
847
894
 
848
895
  Early but real — built for and dogfooded in production.
@@ -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 && !account.configDir) {
23
- throw new Error(`[multi-clawd] account "${account.id}" declares a token source but none resolved — ` +
24
- `refusing to launch on the default login`);
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)
@@ -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,69 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
3
+ export const CLAUDE_BIN_ENV = "MULTI_CLAWD_CLAUDE_BIN";
4
+ export const CLAUDE_ARGV0_ENV = "MULTI_CLAWD_CLAUDE_ARGV0";
5
+ export function isExecutableFile(path) {
6
+ try {
7
+ if (!statSync(path).isFile())
8
+ return false;
9
+ accessSync(path, constants.X_OK);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export function findOnPath(name, pathValue) {
17
+ for (const dir of (pathValue ?? "").split(delimiter)) {
18
+ if (!dir || !isAbsolute(dir))
19
+ continue;
20
+ const candidate = resolve(join(dir, name));
21
+ if (isExecutableFile(candidate))
22
+ return candidate;
23
+ }
24
+ return undefined;
25
+ }
26
+ export function parseClaudeBinOverride(raw) {
27
+ if (!raw)
28
+ return undefined;
29
+ try {
30
+ const parsed = JSON.parse(raw);
31
+ if (Array.isArray(parsed) &&
32
+ parsed.length > 0 &&
33
+ parsed.every((p) => typeof p === "string" && p.length > 0)) {
34
+ const [command, ...prependArgs] = parsed;
35
+ return { kind: "argv", command, prependArgs };
36
+ }
37
+ }
38
+ catch {
39
+ }
40
+ return { kind: "path", value: raw };
41
+ }
42
+ export function resolveExecutable(command, pathValue) {
43
+ if (!command.includes("/") && !command.includes("\\"))
44
+ return findOnPath(command, pathValue);
45
+ if (!isAbsolute(command))
46
+ return undefined;
47
+ const absolute = resolve(command);
48
+ return isExecutableFile(absolute) ? absolute : undefined;
49
+ }
50
+ export function resolveClaudeExecutable(env, platform = process.platform) {
51
+ if (platform === "win32") {
52
+ return { ok: false, reason: "verified launch is not implemented on Windows" };
53
+ }
54
+ const override = parseClaudeBinOverride(env[CLAUDE_BIN_ENV]);
55
+ if (override) {
56
+ if (override.kind === "argv" && override.prependArgs.length > 0) {
57
+ return { ok: false, reason: `${CLAUDE_BIN_ENV} is an interpreter argv, not one executable` };
58
+ }
59
+ const raw = override.kind === "argv" ? override.command : override.value;
60
+ const command = resolveExecutable(raw, env.PATH);
61
+ return command
62
+ ? { ok: true, command, source: "override" }
63
+ : { ok: false, reason: `${CLAUDE_BIN_ENV}=${raw} is not an executable file` };
64
+ }
65
+ const command = findOnPath("claude", env.PATH);
66
+ return command
67
+ ? { ok: true, command, source: "path" }
68
+ : { ok: false, reason: "no executable `claude` on the gateway's PATH" };
69
+ }
@@ -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
+ }
@@ -124,7 +124,8 @@ const VERDICT_RANK = {
124
124
  no_data: 0,
125
125
  near_limit: 1,
126
126
  exhausted: 2,
127
- credential_failed: 3,
127
+ credential_unverified: 3,
128
+ credential_failed: 4,
128
129
  };
129
130
  export function planDirectOrder(params) {
130
131
  const { members } = params;
@@ -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
- if (nowMs - credential.seenAt > CREDENTIAL_FAILED_TTL_MS)
47
- return undefined;
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: "credential_failed",
50
- resumeAt: credential.seenAt + CREDENTIAL_FAILED_TTL_MS,
51
- reason: `login rejected by the Claude CLI ${Math.round((nowMs - credential.seenAt) / 60000)}m ago${credential.reason ? `: ${credential.reason}` : ""} — re-authenticate this account`,
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) {