@drakon-systems/multi-clawd 1.8.0 → 1.8.2

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
@@ -73,7 +73,10 @@ multi-clawd hermes sync --profile work --strategy least_used
73
73
  multi-clawd hermes doctor --config ~/configs/openclaw.json
74
74
  ```
75
75
 
76
- Validated against Hermes Agent **0.19.1**.
76
+ Validated against Hermes Agent **0.20.6**. The bridge/core/CLI integration
77
+ tests exercise that installed release's real Python APIs in isolated temporary
78
+ homes; on machines without a compatible Hermes install, those integration tests
79
+ skip while the no-Hermes CLI safety tests still run.
77
80
 
78
81
  ### Only stable setup tokens are imported
79
82
 
@@ -94,7 +97,7 @@ Everything else is refused, on purpose:
94
97
  on that source instead of duplicating the grant.
95
98
  - **`configDir` logins are not importable, and Hermes cannot be pointed at
96
99
  them either.** The same single-use-refresh-token problem applies, and unlike
97
- a native login there is no Hermes-side fallback: as of Hermes Agent 0.19.1,
100
+ a native login there is no Hermes-side fallback: as of Hermes Agent 0.20.6,
98
101
  its `claude_code` credential source reads only the native path above, never
99
102
  an arbitrary `configDir`. A `configDir` account can only reach Hermes' pool
100
103
  by getting its own `oauthTokenFile` (a `claude setup-token`, same as above),
@@ -364,7 +367,7 @@ openclaw plugins install (Get-Location).Path
364
367
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
365
368
  Code on the target machine already? Paste it this and go make coffee:
366
369
 
367
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.0/SETUP-AGENT.md
370
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.2/SETUP-AGENT.md
368
371
  > and follow it to set up multi-clawd on this machine. I own a second
369
372
  > Claude account — ask me when you need me to log in.
370
373
 
@@ -377,7 +380,11 @@ happens to say today. Swap the tag if you want a different release, but
377
380
  prefer a tag over a branch.
378
381
 
379
382
  **Requirements:** OpenClaw ≥ 2026.6, the `claude` CLI on `PATH`, and a
380
- second Claude subscription you own.
383
+ second Claude subscription you own. The current plugin SDK contract and runtime
384
+ registration path are tested against OpenClaw **2026.7.1**. The standalone
385
+ `multi-clawd` CLI and Hermes commands do not require the optional OpenClaw peer
386
+ to be resolvable; plugin loading and OpenClaw-backed commands still require the
387
+ host-provided peer.
381
388
 
382
389
  **Upgrading:**
383
390
 
package/SECURITY.md CHANGED
@@ -30,9 +30,10 @@ credit you (unless you'd rather we didn't), and note it in the CHANGELOG.
30
30
  `oauthTokenFile`, multi-clawd warns (once per process) when the file is
31
31
  readable beyond your own user account. It warns rather than refuses: the
32
32
  credential still works, and the fix is yours to make — `chmod 600`.
33
- - **Host credentials are stripped from child processes.** 22 Claude/Anthropic
34
- environment variables are cleared before each launch, so one account's
35
- credential cannot bleed into another account's session.
33
+ - **Host credentials are stripped from child processes.** 38 Claude/Anthropic
34
+ environment variables covering authentication, runtime, and telemetry are
35
+ cleared before each launch, so one account's credential cannot bleed into
36
+ another account's session.
36
37
  - **State files are written `0600`,** atomically (temp file + rename), and
37
38
  contain rate-limit telemetry only — never credentials.
38
39
 
@@ -0,0 +1,30 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { parseStoredState, mergeHealthStates, clearCredentialFailure, } from "./shim-core.js";
5
+ export function healthStateFile(accountId) {
6
+ return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
7
+ }
8
+ export function clearAccountCredentialFailure(accountId) {
9
+ const file = healthStateFile(accountId);
10
+ let state;
11
+ try {
12
+ state = parseStoredState(readFileSync(file, "utf8")) ?? { accountId, windows: {} };
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ if (state.credential?.status !== "failed")
18
+ return false;
19
+ const cleared = mergeHealthStates(state, clearCredentialFailure(state, Date.now()), Date.now());
20
+ try {
21
+ mkdirSync(dirname(file), { recursive: true });
22
+ const tmp = `${file}.tmp-${process.pid}`;
23
+ writeFileSync(tmp, JSON.stringify(cleared, null, 2), { mode: 0o600 });
24
+ renameSync(tmp, file);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
package/dist/index.js CHANGED
@@ -11,10 +11,12 @@ import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
11
11
  import { resolveBaseModelIds } from "./catalog-source.js";
12
12
  import { allCredentialFailed, classifyAccountHealth } from "./health.js";
13
13
  import { decideStickySelection } from "./sticky.js";
14
- import { clearCredentialFailure, mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
14
+ import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
15
15
  import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
16
16
  import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
17
17
  import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
18
+ import { healthStateFile, clearAccountCredentialFailure } from "./credential-state.js";
19
+ export { healthStateFile, clearAccountCredentialFailure };
18
20
  import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
19
21
  import { diffCatalogModels, formatNewModelNotice, } from "./model-currency.js";
20
22
  import { checkAccountCredential, createRefProbeTracker, } from "./login-health.js";
@@ -32,7 +34,7 @@ const BASE_ARGS = [
32
34
  "--disallowedTools",
33
35
  "ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor",
34
36
  ];
35
- const CLEAR_ENV = [
37
+ export const CLEAR_ENV = [
36
38
  "ANTHROPIC_API_KEY",
37
39
  "ANTHROPIC_API_KEY_OLD",
38
40
  "ANTHROPIC_API_TOKEN",
@@ -55,6 +57,22 @@ const CLEAR_ENV = [
55
57
  "CLAUDE_CODE_USE_BEDROCK",
56
58
  "CLAUDE_CODE_USE_FOUNDRY",
57
59
  "CLAUDE_CODE_USE_VERTEX",
60
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
61
+ "OTEL_EXPORTER_OTLP_HEADERS",
62
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
63
+ "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
64
+ "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
65
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
66
+ "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
67
+ "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
68
+ "OTEL_EXPORTER_OTLP_PROTOCOL",
69
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
70
+ "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
71
+ "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
72
+ "OTEL_LOGS_EXPORTER",
73
+ "OTEL_METRICS_EXPORTER",
74
+ "OTEL_SDK_DISABLED",
75
+ "OTEL_TRACES_EXPORTER",
58
76
  ];
59
77
  function expandHome(p) {
60
78
  if (p === "~")
@@ -285,9 +303,6 @@ function buildRuntimeModel(account, modelId) {
285
303
  };
286
304
  }
287
305
  const SHIM_PATH = fileURLToPath(new URL("./shim.js", import.meta.url));
288
- export function healthStateFile(accountId) {
289
- return join(homedir(), ".openclaw", "state", "multi-clawd", `${accountId}.json`);
290
- }
291
306
  function knownModelsFile() {
292
307
  return join(homedir(), ".openclaw", "state", "multi-clawd", "known-models.json");
293
308
  }
@@ -500,32 +515,6 @@ function readHealthState(accountId) {
500
515
  return undefined;
501
516
  }
502
517
  }
503
- export function clearAccountCredentialFailure(accountId) {
504
- const file = healthStateFile(accountId);
505
- let state;
506
- try {
507
- state = parseStoredState(readFileSync(file, "utf8")) ?? {
508
- accountId,
509
- windows: {},
510
- };
511
- }
512
- catch {
513
- return false;
514
- }
515
- if (state.credential?.status !== "failed")
516
- return false;
517
- const cleared = mergeHealthStates(state, clearCredentialFailure(state, Date.now()), Date.now());
518
- try {
519
- mkdirSync(dirname(file), { recursive: true });
520
- const tmp = `${file}.tmp-${process.pid}`;
521
- writeFileSync(tmp, JSON.stringify(cleared, null, 2), { mode: 0o600 });
522
- renameSync(tmp, file);
523
- return true;
524
- }
525
- catch {
526
- return false;
527
- }
528
- }
529
518
  function readStickyEntry(file) {
530
519
  try {
531
520
  const parsed = JSON.parse(readFileSync(file, "utf8"));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "multi-clawd",
3
3
  "name": "multi-clawd",
4
- "version": "1.8.0",
4
+ "version": "1.8.2",
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": "1.8.0",
3
+ "version": "1.8.2",
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",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "openclaw": {
23
23
  "build": {
24
- "openclawVersion": "2026.6.11"
24
+ "openclawVersion": "2026.7.1"
25
25
  },
26
26
  "extensions": [
27
27
  "./src/index.ts"
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "main": "./dist/index.js",
38
38
  "bin": {
39
- "multi-clawd": "./scripts/cli.mjs"
39
+ "multi-clawd": "scripts/cli.mjs"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
package/scripts/cli.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * has to remember `--pin --force`.
17
17
  */
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
- import { readFileSync } from "node:fs";
19
+ import { readFileSync, existsSync as existsSyncEarly } from "node:fs";
20
20
  import { dirname, join, resolve } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import readline from "node:readline/promises";
@@ -92,6 +92,34 @@ async function askYes(question, dflt = true) {
92
92
  }
93
93
 
94
94
  /** This package's own version (the CLI half). */
95
+ /**
96
+ * Why a `dist/` import failed, in the user's terms.
97
+ *
98
+ * The bare "reinstall the package" message was wrong in the one case that
99
+ * actually happens: this CLI installs globally, `openclaw` is a peerDependency,
100
+ * and on a box where the peer is not resolvable from this directory a perfectly
101
+ * complete build still throws ERR_MODULE_NOT_FOUND. Reinstalling cannot fix
102
+ * that, so "reinstall the package" sends people in circles — it did, on a Mac,
103
+ * 18 Aug 2026. Check the file exists first, then report the real cause.
104
+ */
105
+ function distFailure(cmd, mod, err) {
106
+ const path = resolve(__dirname, "..", "dist", mod);
107
+ if (!existsSyncEarly(path)) {
108
+ return `${cmd}: built dist/${mod} is missing — reinstall the package.`;
109
+ }
110
+ const missingPeer = /Cannot find package '([^']+)'/.exec(String(err?.message ?? ""));
111
+ if (missingPeer) {
112
+ const peer = missingPeer[1];
113
+ return [
114
+ `${cmd}: dist/${mod} is present but cannot load — the "${peer}" package is not`,
115
+ `resolvable from this install (${resolve(__dirname, "..")}).`,
116
+ `Install "${peer}" globally alongside this CLI, or run the CLI with npx from a`,
117
+ `directory where "${peer}" resolves.`,
118
+ ].join("\n ");
119
+ }
120
+ return `${cmd}: dist/${mod} failed to load — ${err?.message ?? err}`;
121
+ }
122
+
95
123
  function cliVersion() {
96
124
  return JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf8")).version;
97
125
  }
@@ -134,8 +162,8 @@ async function chain(args = []) {
134
162
  let ca;
135
163
  try {
136
164
  ca = await import(resolve(__dirname, "..", "dist", "chain-audit.js"));
137
- } catch {
138
- console.error("chain: built dist/ is missing — reinstall the package.");
165
+ } catch (err) {
166
+ console.error(distFailure("chain", "chain-audit.js", err));
139
167
  process.exit(1);
140
168
  }
141
169
 
@@ -227,8 +255,8 @@ async function update() {
227
255
  let uc;
228
256
  try {
229
257
  uc = await import(resolve(__dirname, "..", "dist", "update-core.js"));
230
- } catch {
231
- console.error("update: built dist/ is missing — reinstall the package.");
258
+ } catch (err) {
259
+ console.error(distFailure("update", "update-core.js", err));
232
260
  process.exit(1);
233
261
  }
234
262
  console.log(`\n${BOLD}🦞 multi-clawd update${RESET}\n`);
@@ -391,8 +419,8 @@ async function explain() {
391
419
  ec = await import(resolve(__dirname, "..", "dist", "explain-core.js"));
392
420
  health = await import(resolve(__dirname, "..", "dist", "health.js"));
393
421
  shim = await import(resolve(__dirname, "..", "dist", "shim-core.js"));
394
- } catch {
395
- console.error("explain: built dist/ is missing — reinstall the package.");
422
+ } catch (err) {
423
+ console.error(distFailure("explain", "explain-core.js", err));
396
424
  process.exit(1);
397
425
  }
398
426
  let config = {};
@@ -465,9 +493,12 @@ async function login() {
465
493
  try {
466
494
  lp = await import(resolve(__dirname, "..", "dist", "login-plan.js"));
467
495
  ec = await import(resolve(__dirname, "..", "dist", "explain-core.js"));
468
- idx = await import(resolve(__dirname, "..", "dist", "index.js"));
469
- } catch {
470
- console.error("login: built dist/ is missing reinstall the package.");
496
+ // credential-state.js, NOT index.js: this is the one call login needs from
497
+ // the plugin side, and index.js drags in the `openclaw` peer, which is not
498
+ // resolvable from a global CLI install on every machine.
499
+ idx = await import(resolve(__dirname, "..", "dist", "credential-state.js"));
500
+ } catch (err) {
501
+ console.error(distFailure("login", "login-plan.js", err));
471
502
  process.exit(1);
472
503
  }
473
504
  let config = {};
@@ -10,7 +10,7 @@ grant (a native or config-dir ``.credentials.json``) is single-use on refresh,
10
10
  so duplicating one into a second store guarantees that one of the copies dies.
11
11
  For a *native* login that copy is unnecessary anyway: Hermes' own
12
12
  ``claude_code`` credential source already reads that exact file directly. A
13
- *config-dir* login has no such fallback — as of Hermes Agent 0.19.1,
13
+ *config-dir* login has no such fallback — as of Hermes Agent 0.20.6,
14
14
  ``claude_code`` only reads the native path, never an arbitrary config dir — so
15
15
  it can only reach this bridge via its own setup token, never a duplicated
16
16
  grant. Requests carrying refresh tokens or expiries are refused outright.
@@ -60,6 +60,24 @@ ACCOUNT_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789_-")
60
60
  # Cleared whenever a managed row is written: a setup token never expires on a
61
61
  # schedule, so a stale expiry copied from an older row would quarantine it.
62
62
  STALE_EXPIRY_FIELDS = ("expires_at", "expires_at_ms", "last_refresh")
63
+ # Hermes' own runtime bookkeeping (agent/credential_pool.py PooledCredential).
64
+ # Cleared ONLY when the access token itself changes: Hermes' selection
65
+ # (CredentialPool._available_entries) skips an entry whose last_status is
66
+ # "exhausted" or "dead" until last_error_reset_at passes, which can be hours
67
+ # or days out. A rotated-in token proves the row is being actively managed
68
+ # again, so carrying the old verdict forward would leave a freshly-rotated
69
+ # credential unselectable until that window lapses on its own. A
70
+ # metadata-only update (label/priority) with the SAME token is not evidence
71
+ # of anything — Hermes' own telemetry for a healthy pool must survive it
72
+ # untouched.
73
+ STATUS_FIELDS = (
74
+ "last_status",
75
+ "last_status_at",
76
+ "last_error_code",
77
+ "last_error_reason",
78
+ "last_error_message",
79
+ "last_error_reset_at",
80
+ )
63
81
  # Mirrors src/hermes-core.ts's parseClaudeSetupToken: ASCII-only, and shaped
64
82
  # like the current `sk-ant-oat01-...` setup-token family. The version digits
65
83
  # are intentionally unconstrained beyond "two or more" so a future
@@ -443,7 +461,12 @@ def merge_credential(existing: dict[str, Any], credential: DesiredCredential) ->
443
461
  Expiry fields are cleared: a setup token carries no expiry, and a value left
444
462
  over from an older row would make Hermes treat a perfectly good credential
445
463
  as expired.
464
+
465
+ Status fields are cleared too, but only when the access token actually
466
+ changed — see STATUS_FIELDS. A metadata-only update (same token) preserves
467
+ them exactly.
446
468
  """
469
+ token_rotated = existing.get("access_token") != credential.access_token
447
470
  updated = copy.deepcopy(existing)
448
471
  updated.update(
449
472
  {
@@ -458,6 +481,9 @@ def merge_credential(existing: dict[str, Any], credential: DesiredCredential) ->
458
481
  updated.pop("refresh_token", None)
459
482
  for field in STALE_EXPIRY_FIELDS:
460
483
  updated.pop(field, None)
484
+ if token_rotated:
485
+ for field in STATUS_FIELDS:
486
+ updated.pop(field, None)
461
487
  return updated
462
488
 
463
489