@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 +13 -1
- package/dist/credential-store.js +27 -1
- package/dist/index.js +47 -12
- package/dist/runtime-auth.js +124 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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.
|
|
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
|
package/dist/credential-store.js
CHANGED
|
@@ -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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.1.
|
|
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.
|
|
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",
|