@drakon-systems/multi-clawd 1.8.6 → 1.8.8

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
@@ -248,8 +248,10 @@ claude-cli/claude-fable-5 # main login
248
248
  - 🩺 **`npm run doctor` (v0.3)** — one command that says whether a box is
249
249
  actually ready: config/manifest agreement (with the exact `--force`
250
250
  preflight strip plan), dist freshness, CLI presence, credential health
251
- (values never printed), telemetry age, pool + sticky state, watchdog
252
- presence, optional `--probe` end-to-end turn.
251
+ (values never printed), **which Claude login each account authenticates as
252
+ and which one the next turn will run on**, telemetry age, pool + sticky
253
+ state, **whether every non-Claude chain rung still has a usable auth
254
+ profile**, watchdog presence, optional `--probe` (one turn per account).
253
255
  - 🧯 **Self-healing config** — registration re-reads the resolved runtime
254
256
  config if the loader hands it an empty block, so a flaky registration pass
255
257
  can't silently no-op the plugin.
@@ -367,7 +369,7 @@ openclaw plugins install (Get-Location).Path
367
369
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
368
370
  Code on the target machine already? Paste it this and go make coffee:
369
371
 
370
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.6/SETUP-AGENT.md
372
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.8/SETUP-AGENT.md
371
373
  > and follow it to set up multi-clawd on this machine. I own a second
372
374
  > Claude account — ask me when you need me to log in.
373
375
 
@@ -0,0 +1,131 @@
1
+ export function identityFilePath(account, io) {
2
+ const dir = !account.native && account.configDir ? io.expandHome(account.configDir) : io.defaultConfigDir;
3
+ if (!dir)
4
+ return undefined;
5
+ if (account.oauthTokenFile || account.oauthTokenRef)
6
+ return undefined;
7
+ return `${dir.replace(/\/+$/, "")}/.claude.json`;
8
+ }
9
+ export function formatPlan(oauthAccount) {
10
+ const tier = typeof oauthAccount.organizationRateLimitTier === "string"
11
+ ? oauthAccount.organizationRateLimitTier
12
+ : undefined;
13
+ const type = typeof oauthAccount.organizationType === "string" ? oauthAccount.organizationType : undefined;
14
+ const source = tier ?? type;
15
+ if (!source)
16
+ return undefined;
17
+ const multiplier = source.match(/max_(\d+)x/)?.[1];
18
+ if (multiplier)
19
+ return `Max ${multiplier}x`;
20
+ if (/max/i.test(source))
21
+ return "Max";
22
+ if (/enterprise/i.test(source))
23
+ return "Enterprise";
24
+ if (/team/i.test(source))
25
+ return "Team";
26
+ if (/pro/i.test(source))
27
+ return "Pro";
28
+ if (/free/i.test(source))
29
+ return "Free";
30
+ return source;
31
+ }
32
+ export function parseIdentityFile(text) {
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(text);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ const oauthAccount = parsed?.oauthAccount;
41
+ if (!oauthAccount || typeof oauthAccount !== "object")
42
+ return undefined;
43
+ const account = oauthAccount;
44
+ const str = (key) => (typeof account[key] === "string" ? account[key] : undefined);
45
+ const identity = {
46
+ accountUuid: str("accountUuid"),
47
+ email: str("emailAddress"),
48
+ organizationName: str("organizationName"),
49
+ plan: formatPlan(account),
50
+ };
51
+ if (!identity.accountUuid && !identity.email)
52
+ return undefined;
53
+ return identity;
54
+ }
55
+ export function resolveAccountIdentity(account, io) {
56
+ const path = identityFilePath(account, io);
57
+ if (!path) {
58
+ return {
59
+ accountId: account.id,
60
+ status: "unknown",
61
+ reason: account.oauthTokenFile || account.oauthTokenRef
62
+ ? "token-sourced login — the identity lives inside the token, not on disk"
63
+ : "no config dir to read",
64
+ };
65
+ }
66
+ let text;
67
+ try {
68
+ text = io.readFile(path);
69
+ }
70
+ catch {
71
+ return {
72
+ accountId: account.id,
73
+ status: "unknown",
74
+ reason: `no readable ${path} — this account has never completed a login here`,
75
+ source: path,
76
+ };
77
+ }
78
+ const identity = parseIdentityFile(text);
79
+ if (!identity) {
80
+ return {
81
+ accountId: account.id,
82
+ status: "unknown",
83
+ reason: `${path} carries no oauthAccount record`,
84
+ source: path,
85
+ };
86
+ }
87
+ return { accountId: account.id, status: "resolved", source: path, ...identity };
88
+ }
89
+ export function maskEmail(email) {
90
+ const at = email.lastIndexOf("@");
91
+ if (at < 1)
92
+ return "…";
93
+ const local = email.slice(0, at);
94
+ const domain = email.slice(at);
95
+ if (local.length <= 2)
96
+ return `${local[0]}…${domain}`;
97
+ return `${local[0]}…${local[local.length - 1]}${domain}`;
98
+ }
99
+ export function describeIdentity(identity, options = {}) {
100
+ if (identity.status !== "resolved")
101
+ return identity.reason ?? "identity unknown";
102
+ const who = identity.email
103
+ ? options.raw
104
+ ? identity.email
105
+ : maskEmail(identity.email)
106
+ : options.raw
107
+ ? (identity.accountUuid ?? "unknown login")
108
+ : `login ${identity.accountUuid?.slice(0, 8) ?? "?"}…`;
109
+ const parts = [who];
110
+ if (identity.plan)
111
+ parts.push(identity.plan);
112
+ if (options.raw && identity.organizationName)
113
+ parts.push(identity.organizationName);
114
+ return parts.join(" · ");
115
+ }
116
+ export function findDuplicateLogins(identities) {
117
+ const groups = new Map();
118
+ for (const identity of identities) {
119
+ if (identity.status !== "resolved")
120
+ continue;
121
+ const key = identity.accountUuid ?? (identity.email ? `email:${identity.email}` : undefined);
122
+ if (!key)
123
+ continue;
124
+ const group = groups.get(key) ?? { ids: [], email: identity.email };
125
+ group.ids.push(identity.accountId);
126
+ groups.set(key, group);
127
+ }
128
+ return [...groups.entries()]
129
+ .filter(([, group]) => group.ids.length > 1)
130
+ .map(([key, group]) => ({ key, accountIds: group.ids, email: group.email }));
131
+ }
@@ -96,8 +96,8 @@ function collectCronRefs(surface, node, depth, out) {
96
96
  collectCronRefs(`${surface}.${k}`, v, depth + 1, out);
97
97
  }
98
98
  }
99
- const RESERVED_AGENT_KEYS = new Set(["defaults", "list", "models"]);
100
- function collectChainRefs(config) {
99
+ const RESERVED_AGENT_KEYS = new Set(["defaults", "list", "models", "entries", "ownership"]);
100
+ export function collectChainRefs(config) {
101
101
  const out = [];
102
102
  const push = (surface, value) => {
103
103
  for (const r of extractModelRefs(surface, value))
@@ -128,6 +128,24 @@ function collectChainRefs(config) {
128
128
  if ("cron" in agent)
129
129
  collectCronRefs(`agents.${name}.cron`, agent.cron, 0, out);
130
130
  }
131
+ const entries = agents.entries;
132
+ if (entries && typeof entries === "object" && !Array.isArray(entries)) {
133
+ for (const [name, v] of Object.entries(entries)) {
134
+ if (!v || typeof v !== "object")
135
+ continue;
136
+ const agent = v;
137
+ if ("model" in agent)
138
+ push(`agents.entries.${name}.model`, agent.model);
139
+ const agentSubagents = agent.subagents;
140
+ if (agentSubagents && typeof agentSubagents === "object") {
141
+ const sub = agentSubagents;
142
+ if (sub.model !== undefined)
143
+ push(`agents.entries.${name}.subagents.model`, sub.model);
144
+ }
145
+ if ("cron" in agent)
146
+ collectCronRefs(`agents.entries.${name}.cron`, agent.cron, 0, out);
147
+ }
148
+ }
131
149
  if (Array.isArray(agents.list)) {
132
150
  agents.list.forEach((a, i) => {
133
151
  if (!a || typeof a !== "object")
@@ -187,6 +205,17 @@ function collectAgentModelBlocks(config) {
187
205
  if ("model" in agent)
188
206
  out.push({ agent: name, surface: `agents.${name}.model`, model: agent.model });
189
207
  }
208
+ const entries = agents.entries;
209
+ if (entries && typeof entries === "object" && !Array.isArray(entries)) {
210
+ for (const [name, v] of Object.entries(entries)) {
211
+ if (!v || typeof v !== "object")
212
+ continue;
213
+ const agent = v;
214
+ if (!("model" in agent))
215
+ continue;
216
+ out.push({ agent: name, surface: `agents.entries.${name}.model`, model: agent.model });
217
+ }
218
+ }
190
219
  if (Array.isArray(agents.list)) {
191
220
  agents.list.forEach((a, i) => {
192
221
  if (!a || typeof a !== "object")
@@ -0,0 +1,125 @@
1
+ export const STALE_OAUTH_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
2
+ const PROFILE_FREE_PROVIDERS = new Set([
3
+ "claude-cli",
4
+ "ollama",
5
+ "lmstudio",
6
+ "llamacpp",
7
+ "vllm",
8
+ "sglang",
9
+ "local",
10
+ ]);
11
+ const POOL_MEMBER_RE = /^claw\d+$/;
12
+ function providerOf(ref) {
13
+ const idx = ref.indexOf("/");
14
+ if (idx < 1)
15
+ return undefined;
16
+ return ref.slice(0, idx).trim().toLowerCase();
17
+ }
18
+ function orderedIds(order, provider) {
19
+ const entry = order?.[provider];
20
+ if (!Array.isArray(entry))
21
+ return undefined;
22
+ const ids = entry.filter((v) => typeof v === "string" && v.length > 0);
23
+ return ids.length > 0 ? ids : undefined;
24
+ }
25
+ function expiryAgeMs(profile, nowMs) {
26
+ if (!profile.expiresAt)
27
+ return undefined;
28
+ const at = Date.parse(profile.expiresAt);
29
+ if (Number.isNaN(at))
30
+ return undefined;
31
+ return nowMs - at;
32
+ }
33
+ function humanAge(ms) {
34
+ const days = Math.floor(ms / 86_400_000);
35
+ if (days >= 1)
36
+ return `${days}d`;
37
+ const hours = Math.floor(ms / 3_600_000);
38
+ if (hours >= 1)
39
+ return `${hours}h`;
40
+ return `${Math.max(1, Math.round(ms / 60_000))}m`;
41
+ }
42
+ export function auditChainAuth(input) {
43
+ const poolId = input.poolId?.trim().toLowerCase();
44
+ const bySurface = new Map();
45
+ for (const { surface, ref, allowlist } of input.refs) {
46
+ if (allowlist)
47
+ continue;
48
+ const provider = providerOf(ref);
49
+ if (!provider)
50
+ continue;
51
+ if (provider === poolId || POOL_MEMBER_RE.test(provider))
52
+ continue;
53
+ if (PROFILE_FREE_PROVIDERS.has(provider))
54
+ continue;
55
+ const list = bySurface.get(provider) ?? [];
56
+ if (!list.includes(surface))
57
+ list.push(surface);
58
+ bySurface.set(provider, list);
59
+ }
60
+ const findings = [];
61
+ for (const [provider, surfaces] of [...bySurface.entries()].sort()) {
62
+ const profiles = input.profiles.filter((p) => p.provider?.trim().toLowerCase() === provider);
63
+ const order = orderedIds(input.order, provider);
64
+ if (profiles.length === 0) {
65
+ findings.push({
66
+ severity: "warn",
67
+ provider,
68
+ surfaces,
69
+ reason: `no saved auth profile; this rung can only authenticate from an environment API key. ` +
70
+ `Confirm with \`openclaw models auth list --provider ${provider}\``,
71
+ });
72
+ continue;
73
+ }
74
+ const eligible = order ? profiles.filter((p) => order.includes(p.id)) : profiles;
75
+ if (eligible.length === 0) {
76
+ const missing = order?.filter((id) => !profiles.some((p) => p.id === id)) ?? [];
77
+ findings.push({
78
+ severity: "bad",
79
+ provider,
80
+ surfaces,
81
+ reason: `auth.order.${provider} excludes every saved profile (${profiles
82
+ .map((p) => p.id)
83
+ .join(", ")})${missing.length > 0 ? ` and names ${missing.join(", ")}, which is not in the store` : ""} — the rung can never authenticate. Fix the order or remove it.`,
84
+ });
85
+ continue;
86
+ }
87
+ if (order) {
88
+ const missing = order.filter((id) => !profiles.some((p) => p.id === id));
89
+ if (missing.length > 0) {
90
+ findings.push({
91
+ severity: "warn",
92
+ provider,
93
+ surfaces,
94
+ reason: `auth.order.${provider} names ${missing.join(", ")}, which is not in the auth store — silently ignored`,
95
+ });
96
+ }
97
+ }
98
+ const ages = eligible.map((p) => ({ profile: p, age: expiryAgeMs(p, input.nowMs) }));
99
+ const dated = ages.filter((a) => a.age !== undefined);
100
+ const undatedUsable = ages.length > dated.length;
101
+ if (!undatedUsable && dated.length > 0 && dated.every((a) => a.age > 0)) {
102
+ const freshest = Math.min(...dated.map((a) => a.age));
103
+ if (freshest > STALE_OAUTH_GRACE_MS) {
104
+ findings.push({
105
+ severity: "bad",
106
+ provider,
107
+ surfaces,
108
+ reason: `every eligible profile's OAuth token expired (oldest usable one ${humanAge(freshest)} ago, ` +
109
+ `past the ${Math.round(STALE_OAUTH_GRACE_MS / 86_400_000)}d refresh grace) — refresh has stopped working. ` +
110
+ `Re-run \`openclaw models auth login --provider ${provider}\``,
111
+ });
112
+ }
113
+ else {
114
+ findings.push({
115
+ severity: "warn",
116
+ provider,
117
+ surfaces,
118
+ reason: `every eligible profile's OAuth token is past expiry (${humanAge(freshest)} ago). ` +
119
+ `Normally refreshed on next use — if this rung is failing, probe it`,
120
+ });
121
+ }
122
+ }
123
+ }
124
+ return findings;
125
+ }
package/dist/models.js CHANGED
@@ -14,7 +14,8 @@ const KNOWN_SPECS = {
14
14
  "claude-opus-4-7": { name: "Claude Opus 4.7", contextWindow: 1048576, maxTokens: 64000 },
15
15
  "claude-opus-4-6": { name: "Claude Opus 4.6", contextWindow: 1048576, maxTokens: 64000 },
16
16
  "claude-sonnet-4-6": { name: "Claude Sonnet 4.6", contextWindow: 1048576, maxTokens: 64000 },
17
- "claude-sonnet-5": { name: "Claude Sonnet 5", contextWindow: 200000, maxTokens: 64000 },
17
+ "claude-sonnet-5": { name: "Claude Sonnet 5", contextWindow: 1000000, maxTokens: 64000 },
18
+ "claude-fable-5-1": { name: "Claude Fable 5.1", contextWindow: 1000000, maxTokens: 128000 },
18
19
  "claude-fable-5": { name: "Claude Fable 5", contextWindow: 1000000, maxTokens: 128000 },
19
20
  "claude-haiku-4-5": { name: "Claude Haiku 4.5", contextWindow: 200000, maxTokens: 64000 },
20
21
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.8.6",
4
+ "version": "1.8.8",
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": "1.8.6",
3
+ "version": "1.8.8",
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",
@@ -6,16 +6,22 @@
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-source health
9
+ * 4. per-account credential-source health + which Claude login each
10
+ * account actually authenticates as (and whether two share one)
10
11
  * 5. per-account rate-limit telemetry (state files, age, windows)
11
- * 6. pool configuration + sticky state
12
+ * 6. pool configuration, sticky state, and the account the next turn runs on
12
13
  * 7. effective chain — Claude tiers must route through the clawd pool
13
- * 8. eviction watchdog presence (launchd/systemd)
14
+ * 8. chain auth — every non-Claude rung must have a usable auth profile
15
+ * 9. eviction watchdog presence (launchd/systemd)
14
16
  *
15
17
  * Flags:
16
18
  * --preflight print the exact config keys to strip before a --force
17
19
  * install against an older installed manifest
18
- * --probe spend one cheap turn proving the pool answers end-to-end
20
+ * --probe spend one cheap turn per account (plus the pool) proving
21
+ * each login answers end-to-end
22
+ * --probe-pool with --probe: the pool ref only, one turn total
23
+ * --raw print login emails and session keys unmasked
24
+ * --verbose list allowlist rungs, per-account verdicts, every surface
19
25
  *
20
26
  * Exit code: 0 all good, 1 any ❌.
21
27
  */
@@ -63,6 +69,11 @@ const STATE_DIR = join(HOME, ".openclaw", "state", "multi-clawd");
63
69
  const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
64
70
 
65
71
  const args = new Set(process.argv.slice(2));
72
+ // Hoisted: doctor output is pasted into issues and support threads, so every
73
+ // section that can print an identifying value (login emails in §4/§6, session
74
+ // keys in §7) masks by default and honours the same single --raw opt-in.
75
+ const VERBOSE = process.env.DOCTOR_VERBOSE === "1" || args.has("--verbose");
76
+ const RAW = process.env.DOCTOR_RAW === "1" || args.has("--raw");
66
77
  let failures = 0;
67
78
  const ok = (msg) => console.log(` ✅ ${msg}`);
68
79
  const warn = (msg) => console.log(` ⚠️ ${msg}`);
@@ -86,6 +97,35 @@ function readJson(path) {
86
97
  }
87
98
  }
88
99
 
100
+ /**
101
+ * Import a compiled module, preferring the INSTALLED plugin so findings
102
+ * describe the artifact that actually runs — but falling back to this
103
+ * checkout when the installed copy predates an export doctor needs.
104
+ *
105
+ * The fallback is the point: a bare `import(EXT).catch(() => import(REPO))`
106
+ * only catches a missing FILE. An older installed dist that loads fine but
107
+ * lacks a newly added export produced `X is not a function` mid-report, with
108
+ * every earlier section already printed as if all was well.
109
+ */
110
+ async function importDist(file, required = [], prefer = "installed") {
111
+ // `prefer: "cli"` for pure static analysis (the chain audits): that logic is
112
+ // doctor's own, so it must run at the CLI's version. Mixing the two — a new
113
+ // section reading the checkout while an older section read the install —
114
+ // produced two different views of the same config in one report.
115
+ const dirs = prefer === "cli" ? [REPO_DIR, EXT_DIR] : [EXT_DIR, REPO_DIR];
116
+ const candidates = dirs.map((d) => join(d, "dist", file));
117
+ let firstError;
118
+ for (const path of candidates) {
119
+ try {
120
+ const mod = await import(path);
121
+ if (required.every((name) => typeof mod[name] === "function")) return mod;
122
+ } catch (err) {
123
+ firstError ??= err;
124
+ }
125
+ }
126
+ throw firstError ?? new Error(`no usable dist/${file} (missing: ${required.join(", ")})`);
127
+ }
128
+
89
129
  function newestMtime(dir, exts) {
90
130
  let newest = 0;
91
131
  let newestFile = "";
@@ -240,12 +280,19 @@ try {
240
280
 
241
281
  // ── 4. account credentials (values never printed) ──────────────────────────
242
282
  console.log("account credentials");
243
- const { checkAccountCredential } = await import(join(EXT_DIR, "dist", "login-health.js")).catch(
244
- () => import(join(REPO_DIR, "dist", "login-health.js")),
245
- );
246
- const { summarizeWindowUsage } = await import(join(EXT_DIR, "dist", "health.js")).catch(() =>
247
- import(join(REPO_DIR, "dist", "health.js")),
248
- );
283
+ const { checkAccountCredential } = await importDist("login-health.js", ["checkAccountCredential"]);
284
+ const { summarizeWindowUsage, classifyAccountHealth } = await importDist("health.js", [
285
+ "summarizeWindowUsage",
286
+ "classifyAccountHealth",
287
+ ]);
288
+ const { resolveAccountIdentity, describeIdentity, findDuplicateLogins, maskEmail } =
289
+ await importDist("account-identity.js", [
290
+ "resolveAccountIdentity",
291
+ "describeIdentity",
292
+ "findDuplicateLogins",
293
+ "maskEmail",
294
+ ]);
295
+ const { decideStickySelection } = await importDist("sticky.js", ["decideStickySelection"]);
249
296
  const io = {
250
297
  readFile: (p) => readFileSync(expandHome(p), "utf8"),
251
298
  keychainHasClaudeCredentials: () => {
@@ -260,9 +307,45 @@ const io = {
260
307
  },
261
308
  platform: process.platform,
262
309
  };
310
+ // A native account's child sets no CLAUDE_CONFIG_DIR, so it authenticates
311
+ // against whatever the default dir is in the ENV THE GATEWAY RUNS IN. Doctor
312
+ // reads the same variable and prints the path it used, so a box that exports
313
+ // CLAUDE_CONFIG_DIR globally shows its real source rather than a guess.
314
+ const identityIo = {
315
+ readFile: (p) => readFileSync(expandHome(p), "utf8"),
316
+ expandHome,
317
+ defaultConfigDir: process.env.CLAUDE_CONFIG_DIR
318
+ ? expandHome(process.env.CLAUDE_CONFIG_DIR)
319
+ : join(HOME, ".claude"),
320
+ };
263
321
  const accounts = pluginConfig.accounts ?? [];
322
+ const identities = [];
264
323
  if (accounts.length === 0) warn("no accounts configured");
265
324
  for (const account of accounts) {
325
+ // WHO this account is, before WHETHER its credential works. An id is a
326
+ // label the operator chose; the login is the thing quota is spent against,
327
+ // and the two drift apart silently (a config dir re-logged-in as the wrong
328
+ // user looks perfectly healthy on every other line of this report).
329
+ const identity = resolveAccountIdentity(account, identityIo);
330
+ identities.push(identity);
331
+ const source = account.native
332
+ ? `native login, ${identityIo.defaultConfigDir}`
333
+ : account.configDir
334
+ ? `config dir ${account.configDir}`
335
+ : account.oauthTokenFile
336
+ ? "token file"
337
+ : account.oauthTokenRef
338
+ ? "token ref"
339
+ : "no declared source";
340
+ if (identity.status === "resolved") {
341
+ note(`${account.id} → ${describeIdentity(identity, { raw: RAW })} (${source})`);
342
+ } else if (account.oauthTokenFile || account.oauthTokenRef) {
343
+ // Expected, not a fault: the identity is inside the token. Proving it
344
+ // would cost a real turn, which doctor does not spend unless --probe.
345
+ note(`${account.id} → login not knowable at rest (${source}) — ${identity.reason}`);
346
+ } else {
347
+ warn(`${account.id}: cannot tell which Claude login this is — ${identity.reason}`);
348
+ }
266
349
  // RUNTIME credential health first: the source check below only proves a
267
350
  // credential EXISTS, and #8 is exactly the case where a present credential
268
351
  // is a session the Claude CLI has already rejected. A recorded runtime
@@ -287,6 +370,29 @@ for (const account of accounts) {
287
370
  else bad(`${account.id}: ${check.reason}`);
288
371
  }
289
372
 
373
+ // Two ids, one login. This is the failure the rest of doctor cannot see: every
374
+ // section still says READY while "failover" rotates onto the quota it just
375
+ // exhausted. Scoped to pool members when a pool exists — outside a pool,
376
+ // sharing a login is a legitimate way to run two profiles of one account.
377
+ {
378
+ const poolMembers = pluginConfig.pool?.accounts;
379
+ const scoped = Array.isArray(poolMembers)
380
+ ? identities.filter((i) => poolMembers.includes(i.accountId))
381
+ : identities;
382
+ for (const dupe of findDuplicateLogins(scoped)) {
383
+ const who = dupe.email
384
+ ? ` (${RAW ? dupe.email : maskEmail(dupe.email)})`
385
+ : "";
386
+ bad(
387
+ `${dupe.accountIds.join(" and ")} are the SAME Claude login${who} — rotating between them ` +
388
+ `buys no extra quota. Log one of them into a different account (\`multi-clawd login <account>\`).`,
389
+ );
390
+ }
391
+ if (scoped.filter((i) => i.status === "resolved").length > 1 && findDuplicateLogins(scoped).length === 0) {
392
+ ok("pool accounts are distinct Claude logins");
393
+ }
394
+ }
395
+
290
396
  // ── 5. telemetry state ──────────────────────────────────────────────────────
291
397
  console.log("rate-limit telemetry");
292
398
  for (const account of accounts) {
@@ -333,8 +439,62 @@ else {
333
439
  if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
334
440
  else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
335
441
  const sticky = readJson(join(STATE_DIR, `pool-${pool.id ?? "clawd"}.sticky.json`));
336
- if (sticky) warn(`pool is currently stuck to ${sticky.account} (since ${new Date(sticky.since).toISOString()})`);
337
- else ok("no sticky — pool is on its home account");
442
+
443
+ // WHICH ACCOUNT IS ACTUALLY BEING USED. Everything above describes the pool
444
+ // as configured; this answers the question an operator actually asks — whose
445
+ // subscription does the next turn spend? Re-run the real selection (the same
446
+ // classify → sticky-dwell decision index.ts makes at launch) rather than
447
+ // inferring it from the sticky file, because "no sticky" means home, a live
448
+ // sticky can be about to expire, and health outranks both.
449
+ if (members.length > 0) {
450
+ const now = Date.now();
451
+ const verdicts = members.map((id) => ({
452
+ id,
453
+ verdict: classifyAccountHealth(
454
+ readJson(join(STATE_DIR, `${id}.json`)),
455
+ {
456
+ staleAfterMs: pool.staleAfterMs,
457
+ utilizationThreshold: pool.utilizationThreshold,
458
+ rotateOnOverage: pool.rotateOnOverage,
459
+ },
460
+ now,
461
+ ).verdict,
462
+ }));
463
+ const decision = decideStickySelection({
464
+ verdicts,
465
+ sticky,
466
+ nowMs: now,
467
+ minDwellMs: pool.minDwellMs,
468
+ });
469
+ const identity = identities.find((i) => i.accountId === decision.account);
470
+ const who =
471
+ identity?.status === "resolved" ? ` (${describeIdentity(identity, { raw: RAW })})` : "";
472
+ const verdict = verdicts.find((v) => v.id === decision.account)?.verdict ?? "no_data";
473
+ const home = members[0];
474
+ if (decision.account === home) {
475
+ ok(`serving on ${decision.account}${who} — home account, health ${verdict}`);
476
+ if (sticky) {
477
+ note(
478
+ `sticky to ${sticky.account} (since ${new Date(sticky.since).toISOString()}) is spent — ` +
479
+ `the next turn returns home`,
480
+ );
481
+ }
482
+ } else {
483
+ // Rotated away from home is normal operation, not a fault: warn so it is
484
+ // visible, never bad, or a healthy rotation would flip READY.
485
+ warn(
486
+ `serving on ${decision.account}${who} — ROTATED off home (${home} is ${
487
+ verdicts[0].verdict
488
+ })${sticky ? `, sticky since ${new Date(sticky.since).toISOString()}` : ""}`,
489
+ );
490
+ }
491
+ // The launch decision is model-aware (a 429 can bench one account for one
492
+ // model only); doctor has no model in hand, so this is the account-wide
493
+ // answer. Say so rather than let a model-scoped exception read as a lie.
494
+ if (VERBOSE) {
495
+ note(`account-wide verdicts: ${verdicts.map((v) => `${v.id}:${v.verdict}`).join(" ")}`);
496
+ }
497
+ }
338
498
  }
339
499
 
340
500
  // ── 7. effective chain (pool-bypass sweep — CASE 1 config + CASE 2 session) ──
@@ -356,18 +516,19 @@ console.log("effective chain");
356
516
  if (!pool) {
357
517
  // No clawd pool ⇒ nothing to bypass; skip the whole section (mirrors §6).
358
518
  } else {
359
- const { auditEffectiveChain, auditSessionOverrides, maskSessionKey } = await import(
360
- join(EXT_DIR, "dist", "chain-audit.js")
361
- ).catch(() => import(join(REPO_DIR, "dist", "chain-audit.js")));
519
+ const { auditEffectiveChain, auditSessionOverrides, maskSessionKey } = await importDist(
520
+ "chain-audit.js",
521
+ ["auditEffectiveChain", "auditSessionOverrides", "maskSessionKey"],
522
+ "cli",
523
+ );
362
524
  const poolId = pool.id ?? "clawd";
363
- const verbose = process.env.DOCTOR_VERBOSE === "1" || process.argv.includes("--verbose");
364
525
  // Session keys embed the operator's private channel id (e.g. a Telegram chat
365
526
  // id). Mask the id tail by default so doctor output is safe to paste into
366
- // issues/support threads; `--raw` restores full keys for local exact-match
367
- // debugging. Only case-2 session surfaces carry a key; config surfaces don't.
368
- const raw = process.env.DOCTOR_RAW === "1" || process.argv.includes("--raw");
527
+ // issues/support threads; `--raw` (hoisted, shared with the login identities
528
+ // in §4/§6) restores full keys for local exact-match debugging. Only case-2
529
+ // session surfaces carry a key; config surfaces don't.
369
530
  const renderSurface = (surface) =>
370
- raw ? surface : surface.replace(/^session (.+)$/, (_m, k) => `session ${maskSessionKey(k)}`);
531
+ RAW ? surface : surface.replace(/^session (.+)$/, (_m, k) => `session ${maskSessionKey(k)}`);
371
532
 
372
533
  // ── case 1: config-level refs ──────────────────────────────────────────────
373
534
  const findings = auditEffectiveChain(config, poolId);
@@ -380,7 +541,7 @@ if (!pool) {
380
541
  // them. A dead-noisy section trains people to skip it — the opposite of the
381
542
  // point. (Live-tier bypasses above are always listed in full.)
382
543
  if (notes.length > 0) {
383
- if (verbose) {
544
+ if (VERBOSE) {
384
545
  for (const f of notes) {
385
546
  note(`${f.surface}: ${f.ref} (allowlist entry, not a live tier) ${f.reason}`);
386
547
  }
@@ -398,9 +559,10 @@ if (!pool) {
398
559
  // where and the how). A store that is expected but absent/unreadable gets a
399
560
  // LOUD skip — never a silent pass; a missed off-pool pin is worse than an
400
561
  // extra line.
401
- const { locateSessionStore, readSessionStore } = await import(
402
- join(EXT_DIR, "dist", "session-store.js")
403
- ).catch(() => import(join(REPO_DIR, "dist", "session-store.js")));
562
+ const { locateSessionStore, readSessionStore } = await importDist("session-store.js", [
563
+ "locateSessionStore",
564
+ "readSessionStore",
565
+ ]);
404
566
  const AGENTS_DIR = join(HOME, ".openclaw", "agents");
405
567
  const stores = [];
406
568
  try {
@@ -436,7 +598,107 @@ if (!pool) {
436
598
  }
437
599
  }
438
600
 
439
- // ── 8. watchdog ─────────────────────────────────────────────────────────────
601
+ // ── 8. chain auth — can each non-Claude rung actually authenticate? ─────────
602
+ //
603
+ // §7 proves the chain is ROUTED correctly. It says nothing about whether the
604
+ // rungs it routes to hold a credential. A fallback whose provider has no
605
+ // eligible auth profile is a rung that does not exist, and the gap only shows
606
+ // up at the moment the tier above it dies — the one moment it was supposed to
607
+ // help. The auth store is read through the documented CLI (`models auth list
608
+ // --json`), never by guessing at its on-disk shape, and cached because that
609
+ // call costs seconds.
610
+ console.log("chain auth");
611
+ {
612
+ const { auditChainAuth } = await importDist("chain-auth.js", ["auditChainAuth"], "cli");
613
+ const { collectChainRefs } = await importDist("chain-audit.js", ["collectChainRefs"], "cli");
614
+ const AUTH_CACHE_TTL_MS = 15 * 60 * 1000;
615
+ const agentIds = Object.keys(config?.agents?.entries ?? {});
616
+ // Which store answers for a surface that names no agent (agents.defaults,
617
+ // cron sections): the default agent. `main` when it exists, else the first
618
+ // configured entry — the same fallback the CLI applies.
619
+ const defaultAgent = agentIds.includes("main") ? "main" : agentIds[0];
620
+
621
+ function ownerOf(surface) {
622
+ const entry = surface.match(/^agents\.entries\.([^.]+)\./)?.[1];
623
+ if (entry) return entry;
624
+ const list = surface.match(/^agents\.list\[([^\]]+)\]/)?.[1];
625
+ if (list && agentIds.includes(list)) return list;
626
+ const named = surface.match(/^agents\.([^.]+)\./)?.[1];
627
+ if (named && agentIds.includes(named)) return named;
628
+ return defaultAgent;
629
+ }
630
+
631
+ function loadProfiles(agentId) {
632
+ const cacheFile = join(STATE_DIR, `auth-profiles-${agentId}.json`);
633
+ const cached = readJson(cacheFile);
634
+ if (cached && Date.now() - (cached.checkedAt ?? 0) < AUTH_CACHE_TTL_MS) {
635
+ return { profiles: cached.profiles ?? [], cached: true };
636
+ }
637
+ try {
638
+ const out = execFileSync(
639
+ "openclaw",
640
+ ["models", "auth", "list", "--agent", agentId, "--json"],
641
+ { encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "ignore"] },
642
+ );
643
+ const profiles = JSON.parse(out)?.profiles ?? [];
644
+ try {
645
+ mkdirSync(STATE_DIR, { recursive: true });
646
+ writeFileSync(
647
+ cacheFile,
648
+ JSON.stringify({ profiles, checkedAt: Date.now() }, null, 2) + "\n",
649
+ );
650
+ } catch {
651
+ /* cache is an optimisation, never a requirement */
652
+ }
653
+ return { profiles, cached: false };
654
+ } catch (err) {
655
+ // A LOUD skip. Silently passing here would turn "doctor could not read
656
+ // the auth store" into "the chain is fine", which is the failure class
657
+ // this whole section exists to remove.
658
+ return { error: String(err).split("\n")[0].slice(0, 160) };
659
+ }
660
+ }
661
+
662
+ const refs = collectChainRefs(config ?? {});
663
+ const byAgent = new Map();
664
+ for (const ref of refs) {
665
+ const agentId = ownerOf(ref.surface);
666
+ if (!agentId) continue;
667
+ const list = byAgent.get(agentId) ?? [];
668
+ list.push(ref);
669
+ byAgent.set(agentId, list);
670
+ }
671
+ if (byAgent.size === 0) {
672
+ note("no chain refs to check");
673
+ }
674
+ for (const [agentId, agentRefs] of [...byAgent.entries()].sort()) {
675
+ const loaded = loadProfiles(agentId);
676
+ if (loaded.error) {
677
+ warn(`chain-auth check SKIPPED for agent ${agentId} — auth store unreadable (${loaded.error})`);
678
+ continue;
679
+ }
680
+ const findings = auditChainAuth({
681
+ refs: agentRefs,
682
+ profiles: loaded.profiles,
683
+ order: config?.auth?.order,
684
+ poolId: pluginConfig.pool?.id ?? "clawd",
685
+ nowMs: Date.now(),
686
+ });
687
+ if (findings.length === 0) {
688
+ ok(`${agentId}: every live non-Claude rung has a usable auth profile`);
689
+ continue;
690
+ }
691
+ for (const f of findings) {
692
+ const where = VERBOSE ? ` [${f.surfaces.join(", ")}]` : ` [${f.surfaces[0]}${f.surfaces.length > 1 ? ` +${f.surfaces.length - 1}` : ""}]`;
693
+ const line = `${agentId}: ${f.provider}${where} — ${f.reason}`;
694
+ if (f.severity === "bad") bad(line);
695
+ else if (f.severity === "warn") warn(line);
696
+ else note(line);
697
+ }
698
+ }
699
+ }
700
+
701
+ // ── 9. watchdog ─────────────────────────────────────────────────────────────
440
702
  console.log("eviction watchdog");
441
703
  let watchdogFound = false;
442
704
  try {
@@ -494,22 +756,63 @@ if (watchdogFound) {
494
756
  } else ok("watchdog scheduled");
495
757
  } else warn("no watchdog found (needed until openclaw#107596 ships — see README)");
496
758
 
497
- // ── 9. optional live probe ──────────────────────────────────────────────────
759
+ // ── 10. optional live probe ─────────────────────────────────────────────────
498
760
  if (args.has("--probe")) {
499
- console.log("live probe (spends one turn)");
500
- const ref = pool ? `${pool.id ?? "clawd"}/${pool.defaultModel ?? "claude-fable-5"}` : accounts[0] ? `${accounts[0].id}/claude-fable-5` : undefined;
501
- if (!ref) bad("nothing to probe");
502
- else {
761
+ // EVERY account, not just the pool ref. The pool ref proves whichever
762
+ // account selection happens to pick right now — so a dead second account
763
+ // stayed invisible until the day rotation needed it, which is the day it
764
+ // could least afford to be wrong. One turn per account plus one for the
765
+ // pool; `--probe-pool` keeps the old single-turn behaviour.
766
+ const model = pool?.defaultModel ?? "claude-fable-5";
767
+ const targets = [];
768
+ if (pool) targets.push({ label: `pool ${pool.id ?? "clawd"}`, ref: `${pool.id ?? "clawd"}/${model}` });
769
+ if (!args.has("--probe-pool")) {
770
+ for (const account of accounts) targets.push({ label: account.id, ref: `${account.id}/${model}` });
771
+ }
772
+ console.log(`live probe (spends ${targets.length} turn${targets.length === 1 ? "" : "s"})`);
773
+ if (targets.length === 0) bad("nothing to probe");
774
+ for (const target of targets) {
503
775
  try {
504
776
  const out = execFileSync(
505
777
  "openclaw",
506
- ["agent", "--agent", "main", "--session-key", "agent:main:mc-doctor-probe", "--model", ref, "--json", "--message", "Reply with exactly this line and nothing else: MC_DOCTOR_OK. Do not use any tools."],
507
- { encoding: "utf8", timeout: 180000 },
778
+ [
779
+ "agent",
780
+ "--agent",
781
+ "main",
782
+ // Per-target session key: one shared key would resume the previous
783
+ // probe's session, and a resumed Claude session can answer from the
784
+ // account that STARTED it — the probe would then prove the wrong
785
+ // account while looking perfectly green.
786
+ "--session-key",
787
+ `agent:main:mc-doctor-probe:${target.ref.replace(/[^a-z0-9]+/gi, "-")}`,
788
+ "--model",
789
+ target.ref,
790
+ "--json",
791
+ "--message",
792
+ "Reply with exactly this line and nothing else: MC_DOCTOR_OK. Do not use any tools.",
793
+ ],
794
+ // stderr captured, not inherited: a refusal is classified below and
795
+ // reported as one line, instead of dumping the CLI's own error block
796
+ // into the middle of the report.
797
+ { encoding: "utf8", timeout: 180000, stdio: ["ignore", "pipe", "pipe"] },
508
798
  );
509
- if (out.includes("MC_DOCTOR_OK")) ok(`${ref} answered end-to-end`);
510
- else bad(`${ref} probe returned unexpected output`);
799
+ if (out.includes("MC_DOCTOR_OK")) ok(`${target.label}: ${target.ref} answered end-to-end`);
800
+ else bad(`${target.label}: ${target.ref} probe returned unexpected output`);
511
801
  } catch (err) {
512
- bad(`${ref} probe failed: ${String(err).slice(0, 200)}`);
802
+ const text = String(err.stderr ?? "") + String(err.stdout ?? "") + String(err);
803
+ // A model-policy refusal says nothing about the ACCOUNT: the gateway
804
+ // declined the ref before any login was used. Reporting it as a probe
805
+ // failure would accuse a perfectly healthy account (pool members are
806
+ // normally reached through `<pool>/*`, so a direct `claw<N>/…` ref is
807
+ // often simply not allowlisted).
808
+ if (/modelPolicy\.allow/.test(text)) {
809
+ warn(
810
+ `${target.label}: not probed — \`${target.ref}\` is blocked by agents.defaults.modelPolicy.allow. ` +
811
+ `Add \`${target.ref.split("/")[0]}/*\` to probe this account directly; its pool routing is unaffected.`,
812
+ );
813
+ } else {
814
+ bad(`${target.label}: ${target.ref} probe failed: ${String(err).slice(0, 200)}`);
815
+ }
513
816
  }
514
817
  }
515
818
  }