@drakon-systems/multi-clawd 1.8.11 → 1.9.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 +76 -1
- package/SECURITY.md +1 -0
- package/dist/account-env.js +5 -0
- package/dist/chain-audit.js +9 -7
- package/dist/direct-report.js +119 -0
- package/dist/direct-route.js +156 -0
- package/dist/direct-sync.js +335 -0
- package/dist/explain-core.js +44 -2
- package/dist/index.js +102 -1
- package/dist/openclaw-runner.js +42 -0
- package/dist/session-handover.js +117 -0
- package/dist/setup-core.js +25 -0
- package/dist/shim.js +6 -0
- package/openclaw.plugin.json +66 -1
- package/package.json +1 -1
- package/scripts/cli.mjs +236 -4
- package/scripts/doctor.mjs +117 -2
- package/scripts/setup.mjs +74 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ multi-clawd setup # guided wizard: accounts, isolated second login, pool, wa
|
|
|
31
31
|
multi-clawd login claw2 # launch the right Claude sign-in for an account (or re-auth it)
|
|
32
32
|
multi-clawd explain # your whole setup in plain English — accounts, chain, live health
|
|
33
33
|
multi-clawd chain # audit your model routing — what actually serves each turn
|
|
34
|
+
multi-clawd direct # the direct anthropic/* route: status, or `direct sync` (v1.9, optional)
|
|
34
35
|
multi-clawd doctor # health check (add --probe for a live end-to-end turn)
|
|
35
36
|
```
|
|
36
37
|
|
|
@@ -374,7 +375,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
374
375
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
375
376
|
Code on the target machine already? Paste it this and go make coffee:
|
|
376
377
|
|
|
377
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.
|
|
378
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.1/SETUP-AGENT.md
|
|
378
379
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
379
380
|
> Claude account — ask me when you need me to log in.
|
|
380
381
|
|
|
@@ -594,6 +595,77 @@ Notes:
|
|
|
594
595
|
which runs on every launch on every turn path. Details in
|
|
595
596
|
[`DESIGN.md`](./DESIGN.md).
|
|
596
597
|
|
|
598
|
+
## Direct route: the pool for `anthropic/*` too (v1.9)
|
|
599
|
+
|
|
600
|
+
OpenClaw reaches Claude two ways. The pool above drives **Claude Code CLI**
|
|
601
|
+
backends (`clawd/…`). OpenClaw's own **`anthropic/*`** models skip the CLI and
|
|
602
|
+
call the Anthropic API directly, with an auth profile. A `claude setup-token`
|
|
603
|
+
works for both, and both use up the same account limits.
|
|
604
|
+
|
|
605
|
+
From v1.9 the pool can steer both. Opt accounts in with `direct`:
|
|
606
|
+
|
|
607
|
+
```jsonc
|
|
608
|
+
"accounts": [
|
|
609
|
+
// native login: give it its own setup-token for the API route
|
|
610
|
+
{ "id": "claw1", "native": true,
|
|
611
|
+
"direct": { "tokenRef": { "source": "exec", "provider": "onepassword",
|
|
612
|
+
"id": "op://YourVault/claw1-setup-token/password" } } },
|
|
613
|
+
// already has a setup-token for the CLI: reuse it
|
|
614
|
+
{ "id": "claw2", "configDir": "~/.claw2",
|
|
615
|
+
"oauthTokenRef": { "source": "exec", "provider": "onepassword",
|
|
616
|
+
"id": "op://YourVault/claw2-setup-token/password" },
|
|
617
|
+
"direct": true }
|
|
618
|
+
],
|
|
619
|
+
"directRoute": { "agents": ["main"] } // optional; default ["main"]
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Then run `multi-clawd direct sync`. It does two things:
|
|
623
|
+
|
|
624
|
+
1. **Stores one OpenClaw profile per account** (`anthropic:claw1`,
|
|
625
|
+
`anthropic:claw2`), using OpenClaw's own commands:
|
|
626
|
+
- A **secret reference** is stored as a reference (`openclaw secrets
|
|
627
|
+
apply`). OpenClaw resolves it at runtime, so the token is never copied.
|
|
628
|
+
- A **token file** is piped to `openclaw models auth paste-token` on stdin.
|
|
629
|
+
- Profiles that already exist are left alone; `--resync` rewrites them.
|
|
630
|
+
2. **Sets the `anthropic` auth order** to match pool health.
|
|
631
|
+
|
|
632
|
+
The plugin then keeps that order up to date inside the gateway, re-checking
|
|
633
|
+
every minute:
|
|
634
|
+
|
|
635
|
+
- The healthiest account goes first; a nearly-maxed one moves to the back
|
|
636
|
+
**before** it errors.
|
|
637
|
+
- The rule is the same one the CLI pool uses, and it reads the same health
|
|
638
|
+
data, so both routes lean on the same account at the same time.
|
|
639
|
+
- OpenClaw's own rotation covers the rest mid-turn: a 429 cools that profile
|
|
640
|
+
down and the turn moves to the next one.
|
|
641
|
+
|
|
642
|
+
What to know:
|
|
643
|
+
|
|
644
|
+
- **Why a native or config-dir login needs its own token.** That login is a
|
|
645
|
+
rotating one-time OAuth grant, and a copy of it breaks on the next refresh.
|
|
646
|
+
multi-clawd never reads Claude's own credential files. Run `claude
|
|
647
|
+
setup-token` signed in as that account and store the token. `setup` walks
|
|
648
|
+
you through it.
|
|
649
|
+
- **Already stored a profile yourself?** Adopt it with
|
|
650
|
+
`"direct": { "profileId": "anthropic:<id>" }`. It gets ordered, never
|
|
651
|
+
rewritten.
|
|
652
|
+
- **Existing sessions stay on their account.** OpenClaw pins a profile to
|
|
653
|
+
each session. A new order applies to new sessions, and to a session whose
|
|
654
|
+
profile cools down. A pinned session moves when its account actually hits
|
|
655
|
+
its limit, and the next account in line is by then the healthiest.
|
|
656
|
+
- **Your own profiles are kept.** Anything in the `anthropic` order that
|
|
657
|
+
multi-clawd doesn't manage stays there, after the pool's accounts.
|
|
658
|
+
`"directRoute": { "manageOrder": false }` hands the order back to you.
|
|
659
|
+
- **Health comes from CLI turns only.** Traffic that only uses the API route
|
|
660
|
+
doesn't update it. With no recent CLI turns an account counts as healthy,
|
|
661
|
+
and OpenClaw's reactive rotation does the work.
|
|
662
|
+
- **Nothing changes without `direct`.** No timer, no OpenClaw calls, and the
|
|
663
|
+
same `explain`/`chain`/`doctor` output as before.
|
|
664
|
+
|
|
665
|
+
`multi-clawd direct` shows, per account: the profile, whether it's stored,
|
|
666
|
+
any cooldown, and the live order. `explain` shows the same, and `doctor
|
|
667
|
+
--probe` makes one tiny live call per profile.
|
|
668
|
+
|
|
597
669
|
## How it works
|
|
598
670
|
|
|
599
671
|
Three moves, all through the official plugin SDK (details in
|
|
@@ -737,6 +809,9 @@ Early but real — built for and dogfooded in production.
|
|
|
737
809
|
for each account shape, verified afterwards (signed-in email shown, token
|
|
738
810
|
values never touched); ClawHub package published under
|
|
739
811
|
`@drakon-systems` ✅
|
|
812
|
+
- **v1.9** — one pool, both transports: accounts can also serve OpenClaw's
|
|
813
|
+
direct `anthropic/*` route; the gateway keeps the `anthropic` auth order in
|
|
814
|
+
pool-health order; `multi-clawd direct` / `direct sync` ✅
|
|
740
815
|
- **Next** — standalone localhost proxy (OpenAI-compatible) so Hermes and
|
|
741
816
|
custom runtimes can share the pool; true per-session affinity; local
|
|
742
817
|
five-hour-window signal (turn counting); per-account lock for the shim
|
package/SECURITY.md
CHANGED
|
@@ -57,6 +57,7 @@ is listed here so you can check them yourself.
|
|
|
57
57
|
| `dist/watchdog-schedule.js` | `node <bundled script>` | Runs the eviction watchdog on a timer. Path is the package's own script. |
|
|
58
58
|
| `scripts/cli.mjs` | `node <bundled script>` | The CLI dispatching to its own subcommands. |
|
|
59
59
|
| `scripts/setup.mjs` | `launchctl` / `systemctl` | Loads the watchdog timer during setup on macOS/Linux. |
|
|
60
|
+
| `dist/openclaw-runner.js` | the `openclaw` CLI (or `directRoute.openclawCommand`) | Direct route only (v1.9, accounts with `direct`): reads and writes the `anthropic` auth profiles and order through OpenClaw's own subcommands (`models auth list`/`order get`/`order set`/`paste-token`, `secrets apply`, `models status`). A setup-token only ever travels on the child's stdin. Never spawned without `direct` configured. |
|
|
60
61
|
|
|
61
62
|
None of these pass a string to a shell — they are direct process spawns with
|
|
62
63
|
argument arrays, so there is no quoting or injection surface. Paths are
|
package/dist/account-env.js
CHANGED
|
@@ -47,3 +47,8 @@ export function validateAccountTokenSources(account) {
|
|
|
47
47
|
`account "${account.id}" declares ${sources.join(" + ")} — token sources are mutually exclusive; precedence applied is ${sources.includes("native") ? "native" : "oauthTokenFile"} first. Remove the extras.`,
|
|
48
48
|
];
|
|
49
49
|
}
|
|
50
|
+
export function accountConfigDir(account) {
|
|
51
|
+
if (!account.native && account.configDir)
|
|
52
|
+
return expandHomePath(account.configDir);
|
|
53
|
+
return resolve(homedir(), ".claude");
|
|
54
|
+
}
|
package/dist/chain-audit.js
CHANGED
|
@@ -27,7 +27,7 @@ function parseRef(ref) {
|
|
|
27
27
|
return { modelId: ref };
|
|
28
28
|
return { provider: ref.slice(0, idx).trim().toLowerCase(), modelId: ref.slice(idx + 1) };
|
|
29
29
|
}
|
|
30
|
-
export function offPoolClaudeRef(ref, poolId) {
|
|
30
|
+
export function offPoolClaudeRef(ref, poolId, opts = {}) {
|
|
31
31
|
if (typeof ref !== "string" || ref.length === 0)
|
|
32
32
|
return null;
|
|
33
33
|
const { provider, modelId } = parseRef(ref);
|
|
@@ -37,14 +37,16 @@ export function offPoolClaudeRef(ref, poolId) {
|
|
|
37
37
|
return null;
|
|
38
38
|
if (provider === poolId.trim().toLowerCase())
|
|
39
39
|
return null;
|
|
40
|
+
if (provider === "anthropic" && opts.directPooled)
|
|
41
|
+
return null;
|
|
40
42
|
if (provider === "anthropic" || provider === "claude-cli")
|
|
41
43
|
return "strong";
|
|
42
44
|
if (ACCOUNT_PIN_RE.test(provider))
|
|
43
45
|
return "warn";
|
|
44
46
|
return null;
|
|
45
47
|
}
|
|
46
|
-
function classifyBypass(ref, poolId) {
|
|
47
|
-
const severity = offPoolClaudeRef(ref, poolId);
|
|
48
|
+
function classifyBypass(ref, poolId, opts) {
|
|
49
|
+
const severity = offPoolClaudeRef(ref, poolId, opts);
|
|
48
50
|
if (!severity)
|
|
49
51
|
return null;
|
|
50
52
|
const { provider } = parseRef(ref);
|
|
@@ -170,12 +172,12 @@ export function collectChainRefs(config) {
|
|
|
170
172
|
}
|
|
171
173
|
return out;
|
|
172
174
|
}
|
|
173
|
-
export function auditEffectiveChain(config, poolId) {
|
|
175
|
+
export function auditEffectiveChain(config, poolId, opts = {}) {
|
|
174
176
|
if (!poolId)
|
|
175
177
|
return [];
|
|
176
178
|
const findings = [];
|
|
177
179
|
for (const { surface, ref, allowlist } of collectChainRefs(config)) {
|
|
178
|
-
const reason = classifyBypass(ref, poolId);
|
|
180
|
+
const reason = classifyBypass(ref, poolId, opts);
|
|
179
181
|
if (!reason)
|
|
180
182
|
continue;
|
|
181
183
|
findings.push({ surface, ref, severity: allowlist ? "note" : "warn", reason });
|
|
@@ -257,7 +259,7 @@ export function auditChainShadowing(config) {
|
|
|
257
259
|
return findings;
|
|
258
260
|
}
|
|
259
261
|
const POOL_PROVIDER = "clawd";
|
|
260
|
-
export function auditSessionOverrides(sessions, poolConfigured) {
|
|
262
|
+
export function auditSessionOverrides(sessions, poolConfigured, opts = {}) {
|
|
261
263
|
if (!poolConfigured)
|
|
262
264
|
return [];
|
|
263
265
|
const findings = [];
|
|
@@ -282,7 +284,7 @@ export function auditSessionOverrides(sessions, poolConfigured) {
|
|
|
282
284
|
continue;
|
|
283
285
|
}
|
|
284
286
|
const ref = `${provider}/${model}`;
|
|
285
|
-
const severity = offPoolClaudeRef(ref, POOL_PROVIDER);
|
|
287
|
+
const severity = offPoolClaudeRef(ref, POOL_PROVIDER, opts);
|
|
286
288
|
if (!severity)
|
|
287
289
|
continue;
|
|
288
290
|
const reason = severity === "strong"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { classifyAccountHealth } from "./health.js";
|
|
2
|
+
import { DIRECT_PROVIDER, collectDirectMembers, } from "./direct-route.js";
|
|
3
|
+
import { parseUnusableProfiles, planAgentOrder, readDirectRouteSnapshot, safeCliError, } from "./direct-sync.js";
|
|
4
|
+
export function describeDirectSource(member) {
|
|
5
|
+
const s = member.source;
|
|
6
|
+
switch (s.kind) {
|
|
7
|
+
case "ref":
|
|
8
|
+
return `setup-token via ${s.ref.provider || "a secret provider"} secret reference${s.reused ? " (the same one its CLI login uses)" : ""}`;
|
|
9
|
+
case "file":
|
|
10
|
+
return `setup-token file ${s.path}${s.reused ? " (the same one its CLI login uses)" : ""}`;
|
|
11
|
+
case "existing":
|
|
12
|
+
return "adopted profile you stored yourself";
|
|
13
|
+
default:
|
|
14
|
+
return "no direct credential";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function gatherDirectStatus(params) {
|
|
18
|
+
const { members, problems } = collectDirectMembers(params.accounts, params.poolAccounts ?? []);
|
|
19
|
+
if (members.length === 0 && problems.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const errors = [];
|
|
22
|
+
const verdicts = members.map((m) => ({
|
|
23
|
+
accountId: m.accountId,
|
|
24
|
+
profileId: m.profileId,
|
|
25
|
+
verdict: classifyAccountHealth(params.readHealth(m.accountId), params.healthOptions, params.nowMs).verdict,
|
|
26
|
+
}));
|
|
27
|
+
const read = members.length > 0 ? await readDirectRouteSnapshot(params.runner, params.agentId) : {};
|
|
28
|
+
if ("error" in read && read.error)
|
|
29
|
+
errors.push(read.error);
|
|
30
|
+
const snapshot = "snapshot" in read ? read.snapshot : undefined;
|
|
31
|
+
let unusable = [];
|
|
32
|
+
if (members.length > 0 && !params.skipCooldowns) {
|
|
33
|
+
const status = await params.runner(["models", "status", "--agent", params.agentId, "--json"], { timeoutMs: 120_000 });
|
|
34
|
+
const parsed = status.code === 0 ? parseUnusableProfiles(status.stdout) : undefined;
|
|
35
|
+
if (parsed)
|
|
36
|
+
unusable = parsed;
|
|
37
|
+
else
|
|
38
|
+
errors.push(`models status ${safeCliError(status)}`);
|
|
39
|
+
}
|
|
40
|
+
let pendingOrder;
|
|
41
|
+
if (snapshot) {
|
|
42
|
+
pendingOrder = planAgentOrder({
|
|
43
|
+
members: verdicts,
|
|
44
|
+
snapshot,
|
|
45
|
+
configOrder: params.configOrder,
|
|
46
|
+
sticky: params.sticky,
|
|
47
|
+
nowMs: params.nowMs,
|
|
48
|
+
minDwellMs: params.minDwellMs,
|
|
49
|
+
}).order;
|
|
50
|
+
}
|
|
51
|
+
const byProfile = new Map(unusable.map((u) => [u.profileId, u]));
|
|
52
|
+
const order = snapshot?.storedOrder ?? (params.configOrder?.length ? [...params.configOrder] : snapshot ? [] : undefined);
|
|
53
|
+
const orderSource = snapshot?.storedOrder
|
|
54
|
+
? `stored for agent ${params.agentId}`
|
|
55
|
+
: params.configOrder?.length
|
|
56
|
+
? "config auth.order"
|
|
57
|
+
: undefined;
|
|
58
|
+
return {
|
|
59
|
+
agentId: params.agentId,
|
|
60
|
+
verdicts,
|
|
61
|
+
pendingOrder,
|
|
62
|
+
unusable,
|
|
63
|
+
errors,
|
|
64
|
+
explain: {
|
|
65
|
+
members: members.map((m) => {
|
|
66
|
+
const u = byProfile.get(m.profileId);
|
|
67
|
+
return {
|
|
68
|
+
accountId: m.accountId,
|
|
69
|
+
profileId: m.profileId,
|
|
70
|
+
source: describeDirectSource(m),
|
|
71
|
+
stored: snapshot ? snapshot.stored.has(m.profileId) : undefined,
|
|
72
|
+
...(m.source.kind === "existing" ? { adopted: true } : {}),
|
|
73
|
+
...(u ? { cooldownUntil: u.until, cooldownReason: u.reason ?? u.kind } : {}),
|
|
74
|
+
};
|
|
75
|
+
}),
|
|
76
|
+
problems: problems.map((p) => ({ accountId: p.accountId, reason: p.reason })),
|
|
77
|
+
order,
|
|
78
|
+
orderSource,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function parseProbeResults(stdout) {
|
|
83
|
+
const start = stdout.indexOf("{");
|
|
84
|
+
if (start < 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
let doc;
|
|
87
|
+
try {
|
|
88
|
+
doc = JSON.parse(stdout.slice(start));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const rows = doc?.auth?.probes?.results;
|
|
94
|
+
if (!Array.isArray(rows))
|
|
95
|
+
return undefined;
|
|
96
|
+
return rows
|
|
97
|
+
.filter((r) => typeof r?.profileId === "string" && (r.provider === undefined || r.provider === DIRECT_PROVIDER))
|
|
98
|
+
.map((r) => ({
|
|
99
|
+
profileId: r.profileId,
|
|
100
|
+
status: typeof r.status === "string" ? r.status : "unknown",
|
|
101
|
+
...(typeof r.error === "string" ? { error: r.error.replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-…").slice(0, 200) } : {}),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
export function probeArgs(agentId, profileIds) {
|
|
105
|
+
return [
|
|
106
|
+
"models",
|
|
107
|
+
"status",
|
|
108
|
+
"--agent",
|
|
109
|
+
agentId,
|
|
110
|
+
"--json",
|
|
111
|
+
"--probe",
|
|
112
|
+
"--probe-provider",
|
|
113
|
+
DIRECT_PROVIDER,
|
|
114
|
+
"--probe-profile",
|
|
115
|
+
profileIds.join(","),
|
|
116
|
+
"--probe-max-tokens",
|
|
117
|
+
"16",
|
|
118
|
+
];
|
|
119
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { isSecretRefShape } from "./token-resolution.js";
|
|
2
|
+
import { decideStickySelection } from "./sticky.js";
|
|
3
|
+
export const DIRECT_PROVIDER = "anthropic";
|
|
4
|
+
export const DIRECT_PROFILE_PREFIX = `${DIRECT_PROVIDER}:`;
|
|
5
|
+
export const DIRECT_SETUP_TOKEN_GUIDANCE = "a native or config-dir Claude login is a rotating single-use OAuth grant — copying it into " +
|
|
6
|
+
"OpenClaw would invalidate one of the two copies on the next refresh. Run `claude setup-token` " +
|
|
7
|
+
"signed in as THIS account, store the printed token in your secret manager (or a 0600 file), " +
|
|
8
|
+
"and set direct.tokenRef (or direct.tokenFile) on the account — or, if a profile for this " +
|
|
9
|
+
'account is already stored in OpenClaw, name it with direct.profileId';
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
12
|
+
? value
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
export function directEnabled(account) {
|
|
16
|
+
const d = account.direct;
|
|
17
|
+
if (d === undefined || d === false)
|
|
18
|
+
return false;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
export function directCredentialSource(account) {
|
|
22
|
+
if (!directEnabled(account))
|
|
23
|
+
return { kind: "none" };
|
|
24
|
+
const explicit = asRecord(account.direct);
|
|
25
|
+
const explicitRef = explicit?.tokenRef;
|
|
26
|
+
const explicitFile = typeof explicit?.tokenFile === "string" ? explicit.tokenFile.trim() : "";
|
|
27
|
+
if (explicitRef !== undefined && explicitFile) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "unsupported",
|
|
30
|
+
code: "direct_sources_conflict",
|
|
31
|
+
reason: "direct.tokenRef and direct.tokenFile are mutually exclusive — keep one",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (explicitRef !== undefined) {
|
|
35
|
+
if (!isSecretRefShape(explicitRef)) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "unsupported",
|
|
38
|
+
code: "direct_ref_malformed",
|
|
39
|
+
reason: 'direct.tokenRef must be { "source": "...", "provider": "...", "id": "..." } like every other secret reference',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { kind: "ref", ref: explicitRef, reused: false };
|
|
43
|
+
}
|
|
44
|
+
if (explicitFile)
|
|
45
|
+
return { kind: "file", path: explicitFile, reused: false };
|
|
46
|
+
if (typeof explicit?.profileId === "string" && explicit.profileId.trim()) {
|
|
47
|
+
return { kind: "existing" };
|
|
48
|
+
}
|
|
49
|
+
if (!account.native) {
|
|
50
|
+
if (account.oauthTokenFile) {
|
|
51
|
+
return { kind: "file", path: account.oauthTokenFile, reused: true };
|
|
52
|
+
}
|
|
53
|
+
if (isSecretRefShape(account.oauthTokenRef)) {
|
|
54
|
+
return { kind: "ref", ref: account.oauthTokenRef, reused: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
kind: "unsupported",
|
|
59
|
+
code: "direct_setup_token_required",
|
|
60
|
+
reason: DIRECT_SETUP_TOKEN_GUIDANCE,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const PROFILE_SUFFIX_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
|
|
64
|
+
export function directProfileId(account) {
|
|
65
|
+
const explicit = asRecord(account.direct)?.profileId;
|
|
66
|
+
if (typeof explicit === "string" && explicit.trim()) {
|
|
67
|
+
const id = explicit.trim();
|
|
68
|
+
if (!id.startsWith(DIRECT_PROFILE_PREFIX) || !PROFILE_SUFFIX_RE.test(id.slice(DIRECT_PROFILE_PREFIX.length))) {
|
|
69
|
+
throw new Error(`account "${account.id}": direct.profileId must look like "${DIRECT_PROFILE_PREFIX}<name>" (got "${id}")`);
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
}
|
|
73
|
+
const suffix = account.id.trim();
|
|
74
|
+
if (!PROFILE_SUFFIX_RE.test(suffix)) {
|
|
75
|
+
throw new Error(`account "${account.id}" cannot become an auth profile id — set direct.profileId`);
|
|
76
|
+
}
|
|
77
|
+
return `${DIRECT_PROFILE_PREFIX}${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
export function collectDirectMembers(accounts, poolOrder = []) {
|
|
80
|
+
const ordered = [];
|
|
81
|
+
const byId = new Map(accounts.filter((a) => a?.id).map((a) => [a.id.trim(), a]));
|
|
82
|
+
for (const id of poolOrder) {
|
|
83
|
+
const a = byId.get(id);
|
|
84
|
+
if (a && !ordered.includes(a))
|
|
85
|
+
ordered.push(a);
|
|
86
|
+
}
|
|
87
|
+
for (const a of byId.values())
|
|
88
|
+
if (!ordered.includes(a))
|
|
89
|
+
ordered.push(a);
|
|
90
|
+
const members = [];
|
|
91
|
+
const problems = [];
|
|
92
|
+
const seenProfiles = new Set();
|
|
93
|
+
for (const account of ordered) {
|
|
94
|
+
const source = directCredentialSource(account);
|
|
95
|
+
if (source.kind === "none")
|
|
96
|
+
continue;
|
|
97
|
+
if (source.kind === "unsupported") {
|
|
98
|
+
problems.push({ accountId: account.id, code: source.code, reason: source.reason });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
let profileId;
|
|
102
|
+
try {
|
|
103
|
+
profileId = directProfileId(account);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
problems.push({ accountId: account.id, code: "direct_profile_invalid", reason: err.message });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (seenProfiles.has(profileId)) {
|
|
110
|
+
problems.push({
|
|
111
|
+
accountId: account.id,
|
|
112
|
+
code: "direct_profile_duplicate",
|
|
113
|
+
reason: `profile ${profileId} is already used by another account`,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
seenProfiles.add(profileId);
|
|
118
|
+
members.push({ accountId: account.id, profileId, source });
|
|
119
|
+
}
|
|
120
|
+
return { members, problems };
|
|
121
|
+
}
|
|
122
|
+
const VERDICT_RANK = {
|
|
123
|
+
ok: 0,
|
|
124
|
+
no_data: 0,
|
|
125
|
+
near_limit: 1,
|
|
126
|
+
exhausted: 2,
|
|
127
|
+
credential_failed: 3,
|
|
128
|
+
};
|
|
129
|
+
export function planDirectOrder(params) {
|
|
130
|
+
const { members } = params;
|
|
131
|
+
if (members.length === 0)
|
|
132
|
+
return undefined;
|
|
133
|
+
const decision = decideStickySelection({
|
|
134
|
+
verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict })),
|
|
135
|
+
sticky: params.sticky,
|
|
136
|
+
nowMs: params.nowMs,
|
|
137
|
+
minDwellMs: params.minDwellMs,
|
|
138
|
+
});
|
|
139
|
+
const first = members.find((m) => m.accountId === decision.account) ?? members[0];
|
|
140
|
+
const rest = members
|
|
141
|
+
.filter((m) => m !== first)
|
|
142
|
+
.map((m, index) => ({ m, index }))
|
|
143
|
+
.sort((a, b) => VERDICT_RANK[a.m.verdict] - VERDICT_RANK[b.m.verdict] || a.index - b.index)
|
|
144
|
+
.map(({ m }) => m.profileId);
|
|
145
|
+
const managed = new Set(members.map((m) => m.profileId));
|
|
146
|
+
const unmanaged = (params.currentOrder ?? []).filter((id, i, all) => typeof id === "string" && !managed.has(id) && all.indexOf(id) === i);
|
|
147
|
+
const order = [first.profileId, ...rest, ...unmanaged];
|
|
148
|
+
const current = params.currentOrder ?? [];
|
|
149
|
+
const changed = order.length !== current.length || order.some((id, i) => id !== current[i]);
|
|
150
|
+
return { order, firstAccount: first.accountId, sticky: decision.sticky, changed };
|
|
151
|
+
}
|
|
152
|
+
export function directRoutePools(accounts, directRoute) {
|
|
153
|
+
if (directRoute?.manageOrder === false)
|
|
154
|
+
return false;
|
|
155
|
+
return collectDirectMembers(accounts).members.length >= 2;
|
|
156
|
+
}
|