@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 +66 -19
- package/dist/account-env.js +41 -3
- package/dist/account-identity.js +5 -0
- package/dist/claude-command.js +69 -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 +173 -39
- package/dist/login-health.js +137 -40
- package/dist/retry-plan.js +38 -3
- package/dist/shim-core.js +3 -1
- package/dist/shim-transport.js +247 -0
- package/dist/shim.js +35 -15
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -4
- 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.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.
|
|
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
|
|
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,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
|
|
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
|
|
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.
|
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,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
|
+
}
|
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) {
|