@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 +5 -3
- package/dist/account-identity.js +131 -0
- package/dist/chain-audit.js +31 -2
- package/dist/chain-auth.js +125 -0
- package/dist/models.js +2 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.mjs +338 -35
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),
|
|
252
|
-
|
|
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.
|
|
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
|
+
}
|
package/dist/chain-audit.js
CHANGED
|
@@ -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:
|
|
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
|
};
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.8.
|
|
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.
|
|
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",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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`
|
|
367
|
-
//
|
|
368
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
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.
|
|
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
|
-
// ──
|
|
759
|
+
// ── 10. optional live probe ─────────────────────────────────────────────────
|
|
498
760
|
if (args.has("--probe")) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
[
|
|
507
|
-
|
|
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
|
-
|
|
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
|
}
|