@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 +36 -2
- package/dist/claude-command.js +69 -0
- package/dist/credential-store.js +27 -1
- package/dist/index.js +149 -23
- package/dist/runtime-auth.js +124 -0
- package/dist/shim-transport.js +247 -0
- package/dist/shim.js +30 -13
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -4
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.
|
|
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.
|
|
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
|
+
}
|
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,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: [
|
|
432
|
-
resumeArgs: [
|
|
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(
|
|
461
|
-
return
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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)
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
34
|
-
"minGatewayVersion": "2026.
|
|
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.
|
|
67
|
+
"openclaw": ">=2026.8.1"
|
|
68
68
|
},
|
|
69
69
|
"peerDependenciesMeta": {
|
|
70
70
|
"openclaw": {
|