@drakon-systems/multi-clawd 1.5.1 → 1.5.3
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 +10 -1
- package/SECURITY.md +82 -0
- package/dist/account-env.js +8 -0
- package/dist/index.js +18 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -2
- package/scripts/cli.mjs +5 -5
- package/scripts/setup.mjs +11 -7
- package/scripts/sync-readme-tag.mjs +34 -0
package/README.md
CHANGED
|
@@ -188,13 +188,18 @@ openclaw plugins install (Get-Location).Path
|
|
|
188
188
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
189
189
|
Code on the target machine already? Paste it this and go make coffee:
|
|
190
190
|
|
|
191
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/
|
|
191
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.5.3/SETUP-AGENT.md
|
|
192
192
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
193
193
|
> Claude account — ask me when you need me to log in.
|
|
194
194
|
|
|
195
195
|
The guide has the guardrails built in (config backup, merge-don't-overwrite,
|
|
196
196
|
never print tokens, ask before touching routing).
|
|
197
197
|
|
|
198
|
+
That URL is pinned to a release tag on purpose: what your agent reads — and
|
|
199
|
+
therefore executes — is fixed at a version you chose, not whatever `master`
|
|
200
|
+
happens to say today. Swap the tag if you want a different release, but
|
|
201
|
+
prefer a tag over a branch.
|
|
202
|
+
|
|
198
203
|
**Requirements:** OpenClaw ≥ 2026.6, the `claude` CLI on `PATH`, and a
|
|
199
204
|
second Claude subscription you own.
|
|
200
205
|
|
|
@@ -446,6 +451,10 @@ event, and no plugin API can force a rebuild. See `DESIGN.md`.
|
|
|
446
451
|
|
|
447
452
|
## Security
|
|
448
453
|
|
|
454
|
+
See **[SECURITY.md](SECURITY.md)** for how to report a vulnerability, exactly
|
|
455
|
+
how credentials are handled, and a table documenting every process-spawn
|
|
456
|
+
callsite (the ones static scanners flag) and why each exists.
|
|
457
|
+
|
|
449
458
|
**Full declaration of what this plugin touches — nothing else:**
|
|
450
459
|
|
|
451
460
|
| Surface | What multi-clawd does |
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
multi-clawd handles Claude Code login credentials. That makes it worth
|
|
4
|
+
explaining exactly what it touches, and worth telling you how to report a
|
|
5
|
+
problem.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
Open a [security advisory](https://github.com/Drakon-Systems-Ltd/multi-clawd/security/advisories/new)
|
|
10
|
+
on the repository, or email **security@drakonsystems.com**. Please do not open
|
|
11
|
+
a public issue for anything exploitable.
|
|
12
|
+
|
|
13
|
+
We aim to acknowledge within 72 hours. If a fix is warranted we will ship it,
|
|
14
|
+
credit you (unless you'd rather we didn't), and note it in the CHANGELOG.
|
|
15
|
+
|
|
16
|
+
## How credentials are handled
|
|
17
|
+
|
|
18
|
+
- **Tokens are never logged and never appear in a command line.** They are
|
|
19
|
+
passed to the Claude CLI child process through its environment only, so they
|
|
20
|
+
cannot leak into a process list, a shell history, or a log file.
|
|
21
|
+
- **Secret references are preferred over token files.** `oauthTokenRef` uses
|
|
22
|
+
the same `{source, provider, id}` shape as the rest of `openclaw.json` and
|
|
23
|
+
resolves through the gateway's own configured secret providers (1Password,
|
|
24
|
+
etc.), so the credential never sits in plaintext on disk.
|
|
25
|
+
- **Secret-resolution failures are redacted to a fixed reason code.** The
|
|
26
|
+
message that reaches your logs contains neither the token, nor the
|
|
27
|
+
provider/id being resolved, nor the provider's own exception text — only the
|
|
28
|
+
error class, for debuggability.
|
|
29
|
+
- **Plaintext token files are checked, not trusted.** If you use the legacy
|
|
30
|
+
`oauthTokenFile`, multi-clawd warns (once per process) when the file is
|
|
31
|
+
readable beyond your own user account. It warns rather than refuses: the
|
|
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.
|
|
36
|
+
- **State files are written `0600`,** atomically (temp file + rename), and
|
|
37
|
+
contain rate-limit telemetry only — never credentials.
|
|
38
|
+
|
|
39
|
+
## Permissions
|
|
40
|
+
|
|
41
|
+
multi-clawd never grants itself more permission than OpenClaw core grants the
|
|
42
|
+
bundled `claude-cli` backend. It appends `--permission-mode bypassPermissions`
|
|
43
|
+
**only** when your own config already sets `tools.exec.mode: "full"` — which is
|
|
44
|
+
what core does for the bundled backend. Under any other exec mode it passes
|
|
45
|
+
nothing and Claude keeps its default prompt-honouring behaviour.
|
|
46
|
+
|
|
47
|
+
## Why static scanners flag this package
|
|
48
|
+
|
|
49
|
+
Automated scans flag multi-clawd for `dangerous_exec`. That is expected and
|
|
50
|
+
correct: starting Claude Code processes *is* the product. Every such callsite
|
|
51
|
+
is listed here so you can check them yourself.
|
|
52
|
+
|
|
53
|
+
| Location | What it runs | Why |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `dist/shim.js` | the `claude` binary | The core function: launches Claude Code for the selected account. Argv is built from a fixed base list plus your configured model; no shell. |
|
|
56
|
+
| `dist/watchdog-schedule.js` | `node <bundled script>` | Runs the eviction watchdog on a timer. Path is the package's own script. |
|
|
57
|
+
| `scripts/cli.mjs` | `node <bundled script>` | The CLI dispatching to its own subcommands. |
|
|
58
|
+
| `scripts/setup.mjs` | `launchctl` / `systemctl` | Loads the watchdog timer during setup on macOS/Linux. |
|
|
59
|
+
|
|
60
|
+
None of these pass a string to a shell — they are direct process spawns with
|
|
61
|
+
argument arrays, so there is no quoting or injection surface. Paths are
|
|
62
|
+
package-internal or derived from your home directory, never from remote input.
|
|
63
|
+
|
|
64
|
+
## Things you should decide for yourself
|
|
65
|
+
|
|
66
|
+
- **The watchdog is persistence.** `setup` schedules a user-level systemd timer
|
|
67
|
+
(or launchd agent) that can restart the OpenClaw gateway. It is optional —
|
|
68
|
+
skip it if you don't want that.
|
|
69
|
+
- **The setup wizard edits `~/.openclaw/openclaw.json`.** It backs the file up
|
|
70
|
+
first and merges rather than overwrites, and `--dry-run` shows you the
|
|
71
|
+
changes without writing. Use it.
|
|
72
|
+
- **The agent-install one-liner is pinned to a release tag,** not `master`, so
|
|
73
|
+
what your assistant reads and executes is fixed at a version you chose.
|
|
74
|
+
Inspect `SETUP-AGENT.md` before pointing an agent at it — that advice holds
|
|
75
|
+
for anyone's setup guide, including ours.
|
|
76
|
+
|
|
77
|
+
## Scope
|
|
78
|
+
|
|
79
|
+
multi-clawd runs entirely on your machine against your own Claude
|
|
80
|
+
subscriptions. It has no server component, no telemetry, and **zero runtime
|
|
81
|
+
dependencies** — the only third-party code in the install is whatever OpenClaw
|
|
82
|
+
itself already provides.
|
package/dist/account-env.js
CHANGED
|
@@ -19,6 +19,14 @@ export function buildAccountChildEnv(account, token, stateFile) {
|
|
|
19
19
|
}
|
|
20
20
|
return env;
|
|
21
21
|
}
|
|
22
|
+
export function tokenFileModeWarning(path, mode) {
|
|
23
|
+
const perms = mode & 0o777;
|
|
24
|
+
if ((perms & 0o077) === 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
return (`token file ${path} is mode ${perms.toString(8).padStart(3, "0")} — readable beyond your ` +
|
|
27
|
+
`user account. Anyone with a login on this machine can take the Claude credential. ` +
|
|
28
|
+
`Fix: chmod 600 ${path}`);
|
|
29
|
+
}
|
|
22
30
|
export function validateAccountTokenSources(account) {
|
|
23
31
|
const sources = [];
|
|
24
32
|
if (account.native)
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
|
|
2
2
|
import { resolvePluginConfigObject, resolveLivePluginConfigObject, } from "openclaw/plugin-sdk/plugin-config-runtime";
|
|
3
3
|
import { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, } from "openclaw/plugin-sdk/cli-backend";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeModelId, resolveModelSpec, } from "./models.js";
|
|
@@ -14,7 +14,7 @@ import { decideStickySelection } from "./sticky.js";
|
|
|
14
14
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
15
15
|
import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
|
|
16
16
|
import { addAlert, clearAlert, pendingAlertText } from "./alerts.js";
|
|
17
|
-
import { buildAccountChildEnv, validateAccountTokenSources } from "./account-env.js";
|
|
17
|
+
import { buildAccountChildEnv, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
18
18
|
import { checkAccountCredential, createRefProbeTracker, } from "./login-health.js";
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
20
|
const BASE_ARGS = [
|
|
@@ -164,11 +164,26 @@ function startLoginHealthProbe(accounts, logger) {
|
|
|
164
164
|
loginProbeTimer = setInterval(() => void probe().catch(() => { }), LOGIN_PROBE_INTERVAL_MS);
|
|
165
165
|
loginProbeTimer.unref?.();
|
|
166
166
|
}
|
|
167
|
+
const warnedTokenFileModes = new Set();
|
|
168
|
+
function warnIfTokenFileExposed(path) {
|
|
169
|
+
if (warnedTokenFileModes.has(path))
|
|
170
|
+
return;
|
|
171
|
+
warnedTokenFileModes.add(path);
|
|
172
|
+
try {
|
|
173
|
+
const warning = tokenFileModeWarning(path, statSync(path).mode);
|
|
174
|
+
if (warning)
|
|
175
|
+
console.warn(`[multi-clawd] ${warning}`);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
}
|
|
179
|
+
}
|
|
167
180
|
function peekToken(account) {
|
|
168
181
|
if (account.native)
|
|
169
182
|
return undefined;
|
|
170
183
|
if (account.oauthTokenFile) {
|
|
171
|
-
|
|
184
|
+
const path = expandHome(account.oauthTokenFile);
|
|
185
|
+
warnIfTokenFileExposed(path);
|
|
186
|
+
return readFileSync(path, "utf8").trim();
|
|
172
187
|
}
|
|
173
188
|
if (isSecretRefShape(account.oauthTokenRef)) {
|
|
174
189
|
return activeTokenResolver?.peek(account.oauthTokenRef);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.3",
|
|
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.5.
|
|
3
|
+
"version": "1.5.3",
|
|
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"scripts",
|
|
47
47
|
"openclaw.plugin.json",
|
|
48
48
|
"README.md",
|
|
49
|
+
"SECURITY.md",
|
|
49
50
|
"LICENSE"
|
|
50
51
|
],
|
|
51
52
|
"scripts": {
|
|
@@ -57,7 +58,8 @@
|
|
|
57
58
|
"doctor": "node scripts/doctor.mjs",
|
|
58
59
|
"setup": "node scripts/setup.mjs",
|
|
59
60
|
"sync-manifest": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('openclaw.plugin.json','utf8'));m.version=process.env.npm_package_version||JSON.parse(fs.readFileSync('package.json','utf8')).version;fs.writeFileSync('openclaw.plugin.json',JSON.stringify(m,null,2)+'\\n')\"",
|
|
60
|
-
"
|
|
61
|
+
"sync-readme-tag": "node scripts/sync-readme-tag.mjs",
|
|
62
|
+
"version": "npm run sync-manifest && npm run sync-readme-tag && git add openclaw.plugin.json README.md"
|
|
61
63
|
},
|
|
62
64
|
"peerDependencies": {
|
|
63
65
|
"openclaw": ">=2026.6"
|
package/scripts/cli.mjs
CHANGED
|
@@ -185,11 +185,11 @@ async function healWatchdogUnit() {
|
|
|
185
185
|
refreshLauncher();
|
|
186
186
|
writeFileSync(file, text.split(target).join(WATCHDOG_LAUNCHER));
|
|
187
187
|
if (d.endsWith("LaunchAgents")) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
188
|
+
// Direct spawns, not a `sh -c` string: no quoting to get wrong and
|
|
189
|
+
// no shell for a path to break out of. Both calls are best-effort —
|
|
190
|
+
// unload fails harmlessly when nothing is loaded yet.
|
|
191
|
+
spawnSync("launchctl", ["unload", file], { stdio: "ignore" });
|
|
192
|
+
spawnSync("launchctl", ["load", file], { stdio: "ignore" });
|
|
193
193
|
} else {
|
|
194
194
|
try {
|
|
195
195
|
execFileSync("systemctl", ["--user", "daemon-reload"]);
|
package/scripts/setup.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, readd
|
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { join, dirname, resolve } from "node:path";
|
|
28
28
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
30
30
|
import readline from "node:readline/promises";
|
|
31
31
|
|
|
32
32
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -369,12 +369,16 @@ function reloadWatchdogUnit(platform, file) {
|
|
|
369
369
|
/* was not loaded */
|
|
370
370
|
}
|
|
371
371
|
try {
|
|
372
|
-
// launchctl can print "Load failed" to stderr and still exit 0
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
});
|
|
377
|
-
if (
|
|
372
|
+
// launchctl can print "Load failed" to stderr and still exit 0, so we
|
|
373
|
+
// inspect BOTH streams rather than trusting the exit code. spawnSync
|
|
374
|
+
// (not a `/bin/sh -c` string) keeps this off the shell entirely: no
|
|
375
|
+
// quoting to get wrong, and nothing for a path to break out of.
|
|
376
|
+
const r = spawnSync("launchctl", ["load", file], { encoding: "utf8" });
|
|
377
|
+
if (r.error) throw r.error;
|
|
378
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
379
|
+
if (r.status !== 0 || /load failed|bootstrap failed/i.test(out)) {
|
|
380
|
+
throw new Error(out.trim() || `launchctl exited ${r.status}`);
|
|
381
|
+
}
|
|
378
382
|
console.log(" ✅ launchd agent (re)loaded");
|
|
379
383
|
} catch {
|
|
380
384
|
console.log(` ⚠ plist written but load failed — run: launchctl load ${file}`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the README's pinned SETUP-AGENT.md URL on the current release tag.
|
|
3
|
+
*
|
|
4
|
+
* The agent-install one-liner points people's assistants at a raw GitHub URL,
|
|
5
|
+
* and that URL is what they will actually EXECUTE. Pinning it to a tag makes
|
|
6
|
+
* those instructions immutable per release — but a hand-maintained version
|
|
7
|
+
* number in prose drifts (the openclaw.plugin.json version did exactly that,
|
|
8
|
+
* twice), so the `version` npm hook rewrites it here instead.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
const readmePath = join(root, "README.md");
|
|
16
|
+
|
|
17
|
+
// npm sets npm_package_version during the version lifecycle; fall back to the
|
|
18
|
+
// file for direct invocations.
|
|
19
|
+
const version =
|
|
20
|
+
process.env.npm_package_version ||
|
|
21
|
+
JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
22
|
+
|
|
23
|
+
const PATTERN =
|
|
24
|
+
/(raw\.githubusercontent\.com\/Drakon-Systems-Ltd\/multi-clawd\/)(?:master|v[\d.]+)(\/SETUP-AGENT\.md)/g;
|
|
25
|
+
|
|
26
|
+
const readme = readFileSync(readmePath, "utf8");
|
|
27
|
+
const updated = readme.replace(PATTERN, `$1v${version}$2`);
|
|
28
|
+
|
|
29
|
+
if (updated === readme) {
|
|
30
|
+
console.log(`sync-readme-tag: already pinned to v${version}`);
|
|
31
|
+
} else {
|
|
32
|
+
writeFileSync(readmePath, updated);
|
|
33
|
+
console.log(`sync-readme-tag: pinned SETUP-AGENT.md URL to v${version}`);
|
|
34
|
+
}
|