@drakon-systems/multi-clawd 2.0.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.0.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
 
@@ -397,7 +397,7 @@ therefore executes — is fixed at a version you chose, not whatever `master`
397
397
  happens to say today. Swap the tag if you want a different release, but
398
398
  prefer a tag over a branch.
399
399
 
400
- **Requirements:** OpenClaw ≥ 2026.6, the `claude` CLI on `PATH`, and a
400
+ **Requirements:** OpenClaw ≥ 2026.8.1, the `claude` CLI on `PATH`, and a
401
401
  second Claude subscription you own. The current plugin SDK contract and runtime
402
402
  registration path are tested against OpenClaw **2026.8.1**. The standalone
403
403
  `multi-clawd` CLI and Hermes commands do not require the optional OpenClaw peer
@@ -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
@@ -868,6 +880,28 @@ Housekeeping:
868
880
  showing only a trailing-newline mismatch is a false alarm.
869
881
  - Use only accounts you own, within your provider's terms of service.
870
882
 
883
+ ### Security notes: verified launch (OpenClaw ≥ 2026.8.1)
884
+
885
+ - **Same verification bar as the built-in backend, no stronger.** Each
886
+ backend's command is the `claude` executable found on the gateway's
887
+ `PATH`. It declares exactly the runtime artifact OpenClaw's built-in
888
+ `claude-cli` backend declares (`@anthropic-ai/claude-code`, native
889
+ executable names `claude` / `claude.exe`). For a native install, OpenClaw
890
+ verifies that executable by its basename, the same check it applies to its
891
+ own `claude-cli`. multi-clawd adds no package or binary of its own to that
892
+ authority and never lists `node` as a native executable.
893
+ - **The shim runs only the verified binary.** Agent turns go through the
894
+ shim, which is given the realpath checked when the turn starts and runs
895
+ that file under the registered command name. A different command, a
896
+ different `argv0`, or an interpreter prefix is refused rather than
897
+ wrapped.
898
+ - **`MULTI_CLAWD_CLAUDE_BIN` is operator-trusted.** When it is set in the
899
+ gateway's environment, the plugin uses it in place of the `PATH` lookup.
900
+ Whoever can set the gateway's environment already controls which binary
901
+ runs, so the variable widens nothing. Only point it at an executable you
902
+ trust. If it is set but not executable, the launch fails; nothing falls
903
+ back to another `claude`.
904
+
871
905
  ## Status & roadmap
872
906
 
873
907
  Early but real — built for and dogfooded in production.
@@ -0,0 +1,69 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
3
+ export const CLAUDE_BIN_ENV = "MULTI_CLAWD_CLAUDE_BIN";
4
+ export const CLAUDE_ARGV0_ENV = "MULTI_CLAWD_CLAUDE_ARGV0";
5
+ export function isExecutableFile(path) {
6
+ try {
7
+ if (!statSync(path).isFile())
8
+ return false;
9
+ accessSync(path, constants.X_OK);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ export function findOnPath(name, pathValue) {
17
+ for (const dir of (pathValue ?? "").split(delimiter)) {
18
+ if (!dir || !isAbsolute(dir))
19
+ continue;
20
+ const candidate = resolve(join(dir, name));
21
+ if (isExecutableFile(candidate))
22
+ return candidate;
23
+ }
24
+ return undefined;
25
+ }
26
+ export function parseClaudeBinOverride(raw) {
27
+ if (!raw)
28
+ return undefined;
29
+ try {
30
+ const parsed = JSON.parse(raw);
31
+ if (Array.isArray(parsed) &&
32
+ parsed.length > 0 &&
33
+ parsed.every((p) => typeof p === "string" && p.length > 0)) {
34
+ const [command, ...prependArgs] = parsed;
35
+ return { kind: "argv", command, prependArgs };
36
+ }
37
+ }
38
+ catch {
39
+ }
40
+ return { kind: "path", value: raw };
41
+ }
42
+ export function resolveExecutable(command, pathValue) {
43
+ if (!command.includes("/") && !command.includes("\\"))
44
+ return findOnPath(command, pathValue);
45
+ if (!isAbsolute(command))
46
+ return undefined;
47
+ const absolute = resolve(command);
48
+ return isExecutableFile(absolute) ? absolute : undefined;
49
+ }
50
+ export function resolveClaudeExecutable(env, platform = process.platform) {
51
+ if (platform === "win32") {
52
+ return { ok: false, reason: "verified launch is not implemented on Windows" };
53
+ }
54
+ const override = parseClaudeBinOverride(env[CLAUDE_BIN_ENV]);
55
+ if (override) {
56
+ if (override.kind === "argv" && override.prependArgs.length > 0) {
57
+ return { ok: false, reason: `${CLAUDE_BIN_ENV} is an interpreter argv, not one executable` };
58
+ }
59
+ const raw = override.kind === "argv" ? override.command : override.value;
60
+ const command = resolveExecutable(raw, env.PATH);
61
+ return command
62
+ ? { ok: true, command, source: "override" }
63
+ : { ok: false, reason: `${CLAUDE_BIN_ENV}=${raw} is not an executable file` };
64
+ }
65
+ const command = findOnPath("claude", env.PATH);
66
+ return command
67
+ ? { ok: true, command, source: "path" }
68
+ : { ok: false, reason: "no executable `claude` on the gateway's PATH" };
69
+ }
@@ -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,11 +22,14 @@ 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";
29
30
  import { materialiseRuntime } from "./runtime-home.js";
31
+ import { CLAUDE_ARGV0_ENV, CLAUDE_BIN_ENV, resolveClaudeExecutable } from "./claude-command.js";
32
+ import { createShimExecute } from "./shim-transport.js";
30
33
  const CLI_FRESH_WATCHDOG_DEFAULTS = {
31
34
  noOutputTimeoutRatio: 0.8,
32
35
  minMs: 180_000,
@@ -157,6 +160,29 @@ function ingestAlertSpool() {
157
160
  const LOGIN_PROBE_INTERVAL_MS = 15 * 60 * 1000;
158
161
  const LOGIN_PROBE_INITIAL_DELAY_MS = 45 * 1000;
159
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
+ }
160
186
  const refProbeTrackers = new Map();
161
187
  const lastProbeStatus = new Map();
162
188
  function recordAccountCredentialFailure(accountId, reason, nowMs) {
@@ -206,6 +232,8 @@ export async function runLoginHealthProbe(accounts, logger, deps = {}) {
206
232
  proof: { lastSuccessAt: lastAuthSuccessAt(readHealth(account.id)) },
207
233
  nowMs: now,
208
234
  });
235
+ if (!deps.io)
236
+ loginAuth().record(account.id, check, now);
209
237
  status = check.status;
210
238
  reason = check.status === "ok" ? check.detail : check.reason;
211
239
  }
@@ -318,6 +346,14 @@ function peekToken(account) {
318
346
  return undefined;
319
347
  throw new Error(`[multi-clawd] account "${account.id}" needs oauthTokenFile, oauthTokenRef, or configDir`);
320
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
+ }
321
357
  async function resolveTokenAsync(account, resolver) {
322
358
  if (isSecretRefShape(account.oauthTokenRef) && !account.native && !account.oauthTokenFile) {
323
359
  return (resolver ?? activeTokenResolver)?.resolve(account.oauthTokenRef);
@@ -359,6 +395,63 @@ if (!SHIM_RUNTIME.stable) {
359
395
  `using the load path, which dies if OpenClaw prunes its plugin capture (issue #31)`);
360
396
  }
361
397
  const SHIM_PATH = SHIM_RUNTIME.shimPath;
398
+ export const CLAUDE_RUNTIME_ARTIFACT = Object.freeze({
399
+ kind: "bundled-package-tree",
400
+ packageName: "@anthropic-ai/claude-code",
401
+ entrypoint: "command",
402
+ nativeExecutableNames: Object.freeze(["claude", "claude.exe"]),
403
+ });
404
+ export function hostRunsExecuteTransports(runtime) {
405
+ try {
406
+ const rt = runtime;
407
+ return (typeof rt?.agent?.runCommandFromIngress === "function" &&
408
+ typeof rt?.hooks?.dispatchHookAgentTurn === "function");
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ }
414
+ export function resolveBackendLaunch(params) {
415
+ const shimPath = params.shimPath ?? SHIM_PATH;
416
+ if (!params.hostSupportsExecute) {
417
+ return {
418
+ kind: "legacy",
419
+ shimPath,
420
+ reason: "this OpenClaw does not expose runtime.agent.runCommandFromIngress and " +
421
+ "runtime.hooks.dispatchHookAgentTurn, so it is treated as not running plugin execute " +
422
+ "transports (needs >= 2026.8.1)",
423
+ };
424
+ }
425
+ const claude = resolveClaudeExecutable(params.env ?? process.env, params.platform ?? process.platform);
426
+ if (!claude.ok)
427
+ return { kind: "legacy", shimPath, reason: claude.reason };
428
+ return {
429
+ kind: "verified",
430
+ claudeCommand: claude.command,
431
+ claudeSource: claude.source,
432
+ shimPath,
433
+ ...(params.log ? { log: params.log } : {}),
434
+ };
435
+ }
436
+ const SHIM_ONLY_ENV = [
437
+ RETRY_ROSTER_ENV,
438
+ SESSION_DIRS_ENV,
439
+ "MULTI_CLAWD_MODEL_OVERRIDE",
440
+ CLAUDE_BIN_ENV,
441
+ CLAUDE_ARGV0_ENV,
442
+ ];
443
+ function preparedForLaunch(launch, ctx, env, transport) {
444
+ if (launch.kind === "legacy")
445
+ return { env };
446
+ if (ctx.executionMode === "side-question") {
447
+ for (const key of SHIM_ONLY_ENV)
448
+ delete env[key];
449
+ return { env };
450
+ }
451
+ if (!transport)
452
+ throw new Error("[multi-clawd] verified launch is missing its shim transport");
453
+ return { env, execute: transport };
454
+ }
362
455
  function knownModelsFile() {
363
456
  return join(homedir(), ".openclaw", "state", "multi-clawd", "known-models.json");
364
457
  }
@@ -407,7 +500,16 @@ function checkModelCurrency(catalogIds, chainRefs, poolId) {
407
500
  catch {
408
501
  }
409
502
  }
410
- export function buildBackend(account, execMode) {
503
+ export function buildBackend(account, execMode, launch = { kind: "legacy", shimPath: SHIM_PATH }) {
504
+ const verified = launch.kind === "verified";
505
+ const shimPrefix = verified ? [] : [launch.shimPath];
506
+ const transport = verified
507
+ ? createShimExecute({
508
+ configuredCommand: launch.claudeCommand,
509
+ shimPath: launch.shimPath,
510
+ ...(launch.log ? { log: launch.log } : {}),
511
+ })
512
+ : undefined;
411
513
  const backend = {
412
514
  id: account.id,
413
515
  liveTest: {
@@ -419,6 +521,7 @@ export function buildBackend(account, execMode) {
419
521
  binaryName: "claude",
420
522
  },
421
523
  },
524
+ ...(verified ? { runtimeArtifact: CLAUDE_RUNTIME_ARTIFACT } : {}),
422
525
  bundleMcp: true,
423
526
  bundleMcpMode: "claude-config-file",
424
527
  nativeToolMode: "selectable",
@@ -427,9 +530,9 @@ export function buildBackend(account, execMode) {
427
530
  sideQuestionToolMode: "disabled",
428
531
  ownsNativeCompaction: true,
429
532
  config: {
430
- command: process.execPath,
431
- args: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode)],
432
- resumeArgs: [SHIM_PATH, ...BASE_ARGS, ...permissionModeArgs(execMode), "--resume", "{sessionId}"],
533
+ command: verified ? launch.claudeCommand : process.execPath,
534
+ args: [...shimPrefix, ...BASE_ARGS, ...permissionModeArgs(execMode)],
535
+ resumeArgs: [...shimPrefix, ...BASE_ARGS, ...permissionModeArgs(execMode), "--resume", "{sessionId}"],
433
536
  forkArg: "--fork-session",
434
537
  resumeAtArg: "--resume-session-at",
435
538
  output: "jsonl",
@@ -457,13 +560,15 @@ export function buildBackend(account, execMode) {
457
560
  },
458
561
  serialize: true,
459
562
  },
460
- async prepareExecution(_ctx) {
461
- return { env: await buildAccountEnv(account) };
563
+ async prepareExecution(ctx) {
564
+ return preparedForLaunch(launch, ctx, await buildAccountEnv(account), transport);
462
565
  },
463
566
  };
567
+ launchTransports.set(backend, { launch, transport });
464
568
  Object.assign(backend.config, { sessionArg: "--session-id" });
465
569
  return backend;
466
570
  }
571
+ const launchTransports = new WeakMap();
467
572
  export function buildRetryRoster(members, launchedId) {
468
573
  const roster = [];
469
574
  for (const member of members) {
@@ -490,26 +595,25 @@ export function isRuntimelessRegistration(api) {
490
595
  const mode = api?.registrationMode;
491
596
  return mode === "cli-metadata" || mode === "setup-only";
492
597
  }
493
- function buildCatalogProvider(account) {
598
+ function buildCatalogProvider(account, members) {
494
599
  return {
495
600
  id: account.id,
496
601
  label: account.label ?? `Claude Code (${account.id})`,
497
602
  auth: [],
498
603
  resolveSyntheticAuth: () => {
499
604
  try {
500
- const token = peekToken(account);
501
- if (!token)
502
- return undefined;
503
- return {
504
- apiKey: token,
505
- source: `multi-clawd ${account.id} token`,
506
- mode: "token",
507
- };
605
+ return members
606
+ ? poolRuntimeAuth(account.id, members.map((member) => () => resolveAccountRuntimeAuth(member)))
607
+ : resolveAccountRuntimeAuth(account);
508
608
  }
509
609
  catch {
510
610
  return undefined;
511
611
  }
512
612
  },
613
+ prepareRuntimeAuth: async (ctx) => {
614
+ refuseRuntimeAuthMarker(ctx);
615
+ return undefined;
616
+ },
513
617
  augmentModelCatalog: async () => buildCatalogEntries(account, await resolveBaseModelIds()),
514
618
  resolveDynamicModel: (ctx) => buildRuntimeModel(account, ctx.modelId),
515
619
  };
@@ -561,7 +665,22 @@ export default definePluginEntry({
561
665
  }
562
666
  const execMode = resolveExecMode(runtimeConfigLoader?.() ?? api.config);
563
667
  api.logger.info(`[multi-clawd] exec policy: ${execMode ?? "unknown"} → permission-mode ${permissionModeArgs(execMode).length ? "bypassPermissions" : "default (no override)"}`);
668
+ const launchLogger = api.logger;
669
+ const launch = resolveBackendLaunch({
670
+ hostSupportsExecute: hostRunsExecuteTransports(api.runtime),
671
+ log: (line) => launchLogger.info(line),
672
+ });
673
+ if (launch.kind === "verified") {
674
+ api.logger.info(`[multi-clawd] launch mode: verified — claude ${launch.claudeCommand} (from ${launch.claudeSource === "override" ? "MULTI_CLAWD_CLAUDE_BIN" : "PATH"}) is the backend command; host runs execute transports ` +
675
+ `(runtime.agent.runCommandFromIngress + runtime.hooks.dispatchHookAgentTurn present), ` +
676
+ `so agent turns run through shim ${launch.shimPath} and side-questions run claude directly`);
677
+ }
678
+ else {
679
+ api.logger.warn(`[multi-clawd] launch mode: legacy node+shim — ${launch.reason ?? "unknown reason"}; ` +
680
+ `failover is unchanged, but OpenClaw cannot verify this backend as a setup owner`);
681
+ }
564
682
  const seen = new Set();
683
+ loginAuthCache = undefined;
565
684
  for (const account of accounts) {
566
685
  const id = account?.id?.trim();
567
686
  if (!id) {
@@ -577,10 +696,11 @@ export default definePluginEntry({
577
696
  }
578
697
  seen.add(id);
579
698
  const normalized = { ...account, id };
580
- api.registerCliBackend(buildBackend(normalized, execMode));
699
+ api.registerCliBackend(buildBackend(normalized, execMode, launch));
581
700
  api.registerProvider(buildCatalogProvider(normalized));
582
701
  }
583
- registerPoolBackend(api, cfg.pool, accounts, seen, execMode);
702
+ registerPoolBackend(api, cfg.pool, accounts, seen, execMode, { launch });
703
+ void warmLoginAuth(accounts.filter((a) => seen.has(a.id.trim()))).catch(() => { });
584
704
  {
585
705
  const logger = api.logger;
586
706
  try {
@@ -696,7 +816,8 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
696
816
  };
697
817
  const minDwellMs = pool.minDwellMs;
698
818
  const stickyFile = join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sticky.json`);
699
- const backend = buildBackend(poolAccount, execMode);
819
+ const backend = buildBackend(poolAccount, execMode, deps?.launch);
820
+ const { launch, transport } = launchTransports.get(backend);
700
821
  backend.prepareExecution = async (ctx) => {
701
822
  const now = Date.now();
702
823
  const requestedModel = canonicalModelId(ctx.modelId) ?? ctx.modelId;
@@ -851,7 +972,12 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
851
972
  requestedModel: ctx.modelId,
852
973
  ladder,
853
974
  });
854
- if (degradation) {
975
+ const runsWithoutShim = launch.kind === "verified" && ctx.executionMode === "side-question";
976
+ if (degradation && runsWithoutShim) {
977
+ logger.warn(`[multi-clawd] pool ${poolId}: side-question runs ${ctx.modelId} undegraded on ${(launched ?? chosen).id} — every account is limited for it, and the degrade ladder (${degradation.model}) ` +
978
+ `can only be applied by the shim, which OpenClaw does not run for side-questions`);
979
+ }
980
+ else if (degradation) {
855
981
  env.MULTI_CLAWD_MODEL_OVERRIDE = degradation.model;
856
982
  const line = `pool ${poolId}: degrading ${ctx.modelId} → ${degradation.model} on ${chosen.id} (${degradation.reason})`;
857
983
  logger.info(`[multi-clawd] ${line}`);
@@ -861,10 +987,10 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
861
987
  logger.info(`[multi-clawd] pool ${poolId}: pinned lane keeps ${ctx.modelId} despite exhausted pool (will fail over via the chain)`);
862
988
  }
863
989
  }
864
- return { env };
990
+ return preparedForLaunch(launch, ctx, env, transport);
865
991
  };
866
992
  api.registerCliBackend(backend);
867
- api.registerProvider(buildCatalogProvider(poolAccount));
993
+ api.registerProvider(buildCatalogProvider(poolAccount, members));
868
994
  registeredIds.add(poolId);
869
995
  logger.info(`[multi-clawd] pool "${poolId}" active — accounts: ${memberIds.join(" → ")}, threshold: ${options.utilizationThreshold ?? 0.85}`);
870
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
+ }
@@ -0,0 +1,247 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { createInterface } from "node:readline";
7
+ import { CLAUDE_ARGV0_ENV, CLAUDE_BIN_ENV } from "./claude-command.js";
8
+ const EXPECTED_FIRST_ARG = "-p";
9
+ const SYSTEM_PROMPT_FILE_ARG = "--append-system-prompt-file";
10
+ const STDERR_TAIL_CHARS = 8192;
11
+ const KILL_GRACE_MS = 1000;
12
+ const EXIT_WAIT_MS = 3000;
13
+ export function composePromptForStdin(prompt, context) {
14
+ const prepended = context?.prependContext ? `${context.prependContext}\n\n${prompt}` : prompt;
15
+ return context?.appendContext ? `${prepended}\n\n${context.appendContext}` : prepended;
16
+ }
17
+ export function assertWrappableInvocation(ctx, configuredCommand) {
18
+ const registered = realpathOf(configuredCommand);
19
+ const handed = realpathOf(ctx.command);
20
+ if (!registered || !handed || registered !== handed) {
21
+ throw new Error(`[multi-clawd] refusing to run ${ctx.command} through the shim: this backend only wraps ` +
22
+ `its registered claude executable ${configuredCommand}`);
23
+ }
24
+ if (ctx.args[0] !== EXPECTED_FIRST_ARG) {
25
+ throw new Error(`[multi-clawd] refusing to wrap an invocation with a leading argv (${String(ctx.args[0])}): ` +
26
+ `the shim can only exec ${configuredCommand} itself`);
27
+ }
28
+ if (ctx.argv0 !== undefined && ctx.argv0 !== configuredCommand) {
29
+ throw new Error(`[multi-clawd] refusing argv0 ${JSON.stringify(ctx.argv0)}: only the registered command ` +
30
+ `${configuredCommand} may name the claude executable`);
31
+ }
32
+ return { executable: handed, argv0: configuredCommand };
33
+ }
34
+ function realpathOf(path) {
35
+ if (!isAbsolute(path))
36
+ return undefined;
37
+ try {
38
+ return realpathSync(path);
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function isPlainRecord(value) {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+ function toError(reason, fallback) {
48
+ if (reason instanceof Error)
49
+ return reason;
50
+ return new Error(typeof reason === "string" && reason ? reason : fallback);
51
+ }
52
+ function redact(text, env) {
53
+ let out = text;
54
+ for (const [name, value] of Object.entries(env)) {
55
+ if (!value || value.length < 8)
56
+ continue;
57
+ if (!/TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(name))
58
+ continue;
59
+ out = out.split(value).join("[REDACTED]");
60
+ }
61
+ return out;
62
+ }
63
+ function appendTail(tail, chunk) {
64
+ const next = tail + chunk;
65
+ return next.length > STDERR_TAIL_CHARS ? next.slice(-STDERR_TAIL_CHARS) : next;
66
+ }
67
+ function signalTree(child, signal) {
68
+ if (!child.pid)
69
+ return;
70
+ try {
71
+ if (process.platform !== "win32")
72
+ process.kill(-child.pid, signal);
73
+ else
74
+ child.kill(signal);
75
+ }
76
+ catch {
77
+ try {
78
+ child.kill(signal);
79
+ }
80
+ catch {
81
+ }
82
+ }
83
+ }
84
+ export function createShimExecute(options) {
85
+ const nodePath = options.nodePath ?? process.execPath;
86
+ return async function* executeThroughShim(ctx) {
87
+ const invocation = assertWrappableInvocation(ctx, options.configuredCommand);
88
+ ctx.assertCurrent?.();
89
+ ctx.abortSignal?.throwIfAborted();
90
+ const capability = ctx.liveSession;
91
+ if (capability?.restart)
92
+ await capability.restart();
93
+ ctx.assertCurrent?.();
94
+ let child;
95
+ let exited = false;
96
+ let resolveExit;
97
+ const exitPromise = new Promise((r) => (resolveExit = r));
98
+ let exitCode = null;
99
+ let exitSignal = null;
100
+ let spawnError;
101
+ let killTimer;
102
+ const terminate = () => {
103
+ if (!child || exited)
104
+ return;
105
+ signalTree(child, "SIGTERM");
106
+ if (!killTimer) {
107
+ killTimer = setTimeout(() => {
108
+ if (child && !exited)
109
+ signalTree(child, "SIGKILL");
110
+ }, KILL_GRACE_MS);
111
+ killTimer.unref?.();
112
+ }
113
+ };
114
+ const handle = capability
115
+ ? {
116
+ generation: randomUUID(),
117
+ fingerprint: capability.fingerprint,
118
+ isIdle: () => exited || !child,
119
+ close: () => terminate(),
120
+ waitForExit: () => (child ? exitPromise : Promise.resolve()),
121
+ }
122
+ : undefined;
123
+ let registered = false;
124
+ let promptDir;
125
+ const onAbort = () => terminate();
126
+ let stderrTail = "";
127
+ let stdoutNoise = "";
128
+ let stderrPartial = "";
129
+ try {
130
+ if (capability && handle) {
131
+ capability.register(handle);
132
+ registered = true;
133
+ capability.activate(handle);
134
+ }
135
+ const args = [...ctx.args];
136
+ const systemPrompt = ctx.systemPrompt.trim();
137
+ if (systemPrompt) {
138
+ promptDir = mkdtempSync(join(tmpdir(), "multi-clawd-system-prompt-"));
139
+ const file = join(promptDir, "system-prompt.md");
140
+ writeFileSync(file, systemPrompt, { mode: 0o600 });
141
+ args.push(SYSTEM_PROMPT_FILE_ARG, file);
142
+ }
143
+ const env = {
144
+ ...ctx.env,
145
+ [CLAUDE_BIN_ENV]: invocation.executable,
146
+ [CLAUDE_ARGV0_ENV]: invocation.argv0,
147
+ };
148
+ ctx.assertCurrent?.();
149
+ ctx.abortSignal?.throwIfAborted();
150
+ const spawned = spawn(nodePath, [options.shimPath, ...args], {
151
+ cwd: ctx.cwd,
152
+ env,
153
+ stdio: ["pipe", "pipe", "pipe"],
154
+ detached: process.platform !== "win32",
155
+ windowsHide: true,
156
+ });
157
+ child = spawned;
158
+ spawned.once("error", (err) => {
159
+ spawnError = err;
160
+ exited = true;
161
+ resolveExit();
162
+ });
163
+ spawned.once("close", (code, signal) => {
164
+ exitCode = code;
165
+ exitSignal = signal;
166
+ exited = true;
167
+ resolveExit();
168
+ });
169
+ ctx.abortSignal?.addEventListener("abort", onAbort, { once: true });
170
+ spawned.stderr.setEncoding("utf8");
171
+ spawned.stderr.on("data", (chunk) => {
172
+ stderrTail = appendTail(stderrTail, chunk);
173
+ if (!options.log)
174
+ return;
175
+ const text = stderrPartial + chunk;
176
+ const lines = text.split("\n");
177
+ stderrPartial = lines.pop() ?? "";
178
+ for (const line of lines) {
179
+ if (line.startsWith("[multi-clawd shim]"))
180
+ options.log(line);
181
+ }
182
+ });
183
+ spawned.stdin.on("error", () => {
184
+ });
185
+ spawned.stdin.end(composePromptForStdin(ctx.prompt, ctx.promptContext));
186
+ const lines = createInterface({ input: spawned.stdout, crlfDelay: Infinity });
187
+ for await (const raw of lines) {
188
+ const line = raw.trim();
189
+ if (!line)
190
+ continue;
191
+ let record;
192
+ try {
193
+ record = JSON.parse(line);
194
+ }
195
+ catch {
196
+ stdoutNoise = appendTail(stdoutNoise, `${line}\n`);
197
+ continue;
198
+ }
199
+ if (!isPlainRecord(record)) {
200
+ stdoutNoise = appendTail(stdoutNoise, `${line}\n`);
201
+ continue;
202
+ }
203
+ yield record;
204
+ }
205
+ await exitPromise;
206
+ if (ctx.abortSignal?.aborted) {
207
+ throw toError(ctx.abortSignal.reason, "Claude CLI run was aborted.");
208
+ }
209
+ if (spawnError) {
210
+ throw new Error(`[multi-clawd] failed to start the shim: ${spawnError.message}`);
211
+ }
212
+ if (exitSignal || exitCode !== 0) {
213
+ const candidates = [stderrTail.trim(), stdoutNoise.trim()].filter(Boolean);
214
+ const detail = candidates[0] ?? `CLI failed (${exitSignal ? `signal ${exitSignal}` : `exit ${exitCode}`}).`;
215
+ throw new Error(redact(detail, env));
216
+ }
217
+ }
218
+ finally {
219
+ ctx.abortSignal?.removeEventListener("abort", onAbort);
220
+ if (child && !exited) {
221
+ terminate();
222
+ await Promise.race([
223
+ exitPromise,
224
+ new Promise((r) => setTimeout(r, EXIT_WAIT_MS).unref?.()),
225
+ ]);
226
+ }
227
+ if (killTimer)
228
+ clearTimeout(killTimer);
229
+ if (!child)
230
+ resolveExit();
231
+ if (registered && capability && handle) {
232
+ try {
233
+ capability.remove(handle);
234
+ }
235
+ catch {
236
+ }
237
+ }
238
+ if (promptDir) {
239
+ try {
240
+ rmSync(promptDir, { recursive: true, force: true });
241
+ }
242
+ catch {
243
+ }
244
+ }
245
+ }
246
+ };
247
+ }
package/dist/shim.js CHANGED
@@ -6,21 +6,36 @@ import { rewriteModelArg } from "./degrade.js";
6
6
  import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
7
7
  import { canonicalModelId } from "./models.js";
8
8
  import { buildRetryEnv, chooseLaunchableRetryAccount, materializeRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
9
+ import { CLAUDE_ARGV0_ENV, CLAUDE_BIN_ENV, parseClaudeBinOverride, resolveExecutable, } from "./claude-command.js";
9
10
  import { effectiveConfigDir, handoverForLaunch, handoverSnapshotTo, parseSessionDirs, SESSION_DIRS_ENV, snapshotResumeTranscript, } from "./session-handover.js";
10
11
  function resolveClaudeCommand() {
11
- const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
12
- if (override) {
13
- try {
14
- const parsed = JSON.parse(override);
15
- if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((p) => typeof p === "string")) {
16
- return { command: parsed[0], prependArgs: parsed.slice(1) };
17
- }
18
- }
19
- catch {
20
- }
21
- return { command: override, prependArgs: [] };
12
+ const raw = process.env[CLAUDE_BIN_ENV];
13
+ const argv0 = process.env[CLAUDE_ARGV0_ENV] || undefined;
14
+ delete process.env[CLAUDE_BIN_ENV];
15
+ delete process.env[CLAUDE_ARGV0_ENV];
16
+ const override = parseClaudeBinOverride(raw);
17
+ if (!override) {
18
+ if (argv0)
19
+ refuseLaunch(`${CLAUDE_ARGV0_ENV} is set without ${CLAUDE_BIN_ENV}`);
20
+ return { command: "claude", prependArgs: [] };
22
21
  }
23
- return { command: "claude", prependArgs: [] };
22
+ const requested = override.kind === "argv" ? override.command : override.value;
23
+ const command = resolveExecutable(requested, process.env.PATH);
24
+ if (!command) {
25
+ refuseLaunch(`${CLAUDE_BIN_ENV}=${requested} is not an executable file`);
26
+ }
27
+ if (argv0 && override.kind === "argv" && override.prependArgs.length > 0) {
28
+ refuseLaunch(`${CLAUDE_ARGV0_ENV} cannot apply to an interpreter-form ${CLAUDE_BIN_ENV}`);
29
+ }
30
+ return {
31
+ command,
32
+ prependArgs: override.kind === "argv" ? override.prependArgs : [],
33
+ ...(argv0 ? { argv0 } : {}),
34
+ };
35
+ }
36
+ function refuseLaunch(why) {
37
+ process.stderr.write(`[multi-clawd shim] refusing to launch claude: ${why}\n`);
38
+ process.exit(127);
24
39
  }
25
40
  let stateFile = process.env.MULTI_CLAWD_STATE_FILE;
26
41
  let accountId = process.env.MULTI_CLAWD_ACCOUNT_ID ?? "unknown";
@@ -79,7 +94,7 @@ function persistState() {
79
94
  process.stderr.write(`[multi-clawd shim] state write failed: ${String(err)}\n`);
80
95
  }
81
96
  }
82
- const { command, prependArgs } = resolveClaudeCommand();
97
+ const { command, prependArgs, argv0: claudeArgv0 } = resolveClaudeCommand();
83
98
  let childArgs = [...prependArgs, ...process.argv.slice(2)];
84
99
  const modelOverride = process.env.MULTI_CLAWD_MODEL_OVERRIDE;
85
100
  if (modelOverride) {
@@ -107,6 +122,7 @@ if (!retryArmed && retryRoster.length > 0 && arming.reason) {
107
122
  let child = spawn(command, childArgs, {
108
123
  stdio: ["pipe", "pipe", "inherit"],
109
124
  env: process.env,
125
+ ...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
110
126
  });
111
127
  const STDIN_REPLAY_CAP_BYTES = 8 * 1024 * 1024;
112
128
  let stdinReplay = [];
@@ -286,6 +302,7 @@ function attemptRetry() {
286
302
  child = spawn(command, childArgs, {
287
303
  stdio: ["pipe", "pipe", "inherit"],
288
304
  env: buildRetryEnv(process.env, target),
305
+ ...(claudeArgv0 ? { argv0: claudeArgv0 } : {}),
289
306
  });
290
307
  attachChildHandlers();
291
308
  for (const chunk of stdinReplay)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "2.0.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.0.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",
@@ -30,8 +30,8 @@
30
30
  "./dist/index.js"
31
31
  ],
32
32
  "compat": {
33
- "pluginApi": ">=2026.3.24-beta.2",
34
- "minGatewayVersion": "2026.3.24-beta.2"
33
+ "pluginApi": ">=2026.8.1",
34
+ "minGatewayVersion": "2026.8.1"
35
35
  }
36
36
  },
37
37
  "main": "./dist/index.js",
@@ -64,7 +64,7 @@
64
64
  "version": "npm run sync-manifest && npm run sync-readme-tag && git add openclaw.plugin.json README.md"
65
65
  },
66
66
  "peerDependencies": {
67
- "openclaw": ">=2026.6"
67
+ "openclaw": ">=2026.8.1"
68
68
  },
69
69
  "peerDependenciesMeta": {
70
70
  "openclaw": {