@drakon-systems/multi-clawd 1.9.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,8 @@
6
6
  * 1. plugin install + manifest/config key agreement (the --force trap)
7
7
  * 2. compiled-artifact freshness (stale dist detection)
8
8
  * 3. claude CLI availability + PATH sanity
9
- * 4. per-account credential-source health + which Claude login each
9
+ * 4. per-account credential health (judged on expiry and proof, never on
10
+ * presence), credential split-store detection, and which Claude login each
10
11
  * account actually authenticates as (and whether two share one)
11
12
  * 5. per-account rate-limit telemetry (state files, age, windows)
12
13
  * 6. pool configuration, sticky state, and the account the next turn runs on
@@ -280,55 +281,73 @@ try {
280
281
 
281
282
  // ── 4. account credentials (values never printed) ──────────────────────────
282
283
  console.log("account credentials");
283
- const { checkAccountCredential, keychainServiceForConfigDir } = await importDist("login-health.js", [
284
- "checkAccountCredential",
285
- "keychainServiceForConfigDir",
286
- ]);
287
- const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount } = await importDist("health.js", [
288
- "summarizeWindowUsage",
289
- "classifyAccountHealth",
290
- "overdueProbeAccount",
291
- ]);
284
+ // "cli": judging a credential source is doctor's own diagnostic (see the
285
+ // identity import below). The store module carries the system I/O; both must
286
+ // come from the same build so the io matches the judge.
287
+ const { assessAccountCredential } = await importDist(
288
+ "login-health.js",
289
+ ["assessAccountCredential"],
290
+ "cli",
291
+ );
292
+ const { createSystemCredentialIo, findCredentialCopies, describeSplitStore } = await importDist(
293
+ "credential-store.js",
294
+ ["createSystemCredentialIo", "findCredentialCopies", "describeSplitStore"],
295
+ "cli",
296
+ );
297
+ const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount, lastAuthSuccessAt } =
298
+ await importDist("health.js", [
299
+ "summarizeWindowUsage",
300
+ "classifyAccountHealth",
301
+ "overdueProbeAccount",
302
+ "lastAuthSuccessAt",
303
+ ]);
292
304
  // "cli", like the chain audits: resolving WHICH login backs an account is
293
305
  // doctor's own diagnostic, not a description of the running plugin's
294
306
  // behaviour. Asking the installed copy meant a fix to the resolver could not
295
307
  // take effect until the plugin itself was reinstalled — so the CLI reported a
296
308
  // bug it had already been taught not to make.
297
- const { resolveAccountIdentity, describeIdentity, findDuplicateLogins, maskEmail } =
298
- await importDist(
299
- "account-identity.js",
300
- ["resolveAccountIdentity", "describeIdentity", "findDuplicateLogins", "maskEmail"],
301
- "cli",
302
- );
309
+ const {
310
+ resolveAccountIdentity,
311
+ describeIdentity,
312
+ findDuplicateLogins,
313
+ maskEmail,
314
+ nativeDefaultConfigDir,
315
+ } = await importDist(
316
+ "account-identity.js",
317
+ ["resolveAccountIdentity", "describeIdentity", "findDuplicateLogins", "maskEmail", "nativeDefaultConfigDir"],
318
+ "cli",
319
+ );
303
320
  const { decideStickySelection } = await importDist("sticky.js", ["decideStickySelection"]);
304
- const io = {
305
- readFile: (p) => readFileSync(expandHome(p), "utf8"),
306
- keychainHasClaudeCredentials: () => keychainHasService("Claude Code-credentials"),
307
- keychainHasClaudeCredentialsForDir: (dir) =>
308
- keychainHasService(keychainServiceForConfigDir(expandHome(dir))),
309
- platform: process.platform,
310
- };
311
- // Metadata-only probe (no `-w`): the secret is never read.
312
- function keychainHasService(service) {
313
- try {
314
- execFileSync("security", ["find-generic-password", "-s", service], { stdio: "ignore" });
315
- return true;
316
- } catch {
317
- return false;
318
- }
321
+ // Keychain reads name the account the Claude CLI uses (`-a`), never just the
322
+ // service. Token values are reduced to expiry metadata where they are read and
323
+ // are never printed.
324
+ const io = createSystemCredentialIo();
325
+ // A native account's child runs with CLAUDE_CONFIG_DIR CLEARED (the pool strips
326
+ // it before every launch), so its login lives under the real home whatever
327
+ // this shell says. Doctor run inside an agent session served by the pool
328
+ // inherits the serving account's dir; honouring it resolved "native" from that
329
+ // account and reported two distinct logins as one.
330
+ const nativeDir = nativeDefaultConfigDir(process.env, HOME);
331
+ if (nativeDir.ignored) {
332
+ note(
333
+ `CLAUDE_CONFIG_DIR=${nativeDir.ignored} is set in this shell — ignored: a native account runs ` +
334
+ `with it cleared, so native is resolved from ${nativeDir.dir}`,
335
+ );
319
336
  }
320
- // A native account's child sets no CLAUDE_CONFIG_DIR, so it authenticates
321
- // against whatever the default dir is in the ENV THE GATEWAY RUNS IN. Doctor
322
- // reads the same variable and prints the path it used, so a box that exports
323
- // CLAUDE_CONFIG_DIR globally shows its real source rather than a guess.
324
337
  const identityIo = {
325
338
  readFile: (p) => readFileSync(expandHome(p), "utf8"),
326
339
  expandHome,
327
- defaultConfigDir: process.env.CLAUDE_CONFIG_DIR
328
- ? expandHome(process.env.CLAUDE_CONFIG_DIR)
329
- : join(HOME, ".claude"),
340
+ defaultConfigDir: nativeDir.dir,
330
341
  homeDir: HOME,
331
342
  };
343
+ /**
344
+ * Last time the CLI's login for this account was accepted. The shim's file
345
+ * only: the usage poll authenticates with the credentials FILE, which on a
346
+ * split store is not the copy the CLI reads, so it proves nothing here.
347
+ */
348
+ function authProofAt(accountId) {
349
+ return lastAuthSuccessAt(readJson(join(STATE_DIR, `${accountId}.json`)));
350
+ }
332
351
  const accounts = pluginConfig.accounts ?? [];
333
352
  const identities = [];
334
353
  if (accounts.length === 0) warn("no accounts configured");
@@ -361,24 +380,51 @@ for (const account of accounts) {
361
380
  // credential EXISTS, and #8 is exactly the case where a present credential
362
381
  // is a session the Claude CLI has already rejected. A recorded runtime
363
382
  // failure is the stronger evidence, so it is reported as such.
383
+ // The record stands until a successful turn (or a re-login) clears it, so
384
+ // its age changes the wording, never the verdict.
364
385
  const recorded = readJson(join(STATE_DIR, `${account.id}.json`))?.credential;
365
386
  if (recorded?.status === "failed") {
366
- const ageMin = Math.round((Date.now() - recorded.seenAt) / 60000);
387
+ const ageMin = Math.max(0, Math.round((Date.now() - recorded.seenAt) / 60000));
388
+ const age = ageMin < 90 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`;
367
389
  bad(
368
- `${account.id}: the Claude CLI rejected this login ${ageMin}m ago${
390
+ `${account.id}: the Claude CLI rejected this login ${age} ago${
369
391
  recorded.reason ? ` (${recorded.reason})` : ""
370
- } — excluded from the pool; fix with \`multi-clawd login ${account.id}\``,
392
+ } and no successful turn has been seen since — ` +
393
+ `fix with \`multi-clawd login ${account.id}\`, or prove an out-of-band fix with ` +
394
+ `\`multi-clawd doctor --probe\``,
371
395
  );
372
- continue;
373
- }
374
- if (account.oauthTokenRef) {
396
+ } else if (account.oauthTokenRef) {
375
397
  warn(`${account.id}: oauthTokenRef — validated by the gateway's async probe, not doctor`);
376
- continue;
398
+ } else {
399
+ const check = assessAccountCredential(account, io, { proof: { lastSuccessAt: authProofAt(account.id) } });
400
+ if (check.status === "ok") ok(`${account.id}: credential verified — ${check.detail ?? "ok"}`);
401
+ else if (check.status === "unverified") {
402
+ // Not a failure (an idle account's access token expires routinely), and
403
+ // never "alive": a dead refresh token looks exactly like this at rest.
404
+ warn(
405
+ `${account.id}: credential UNVERIFIED — ${check.reason}. The next turn on it refreshes or ` +
406
+ `fails; prove it now with \`multi-clawd doctor --probe\``,
407
+ );
408
+ } else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
409
+ else bad(`${account.id}: ${check.reason}`);
410
+ }
411
+ // Credential split-store: more than one copy of this login's OAuth
412
+ // credential. Refresh tokens rotate, so once the copies diverge only the one
413
+ // refreshed last can still refresh — and the copy a tool happens to read
414
+ // decides whether it sees a live login or a dead one. Warn-level: the CLI's
415
+ // own copy may be fine today; this is the early warning.
416
+ const split = findCredentialCopies(account, io);
417
+ if (split.status === "split") {
418
+ const text = describeSplitStore(split, Date.now());
419
+ warn(`credential split-store — ${text.headline}`);
420
+ for (const line of text.copies) console.log(` · ${line}`);
421
+ console.log(` remedy: ${text.remedy}`);
422
+ } else if (split.status !== "n/a" && VERBOSE) {
423
+ note(`${account.id}: credential split-store — ${split.status === "single" ? "one copy" : "no stored copy"}`);
424
+ }
425
+ if (split.incomplete) {
426
+ note(`${account.id}: credential split-store check incomplete — the Keychain could not be listed, so same-service duplicates may be missed`);
377
427
  }
378
- const check = checkAccountCredential(account, io);
379
- if (check.status === "ok") ok(`${account.id}: credential source looks alive`);
380
- else if (check.status === "unknown") warn(`${account.id}: cannot verify (${check.reason ?? "no source"})`);
381
- else bad(`${account.id}: ${check.reason}`);
382
428
  }
383
429
 
384
430
  // Two ids, one login. This is the failure the rest of doctor cannot see: every
@@ -449,6 +495,27 @@ else {
449
495
  const members = (pool.accounts ?? []).filter((id) => accounts.some((a) => a.id === id));
450
496
  if (members.length < 2) bad(`pool "${pool.id ?? "clawd"}" has ${members.length} valid member(s); needs ≥ 2`);
451
497
  else ok(`pool "${pool.id ?? "clawd"}": ${members.join(" → ")}`);
498
+ // Setup-token members: what they do and do not get, said once, plainly.
499
+ const tokenMembers = members
500
+ .map((id) => accounts.find((a) => a.id === id))
501
+ .filter((a) => a && !a.native && (a.oauthTokenFile || a.oauthTokenRef));
502
+ if (tokenMembers.length > 0 && pool.usagePoll?.enabled !== false) {
503
+ note(
504
+ `usage poll: ${tokenMembers.map((a) => a.id).join(", ")} not polled — a setup-token carries only the ` +
505
+ `user:inference scope and the usage endpoint needs user:profile (upstream claude-code #11985). ` +
506
+ `Rotation for ${tokenMembers.length === 1 ? "it" : "them"} runs on stream telemetry: each turn's ` +
507
+ `rate-limit events still trigger the threshold and 5-hour-warning rules, but an idle account is ` +
508
+ `not re-measured between turns and there is no 95% usage alert.`,
509
+ );
510
+ }
511
+ const refOnly = tokenMembers.filter((a) => !a.oauthTokenFile);
512
+ if (refOnly.length > 0) {
513
+ note(
514
+ `in-turn retry: ${refOnly.map((a) => a.id).join(", ")} (oauthTokenRef) cannot be a mid-turn retry ` +
515
+ `target — the shim never receives secret values, and a reference has no file it could read. ` +
516
+ `${refOnly.length === 1 ? "It rotates" : "They rotate"} at the next launch instead.`,
517
+ );
518
+ }
452
519
  const sticky = readJson(join(STATE_DIR, `pool-${pool.id ?? "clawd"}.sticky.json`));
453
520
 
454
521
  // WHICH ACCOUNT IS ACTUALLY BEING USED. Everything above describes the pool