@drakon-systems/multi-clawd 2.1.0 → 2.1.1

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
@@ -385,7 +385,7 @@ openclaw plugins install (Get-Location).Path
385
385
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
386
386
  Code on the target machine already? Paste it this and go make coffee:
387
387
 
388
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.1.0/SETUP-AGENT.md
388
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v2.1.1/SETUP-AGENT.md
389
389
  > and follow it to set up multi-clawd on this machine. I own a second
390
390
  > Claude account — ask me when you need me to log in.
391
391
 
@@ -817,6 +817,18 @@ event, and no plugin API can force a rebuild. See `DESIGN.md`.
817
817
 
818
818
  ## Known limitations
819
819
 
820
+ - **Control UI shows the pool as unavailable on OpenClaw 2026.10 (multi-clawd
821
+ ≤ 2.1.0).** OpenClaw 2026.10 computes model availability per provider, and
822
+ a plugin provider backed by a Claude *login* (a `native` or `configDir`
823
+ account) had no credential to show it — so **Settings → Models** greyed out
824
+ every `clawd/*` and `claw<N>/*` row with "missing auth" and showed a
825
+ different model than the configured primary, even though agent turns worked.
826
+ `openclaw gateway call models.list --json` shows the same rows as
827
+ `available: false, unavailableReason: "missing-auth"`. Fixed in **2.1.1**:
828
+ a usable login now reports as available, a dead one still reports
829
+ "missing-auth", and the direct API transport keeps refusing CLI-login
830
+ accounts as before. Update with `multi-clawd update`.
831
+
820
832
  - **Shim window persistence is sequential-safe, not concurrent-writer-safe.**
821
833
  `persistState()` in `src/shim.ts` does a read-merge-write on every save so a
822
834
  turn that only reports one window type (say `five_hour`) doesn't clobber
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { homedir, userInfo } from "node:os";
4
4
  import { join, resolve } from "node:path";
@@ -183,6 +183,32 @@ export function expandHomeDir(p) {
183
183
  }
184
184
  const SECURITY_TIMEOUT_MS = 10_000;
185
185
  const SECURITY_ITEM_NOT_FOUND = 44;
186
+ function defaultExecFileAsync(file, args, options) {
187
+ return new Promise((resolvePromise, reject) => {
188
+ execFile(file, args, options, (err, stdout) => {
189
+ if (err)
190
+ reject(err);
191
+ else
192
+ resolvePromise(String(stdout));
193
+ });
194
+ });
195
+ }
196
+ export async function readKeychainItemAsync(service, account, deps = {}) {
197
+ const exec = deps.exec ?? defaultExecFileAsync;
198
+ try {
199
+ const data = await exec("security", ["find-generic-password", "-s", service, "-a", account, "-w"], {
200
+ encoding: "utf8",
201
+ stdio: ["ignore", "pipe", "ignore"],
202
+ timeout: SECURITY_TIMEOUT_MS,
203
+ });
204
+ return { status: "found", data: data.replace(/\n$/, "") };
205
+ }
206
+ catch (err) {
207
+ const status = err?.status ??
208
+ err?.code;
209
+ return status === SECURITY_ITEM_NOT_FOUND ? { status: "absent" } : { status: "unreadable" };
210
+ }
211
+ }
186
212
  export function createSystemCredentialIo(deps = {}) {
187
213
  const exec = deps.exec ?? ((file, args, options) => execFileSync(file, args, options));
188
214
  return {
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
7
7
  import { decideDegradation, matchesPin } from "./degrade.js";
8
8
  import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
9
9
  import { resolveBaseModelIds } from "./catalog-source.js";
10
- import { allCredentialFailed, classifyAccountHealth, lastAuthSuccessAt, overdueProbeAccount, } from "./health.js";
10
+ import { allCredentialFailed, classifyAccountHealth, credentialFailureFor, lastAuthSuccessAt, overdueProbeAccount, } from "./health.js";
11
11
  import { decideStickySelection } from "./sticky.js";
12
12
  import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
13
13
  import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
@@ -22,7 +22,8 @@ export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
22
22
  import { accountConfigDir, AccountCredentialError, buildAccountChildEnv, credentialFailureText, parseSetupTokenFile, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
23
23
  import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
24
24
  import { assessAccountCredential, createRefProbeTracker, } from "./login-health.js";
25
- import { createSystemCredentialIo } from "./credential-store.js";
25
+ import { createSystemCredentialIo, readKeychainItemAsync } from "./credential-store.js";
26
+ import { createLoginAuthCache, loginAuthEligible, poolRuntimeAuth, refuseRuntimeAuthMarker, } from "./runtime-auth.js";
26
27
  import { collectDirectMembers } from "./direct-route.js";
27
28
  import { createDirectOrderController, isValidAgentId } from "./direct-sync.js";
28
29
  import { createOpenclawRunner } from "./openclaw-runner.js";
@@ -159,6 +160,29 @@ function ingestAlertSpool() {
159
160
  const LOGIN_PROBE_INTERVAL_MS = 15 * 60 * 1000;
160
161
  const LOGIN_PROBE_INITIAL_DELAY_MS = 45 * 1000;
161
162
  let realCredentialIo;
163
+ let loginAuthCache;
164
+ let loginAuthWarm = Promise.resolve();
165
+ function loginAuth() {
166
+ return (loginAuthCache ??= createLoginAuthCache({
167
+ io: (realCredentialIo ??= createSystemCredentialIo()),
168
+ readKeychainItemAsync: (service, account) => readKeychainItemAsync(service, account),
169
+ health: (accountId) => {
170
+ const state = readHealthState(accountId);
171
+ return {
172
+ lastSuccessAt: lastAuthSuccessAt(state),
173
+ failed: credentialFailureFor(state, Date.now()) !== undefined,
174
+ };
175
+ },
176
+ }));
177
+ }
178
+ export function warmLoginAuth(accounts) {
179
+ const cache = loginAuth();
180
+ loginAuthWarm = Promise.all(accounts.filter(loginAuthEligible).map((account) => cache.refresh(account))).then(() => undefined);
181
+ return loginAuthWarm;
182
+ }
183
+ export function loginAuthWarmed() {
184
+ return loginAuthWarm;
185
+ }
162
186
  const refProbeTrackers = new Map();
163
187
  const lastProbeStatus = new Map();
164
188
  function recordAccountCredentialFailure(accountId, reason, nowMs) {
@@ -208,6 +232,8 @@ export async function runLoginHealthProbe(accounts, logger, deps = {}) {
208
232
  proof: { lastSuccessAt: lastAuthSuccessAt(readHealth(account.id)) },
209
233
  nowMs: now,
210
234
  });
235
+ if (!deps.io)
236
+ loginAuth().record(account.id, check, now);
211
237
  status = check.status;
212
238
  reason = check.status === "ok" ? check.detail : check.reason;
213
239
  }
@@ -320,6 +346,14 @@ function peekToken(account) {
320
346
  return undefined;
321
347
  throw new Error(`[multi-clawd] account "${account.id}" needs oauthTokenFile, oauthTokenRef, or configDir`);
322
348
  }
349
+ function resolveAccountRuntimeAuth(account) {
350
+ const token = peekToken(account);
351
+ if (token)
352
+ return { apiKey: token, source: `multi-clawd ${account.id} token`, mode: "token" };
353
+ if (account.oauthTokenFile || isSecretRefShape(account.oauthTokenRef))
354
+ return undefined;
355
+ return loginAuth().peek(account, account.id);
356
+ }
323
357
  async function resolveTokenAsync(account, resolver) {
324
358
  if (isSecretRefShape(account.oauthTokenRef) && !account.native && !account.oauthTokenFile) {
325
359
  return (resolver ?? activeTokenResolver)?.resolve(account.oauthTokenRef);
@@ -561,26 +595,25 @@ export function isRuntimelessRegistration(api) {
561
595
  const mode = api?.registrationMode;
562
596
  return mode === "cli-metadata" || mode === "setup-only";
563
597
  }
564
- function buildCatalogProvider(account) {
598
+ function buildCatalogProvider(account, members) {
565
599
  return {
566
600
  id: account.id,
567
601
  label: account.label ?? `Claude Code (${account.id})`,
568
602
  auth: [],
569
603
  resolveSyntheticAuth: () => {
570
604
  try {
571
- const token = peekToken(account);
572
- if (!token)
573
- return undefined;
574
- return {
575
- apiKey: token,
576
- source: `multi-clawd ${account.id} token`,
577
- mode: "token",
578
- };
605
+ return members
606
+ ? poolRuntimeAuth(account.id, members.map((member) => () => resolveAccountRuntimeAuth(member)))
607
+ : resolveAccountRuntimeAuth(account);
579
608
  }
580
609
  catch {
581
610
  return undefined;
582
611
  }
583
612
  },
613
+ prepareRuntimeAuth: async (ctx) => {
614
+ refuseRuntimeAuthMarker(ctx);
615
+ return undefined;
616
+ },
584
617
  augmentModelCatalog: async () => buildCatalogEntries(account, await resolveBaseModelIds()),
585
618
  resolveDynamicModel: (ctx) => buildRuntimeModel(account, ctx.modelId),
586
619
  };
@@ -647,6 +680,7 @@ export default definePluginEntry({
647
680
  `failover is unchanged, but OpenClaw cannot verify this backend as a setup owner`);
648
681
  }
649
682
  const seen = new Set();
683
+ loginAuthCache = undefined;
650
684
  for (const account of accounts) {
651
685
  const id = account?.id?.trim();
652
686
  if (!id) {
@@ -666,6 +700,7 @@ export default definePluginEntry({
666
700
  api.registerProvider(buildCatalogProvider(normalized));
667
701
  }
668
702
  registerPoolBackend(api, cfg.pool, accounts, seen, execMode, { launch });
703
+ void warmLoginAuth(accounts.filter((a) => seen.has(a.id.trim()))).catch(() => { });
669
704
  {
670
705
  const logger = api.logger;
671
706
  try {
@@ -955,7 +990,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
955
990
  return preparedForLaunch(launch, ctx, env, transport);
956
991
  };
957
992
  api.registerCliBackend(backend);
958
- api.registerProvider(buildCatalogProvider(poolAccount));
993
+ api.registerProvider(buildCatalogProvider(poolAccount, members));
959
994
  registeredIds.add(poolId);
960
995
  logger.info(`[multi-clawd] pool "${poolId}" active — accounts: ${memberIds.join(" → ")}, threshold: ${options.utilizationThreshold ?? 0.85}`);
961
996
  void (async () => {
@@ -0,0 +1,124 @@
1
+ import { ACCESS_TOKEN_EXPIRY_SKEW_MS, CLAUDE_KEYCHAIN_SERVICE, assessAccountCredential, keychainServiceForConfigDir, } from "./login-health.js";
2
+ export const RUNTIME_AUTH_MARKER_PREFIX = "oauth:";
3
+ export const LOGIN_AUTH_CACHE_TTL_MS = 30 * 1000;
4
+ export function runtimeAuthMarker(providerId) {
5
+ return `${RUNTIME_AUTH_MARKER_PREFIX}${providerId.trim()}`;
6
+ }
7
+ export function isRuntimeAuthMarker(value) {
8
+ if (typeof value !== "string")
9
+ return false;
10
+ const trimmed = value.trim();
11
+ return trimmed.startsWith(RUNTIME_AUTH_MARKER_PREFIX) && trimmed.length > RUNTIME_AUTH_MARKER_PREFIX.length;
12
+ }
13
+ export function loginRuntimeAuth(check, ctx) {
14
+ if (check.status !== "ok")
15
+ return undefined;
16
+ const expiresAt = check.expiresAt !== undefined && check.expiresAt > ctx.nowMs ? check.expiresAt : undefined;
17
+ return {
18
+ apiKey: runtimeAuthMarker(ctx.providerId),
19
+ source: `claude login (${ctx.accountId})`,
20
+ mode: "oauth",
21
+ ...(expiresAt === undefined ? {} : { expiresAt }),
22
+ };
23
+ }
24
+ export function poolRuntimeAuth(poolId, members) {
25
+ for (const member of members) {
26
+ let result;
27
+ try {
28
+ result = member();
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ if (!result)
34
+ continue;
35
+ return {
36
+ apiKey: runtimeAuthMarker(poolId),
37
+ source: result.source,
38
+ mode: "oauth",
39
+ ...(result.expiresAt === undefined ? {} : { expiresAt: result.expiresAt }),
40
+ };
41
+ }
42
+ return undefined;
43
+ }
44
+ export function loginAuthEligible(account) {
45
+ return !account.oauthTokenFile && !account.oauthTokenRef && Boolean(account.native || account.configDir);
46
+ }
47
+ export function createLoginAuthCache(options) {
48
+ const ttlMs = options.ttlMs ?? LOGIN_AUTH_CACHE_TTL_MS;
49
+ const cache = new Map();
50
+ const inflight = new Map();
51
+ const health = (accountId) => {
52
+ try {
53
+ return options.health?.(accountId) ?? {};
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ };
59
+ const eligible = loginAuthEligible;
60
+ const store = (accountId, check, nowMs) => {
61
+ const tokenBound = check.status === "ok" && check.expiresAt !== undefined
62
+ ? check.expiresAt - ACCESS_TOKEN_EXPIRY_SKEW_MS
63
+ : undefined;
64
+ const hardUntil = tokenBound !== undefined && tokenBound > nowMs ? tokenBound : nowMs + ttlMs;
65
+ cache.set(accountId, { check, freshUntil: nowMs + ttlMs, hardUntil });
66
+ };
67
+ const judge = async (account, nowMs) => {
68
+ let io = options.io;
69
+ if (io.platform === "darwin" && options.readKeychainItemAsync) {
70
+ const dir = account.native ? "~/.claude" : account.configDir;
71
+ const service = account.native
72
+ ? CLAUDE_KEYCHAIN_SERVICE
73
+ : keychainServiceForConfigDir(io.expandHome(dir ?? ""));
74
+ const read = await options.readKeychainItemAsync(service, io.keychainAccount);
75
+ io = { ...io, readKeychainItem: () => read };
76
+ }
77
+ const check = assessAccountCredential(account, io, {
78
+ proof: { lastSuccessAt: health(account.id).lastSuccessAt },
79
+ nowMs,
80
+ });
81
+ store(account.id, check, nowMs);
82
+ };
83
+ const refresh = (account, nowMs = Date.now()) => {
84
+ if (!eligible(account))
85
+ return Promise.resolve();
86
+ const running = inflight.get(account.id);
87
+ if (running)
88
+ return running;
89
+ const task = judge(account, nowMs)
90
+ .catch(() => {
91
+ store(account.id, { status: "unknown", reason: "credential source could not be judged" }, nowMs);
92
+ })
93
+ .finally(() => {
94
+ inflight.delete(account.id);
95
+ });
96
+ inflight.set(account.id, task);
97
+ return task;
98
+ };
99
+ return {
100
+ peek(account, providerId, nowMs = Date.now()) {
101
+ if (!eligible(account))
102
+ return undefined;
103
+ if (health(account.id).failed)
104
+ return undefined;
105
+ const hit = cache.get(account.id);
106
+ if (!hit || hit.freshUntil <= nowMs)
107
+ void refresh(account, nowMs);
108
+ if (!hit || hit.hardUntil <= nowMs)
109
+ return undefined;
110
+ return loginRuntimeAuth(hit.check, { providerId, accountId: account.id, nowMs });
111
+ },
112
+ refresh,
113
+ record(accountId, check, nowMs = Date.now()) {
114
+ store(accountId, check, nowMs);
115
+ },
116
+ };
117
+ }
118
+ export function refuseRuntimeAuthMarker(ctx) {
119
+ if (!isRuntimeAuthMarker(ctx.apiKey))
120
+ return undefined;
121
+ throw new Error(`[multi-clawd] ${ctx.provider}/${ctx.modelId} runs through the Claude Code CLI backend; ` +
122
+ `this account is a Claude login (no setup token), so the direct API transport is not available for it. ` +
123
+ `Run the model as an agent turn, or give the account an oauthTokenFile/oauthTokenRef to use it directly.`);
124
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.1.0",
4
+ "version": "2.1.1",
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": "2.1.0",
3
+ "version": "2.1.1",
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",