@byokit/openclaw 0.9.0 → 0.11.0

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/dist/locks.js ADDED
@@ -0,0 +1,85 @@
1
+ export function createLocks() {
2
+ const slots = new Map();
3
+ const slot = (id) => {
4
+ let s = slots.get(id);
5
+ if (!s) {
6
+ s = { readers: 0, writer: false, queue: [] };
7
+ slots.set(id, s);
8
+ }
9
+ return s;
10
+ };
11
+ // FIFO: an exclusive waiter blocks later shared waiters until the readers ahead of it drain.
12
+ const pump = (s) => {
13
+ if (s.writer)
14
+ return;
15
+ const head = s.queue[0];
16
+ if (!head)
17
+ return;
18
+ if (head.mode === 'exclusive') {
19
+ if (s.readers === 0) {
20
+ s.queue.shift();
21
+ s.writer = true;
22
+ head.grant();
23
+ }
24
+ return;
25
+ }
26
+ while (s.queue[0]?.mode === 'shared') {
27
+ const w = s.queue.shift();
28
+ s.readers++;
29
+ w.grant();
30
+ }
31
+ };
32
+ const acquire = (id, mode) => {
33
+ const s = slot(id);
34
+ if (mode === 'shared' && !s.writer && s.queue.every((w) => w.mode === 'shared')) {
35
+ s.readers++;
36
+ return Promise.resolve();
37
+ }
38
+ if (mode === 'exclusive' && !s.writer && s.readers === 0 && s.queue.length === 0) {
39
+ s.writer = true;
40
+ return Promise.resolve();
41
+ }
42
+ return new Promise((grant) => {
43
+ s.queue.push(mode === 'exclusive' ? { mode, grant: () => { s.writer = true; grant(); } } : { mode, grant });
44
+ pump(s);
45
+ });
46
+ };
47
+ const release = (id, mode) => {
48
+ const s = slot(id);
49
+ if (mode === 'shared')
50
+ s.readers = Math.max(0, s.readers - 1);
51
+ else
52
+ s.writer = false;
53
+ pump(s);
54
+ if (s.readers === 0 && !s.writer && s.queue.length === 0)
55
+ slots.delete(id);
56
+ };
57
+ return {
58
+ async shared(agentId, work) {
59
+ await acquire(agentId, 'shared');
60
+ try {
61
+ return await work();
62
+ }
63
+ finally {
64
+ release(agentId, 'shared');
65
+ }
66
+ },
67
+ async exclusive(agentIds, work) {
68
+ // Sorted so two exclusive takes over overlapping sets cannot deadlock each other.
69
+ const ids = [...new Set(agentIds)].sort();
70
+ for (const id of ids)
71
+ await acquire(id, 'exclusive');
72
+ try {
73
+ return await work();
74
+ }
75
+ finally {
76
+ for (const id of [...ids].reverse())
77
+ release(id, 'exclusive');
78
+ }
79
+ },
80
+ live(agentId) {
81
+ const s = slots.get(agentId);
82
+ return !!s && (s.readers > 0 || s.writer);
83
+ },
84
+ };
85
+ }
package/dist/members.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { GatewayTransport, Member } from './types.ts';
2
2
  export declare const MEMBER_ID: RegExp;
3
3
  export declare const KEY_PREFIX = "byokit-key-";
4
+ export declare const BASE_AGENT = "byokit-base";
4
5
  export declare const keyAgentId: (member: Member) => string;
5
6
  export declare function createMembers(ctx: {
6
7
  request: GatewayTransport['request'];
package/dist/members.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { join } from 'node:path';
2
+ // Any member that may run: D9's older rule, which a member created before O14 still matches (D17).
2
3
  export const MEMBER_ID = /^[a-z][a-z0-9-]{0,31}$/;
4
+ // D9 for a member the kit creates: no `--` (account agents are `<member>--<6 hex>`) and at most 24 characters.
5
+ const NEW_MEMBER_ID = /^[a-z](?!.*--)[a-z0-9-]{0,23}$/;
3
6
  export const KEY_PREFIX = 'byokit-key-';
7
+ // The inherited auth base every agent reads through (5.15): the kit never creates, signs in to or runs it.
8
+ export const BASE_AGENT = 'byokit-base';
9
+ // The engine's own agents (`main` exists on every install) and the base are never a member, existing or not.
10
+ const RESERVED = new Set(['main', 'openclaw', 'crestodian', BASE_AGENT]);
4
11
  export const keyAgentId = (member) => `${KEY_PREFIX}${member}`;
5
12
  // The engine answers agents.create before its roster hot-reload applies (up to ~113 ms observed), so a turn sent
6
13
  // at once fails as 'unknown agent id'. ensure resolves only once agents.list shows the member.
@@ -10,14 +17,17 @@ export function createMembers(ctx) {
10
17
  const cache = new Map();
11
18
  const listed = (member) => ctx.request('agents.list').then((list) => list.agents.some((agent) => agent.id === member));
12
19
  return { ensure(member) {
13
- if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
14
- return Promise.reject(new Error(`invalid member id: ${member}`));
20
+ const invalid = () => new Error(`invalid member id: ${member}`);
21
+ if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX) || RESERVED.has(member))
22
+ return Promise.reject(invalid());
15
23
  const existing = cache.get(member);
16
24
  if (existing)
17
25
  return existing;
18
26
  const workspace = join(ctx.root, 'workspaces', member);
19
27
  const result = (async () => {
20
28
  if (!await listed(member)) {
29
+ if (!NEW_MEMBER_ID.test(member) || member.startsWith('byokit-'))
30
+ throw invalid();
21
31
  await ctx.request('agents.create', { name: member, workspace });
22
32
  const deadline = Date.now() + VISIBLE_TIMEOUT_MS;
23
33
  while (!await listed(member)) {
package/dist/pick.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Account, AccountId, AccountPick, Considered, Defaults, ModelInfo, PickWhy, Room, RunSelection } from './types.ts';
2
+ export type RoomOf = (a: Account, demand: readonly string[]) => Room;
3
+ /** Every account's `Considered` row, in list order, with the kit-only `bound` applied. */
4
+ export declare function consider(accounts: readonly Account[], defaults: Defaults, sel: RunSelection, room: RoomOf, nowMs: number, models?: (a: Account) => readonly ModelInfo[], bound?: readonly AccountId[]): Considered[];
5
+ /** Auto's winner only; `candidates` are the eligible accounts. Pure and portable; no timers, reads or mid-run switching. */
6
+ export declare function chooseAccount(candidates: readonly Account[], room: (a: Account) => Room, nowMs: number): {
7
+ account: Account;
8
+ why: PickWhy;
9
+ } | undefined;
10
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
11
+ export declare function resolveSelection(accounts: readonly Account[], defaults: Defaults, sel: RunSelection, room: RoomOf, nowMs: number, models?: (a: Account) => readonly ModelInfo[], bound?: readonly AccountId[]): AccountPick;
package/dist/pick.js ADDED
@@ -0,0 +1,145 @@
1
+ import { words } from "./words.js";
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
4
+ const reset = (r) => r.resetsAt ?? Infinity;
5
+ const roomWords = (left, span) => words(`room.${span}`, { left: `${Math.round(left)}%` });
6
+ // "3:40pm", or "Fri 3:40pm" when it isn't today (matches @byokit/accounts' clock).
7
+ const clock = (t) => (new Date(t).toDateString() === new Date().toDateString() ? '' : new Date(t).toLocaleDateString('en-US', { weekday: 'short' }) + ' ') +
8
+ new Date(t).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }).toLowerCase();
9
+ // One shared eligibility predicate for direct picks, Auto and every explanation row. Room is read once per row.
10
+ // `bound` (kit only) is the accounts a conversation may use; an account outside it is never a candidate, even when
11
+ // the selection names it, and its row carries `out: 'bound'`. An account the selection names is judged only on
12
+ // `bound` and the demand, so an API-key or resting account it names carries no `billing`/`resting` `out`.
13
+ function considerRows(accounts, room, nowMs, demand = [], models, provider, named, bound) {
14
+ return accounts.map((account) => {
15
+ const r = room(account);
16
+ const age = finite(r.at) ? Math.max(0, nowMs - r.at) : 'unknown';
17
+ const stale = typeof age === 'number' && age > DAY_MS;
18
+ const left = r.left === 'unknown' || !finite(r.left) || stale ? 'unknown' : Math.max(0, Math.min(100, r.left));
19
+ const row = { id: account.id, left, age,
20
+ confidence: stale ? 'stale' : left === 'unknown' || age === 'unknown' ? 'unknown' : 'known', reason: 'no_reading' };
21
+ if (r.left !== 'unknown') {
22
+ row.span = r.span;
23
+ if (finite(r.resetsAt))
24
+ row.resetsAt = r.resetsAt;
25
+ }
26
+ if (bound !== undefined && !bound.includes(account.id))
27
+ row.out = 'bound';
28
+ if (!row.out && account.id !== named) {
29
+ if (account.state === 'resting' && finite(account.until) && account.until > nowMs) {
30
+ row.out = 'resting';
31
+ row.until = account.until;
32
+ }
33
+ else if (account.state !== 'ready' && !(account.state === 'resting' && finite(account.until) && account.until <= nowMs))
34
+ row.out = 'state';
35
+ else if (account.billing !== 'subscription')
36
+ row.out = 'billing';
37
+ }
38
+ if (!row.out && demand.length && models) {
39
+ const available = models(account);
40
+ const missing = demand.find((id) => !available.some((m) => m.id === id && m.available));
41
+ if (missing !== undefined) {
42
+ row.out = 'model';
43
+ row.missing = missing;
44
+ }
45
+ }
46
+ if (!row.out && !demand.length && provider !== undefined && account.provider !== provider && account.id !== named)
47
+ row.out = 'provider';
48
+ if (row.out)
49
+ row.reason = row.out;
50
+ else
51
+ row.tier = left === 'unknown' ? 'unknown' : left > 0 || reset(row) <= nowMs ? 'room' : 'exhausted';
52
+ return { account, row };
53
+ });
54
+ }
55
+ /** Every account's `Considered` row, in list order, with the kit-only `bound` applied. */
56
+ export function consider(accounts, defaults, sel, room, nowMs, models, bound) {
57
+ const demand = [...new Set([...(sel.model ? [sel.model] : []), ...(sel.needs ?? [])])];
58
+ const defaultAccount = accounts.find((a) => a.id === defaults.account);
59
+ const named = nameOf(accounts, defaults, sel, bound);
60
+ const provider = sel.provider ?? defaultAccount?.provider ?? accounts[0]?.provider;
61
+ return considerRows(accounts, (a) => room(a, demand), nowMs, demand, models, provider, named, bound).map((c) => c.row);
62
+ }
63
+ const tierOrder = { room: 0, unknown: 1, exhausted: 2 };
64
+ function compare(a, b) {
65
+ const tier = tierOrder[a.row.tier] - tierOrder[b.row.tier];
66
+ if (tier)
67
+ return tier;
68
+ if (a.row.tier === 'unknown')
69
+ return 0;
70
+ if (a.row.tier === 'room' && a.row.left !== b.row.left)
71
+ return Number(b.row.left) - Number(a.row.left);
72
+ // Avoid Infinity - Infinity (NaN); stable sort preserves list order for ties.
73
+ return reset(a.row) === reset(b.row) ? 0 : reset(a.row) < reset(b.row) ? -1 : 1;
74
+ }
75
+ function rankingReason(winner, next) {
76
+ if (winner.row.tier === 'unknown')
77
+ return 'no_reading';
78
+ if (winner.row.tier === 'exhausted')
79
+ return 'refills_first';
80
+ if (!next || next.row.tier !== 'room' || winner.row.left !== next.row.left)
81
+ return 'most_room';
82
+ return reset(winner.row) < reset(next.row) ? 'earlier_reset' : 'list_order';
83
+ }
84
+ function rank(rows) {
85
+ const ranked = rows.filter((c) => !c.row.out).sort(compare);
86
+ const winner = ranked[0];
87
+ if (!winner)
88
+ return undefined;
89
+ const why = ranked.length === 1 ? 'only' : rankingReason(winner, ranked[1]);
90
+ for (const c of ranked)
91
+ c.row.reason = c === winner ? why : rankingReason(winner, c);
92
+ return { account: winner.account, why, row: winner.row };
93
+ }
94
+ /** Auto's winner only; `candidates` are the eligible accounts. Pure and portable; no timers, reads or mid-run switching. */
95
+ export function chooseAccount(candidates, room, nowMs) {
96
+ const pick = rank(considerRows(candidates, room, nowMs));
97
+ return pick && { account: pick.account, why: pick.why };
98
+ }
99
+ // The `'default'` id is the saved default account when it is ready, a subscription (or a non-custom API choice) and
100
+ // in `bound`; a default outside `bound` is treated as not ready (its row has `out: 'bound'`) and the pick falls through.
101
+ function nameOf(accounts, defaults, sel, bound) {
102
+ const defaultAccount = accounts.find((a) => a.id === defaults.account);
103
+ const usable = defaultAccount?.state === 'ready' &&
104
+ (defaultAccount.billing === 'subscription' || (defaultAccount.billing === 'api' && defaultAccount.provider !== 'custom')) &&
105
+ (bound === undefined || bound.includes(defaultAccount.id));
106
+ return sel.account === 'default' ? (usable ? defaultAccount.id : undefined) : sel.account === 'auto' ? undefined : sel.account;
107
+ }
108
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
109
+ export function resolveSelection(accounts, defaults, sel, room, nowMs, models, bound) {
110
+ const demand = [...new Set([...(sel.model ? [sel.model] : []), ...(sel.needs ?? [])])];
111
+ const defaultAccount = accounts.find((a) => a.id === defaults.account);
112
+ const named = nameOf(accounts, defaults, sel, bound);
113
+ const provider = sel.provider ?? defaultAccount?.provider ?? accounts[0]?.provider;
114
+ const rows = considerRows(accounts, (a) => room(a, demand), nowMs, demand, models, provider, named, bound);
115
+ const considered = rows.map((c) => c.row);
116
+ const auto = rank(rows); // also fills explanations for every eligible row, even on explicit selection
117
+ if (named !== undefined) {
118
+ const chosen = rows.find((c) => c.account.id === named);
119
+ if (!chosen)
120
+ return { ok: false, code: 'unknown_account', reason: words('auto.none', { provider: provider ?? '' }), considered };
121
+ if (chosen.row.out)
122
+ return { ok: false, code: 'not_included', reason: words(`pick.out.${chosen.row.out}`, { model: chosen.row.missing ?? '' }), considered };
123
+ return finish(chosen.account, chosen.row, sel.account === 'default' ? 'default' : 'chosen', sel.account === 'default' ? 'default' : 'chosen');
124
+ }
125
+ if (sel.account === 'default' && defaults.auto === false) {
126
+ const first = rows.find((c) => !c.row.out);
127
+ if (first)
128
+ return finish(first.account, first.row, 'default', 'first_ready');
129
+ }
130
+ else if (auto)
131
+ return finish(auto.account, auto.row, 'auto', auto.why);
132
+ return { ok: false, code: 'none', reason: words('auto.none', { provider: provider ?? '' }), considered };
133
+ function finish(account, row, how, why) {
134
+ const available = models?.(account).filter((m) => m.available);
135
+ const model = sel.model ?? (defaults.model && (!available || available.some((m) => m.id === defaults.model)) ? defaults.model : available?.[0]?.id) ?? '';
136
+ if (models && !model)
137
+ return { ok: false, code: 'not_included', reason: words('pick.out.model', { model: sel.model ?? '' }), considered };
138
+ row.reason = why;
139
+ const slots = { name: account.name, provider: account.provider };
140
+ const reason = how !== 'auto' ? words(`pick.why.${why}`, slots) : row.tier === 'unknown' ? words('auto.unknown', slots) : row.tier === 'exhausted' ?
141
+ words(finite(row.resetsAt) ? 'auto.refills' : 'auto.refillsNoTime', { ...slots, time: finite(row.resetsAt) ? clock(row.resetsAt) : '' }) :
142
+ words('auto.room', { ...slots, room: roomWords(Number(row.left), row.span ?? 'tightest') });
143
+ return { ok: true, account, model, how, why, reason, considered };
144
+ }
145
+ }
package/dist/signin.d.ts CHANGED
@@ -21,4 +21,5 @@ export declare function signIn(ctx: SignInCtx, member: Member, o: SignInOptions,
21
21
  };
22
22
  /** Usable provider profiles plus native Claude Code readiness reported by the engine. */
23
23
  export declare function providers(ctx: SignInCtx, member: Member, refresh?: boolean): Promise<string[]>;
24
+ /** Signs the member's agent out of its own profiles of `provider` only (5.7, 5.15). */
24
25
  export declare function signOut(ctx: SignInCtx, member: Member, provider: string): Promise<void>;
package/dist/signin.js CHANGED
@@ -433,7 +433,19 @@ export async function providers(ctx, member, refresh) {
433
433
  return signedInProviders(await (ctx.authStatus ? ctx.authStatus(agentId, refresh ?? false, true)
434
434
  : authStatus(ctx.request, agentId, refresh, true))) ?? [];
435
435
  }
436
+ /** Signs the member's agent out of its own profiles of `provider` only (5.7, 5.15). */
436
437
  export async function signOut(ctx, member, provider) {
437
438
  const { agentId } = await ctx.ensure(member);
438
- await ctx.request('models.authLogout', { provider, agentId }, { timeoutMs: STATUS_MS });
439
+ // Without profileIds the pin removes the provider's profiles from every owner store, another member's included.
440
+ // The inherited base is empty (5.15), so every profile this agent's status lists is its own.
441
+ const read = (refresh) => ctx.authStatus ? ctx.authStatus(agentId, refresh, false) : authStatus(ctx.request, agentId, refresh);
442
+ let status = await read(false);
443
+ if (status.unavailable)
444
+ status = await read(true); // no prepared snapshot yet: build it once
445
+ if (status.unavailable)
446
+ throw new Error(String(status.unavailable.message ?? 'account status unavailable'));
447
+ const profileIds = (status.providers ?? []).flatMap((row) => typeof row === 'string' || row.provider !== provider ? []
448
+ : (row.profiles ?? []).map((p) => p.profileId).filter((id) => typeof id === 'string' && id !== ''));
449
+ if (profileIds.length)
450
+ await ctx.request('models.authLogout', { provider, agentId, profileIds }, { timeoutMs: STATUS_MS });
439
451
  }
@@ -18,7 +18,6 @@ const REQUIRED_METHODS = [
18
18
  'plugin.approval.resolve', 'question.resolve', 'wizard.cancel', 'wizard.next',
19
19
  ];
20
20
  // Driven by the kit but not advertised in hello (O11 probe, e.g. sessions.steer): routable, just not listed.
21
- // The engine job's generated.test.ts audits the full generated table against hello instead of asserting it here.
22
21
  const REQUIRED_EVENTS = [
23
22
  'agent',
24
23
  'exec.approval.requested', 'exec.approval.resolved',
@@ -2,7 +2,7 @@
2
2
  // `new OpenClawKit({ transport: fake.factory, spawnEngine: false, ... })` or drive `fake.transport.request`
3
3
  // directly. No engine, no network, no account.
4
4
  import { connect } from 'node:net';
5
- import { randomUUID } from 'node:crypto';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { ENGINE_VERSION, PROTOCOL_VERSION } from "../constants.js";
@@ -23,6 +23,12 @@ const EVENTS = [
23
23
  /** Window between a run's last event and its completion, so an abort issued on the text event still lands. */
24
24
  const ABORT_WINDOW_MS = 25;
25
25
  const BRIDGE_TIMEOUT_MS = 30_000;
26
+ // The plugin's accounts.js status thresholds: auth selection's refresh margin and auth-health's oauth warn window.
27
+ const EXPIRING_MS = 300_000;
28
+ const OAUTH_WARN_MS = 86_400_000;
29
+ // The plugin's account agent shape (D9) and agent id shape, as plugin/accounts.js tests them.
30
+ const ACCOUNT_AGENT = /--[0-9a-f]{6}$/;
31
+ const AGENT_ID = /^[a-z][a-z0-9-]{0,31}$/;
26
32
  const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
27
33
  function mergeInto(target, patch) {
28
34
  for (const [key, value] of Object.entries(patch)) {
@@ -100,6 +106,9 @@ export function fakeGateway(script, o) {
100
106
  const handlers = new Map();
101
107
  const agents = new Map();
102
108
  const keys = new Map();
109
+ const profiles = new Map();
110
+ const orders = new Map();
111
+ const models = new Map();
103
112
  const wizards = new Map();
104
113
  const runs = new Map();
105
114
  const configBox = { hash: 'hash-1', config: {} };
@@ -114,10 +123,92 @@ export function fakeGateway(script, o) {
114
123
  agent.workspace = workspace;
115
124
  return agent;
116
125
  };
117
- const signInAgent = (id, provider) => {
126
+ const storeOf = (id) => {
127
+ let store = profiles.get(id);
128
+ if (!store) {
129
+ store = {};
130
+ profiles.set(id, store);
131
+ }
132
+ return store;
133
+ };
134
+ const orderOf = (id) => {
135
+ let order = orders.get(id);
136
+ if (!order) {
137
+ order = {};
138
+ orders.set(id, order);
139
+ }
140
+ return order;
141
+ };
142
+ // As the plugin's accounts.js: `who` only exists for an OAuth profile that stores a provider account id.
143
+ const whoOf = (credential) => credential.type === 'oauth' && typeof credential.accountId === 'string' && credential.accountId.length > 0
144
+ ? createHash('sha256').update(`${credential.provider}\n${credential.accountId}`).digest('hex').slice(0, 16) : undefined;
145
+ const hasSecret = (value, ref) => (typeof value === 'string' && value.length > 0) || (!!ref && typeof ref === 'object' && !Array.isArray(ref));
146
+ // The plugin's token expiry state (credential-state.ts): missing, invalid, expired, or valid.
147
+ const expiryState = (expires, now) => {
148
+ if (expires === undefined)
149
+ return 'missing';
150
+ if (typeof expires !== 'number' || !Number.isFinite(expires) || expires <= 0 || expires > 8.64e13)
151
+ return 'invalid';
152
+ return expires <= now ? 'expired' : 'valid';
153
+ };
154
+ // The plugin's profile status (auth-health.ts) from the credential alone: static, ok, expiring, expired or missing.
155
+ const statusOf = (credential, now) => {
156
+ if (credential.type === 'api_key')
157
+ return hasSecret(credential.key, credential.keyRef) ? 'static' : 'missing';
158
+ if (credential.type === 'token') {
159
+ if (!hasSecret(credential.token, credential.tokenRef))
160
+ return 'missing';
161
+ const state = expiryState(credential.expires, now);
162
+ if (state === 'invalid')
163
+ return 'missing';
164
+ if (state === 'expired')
165
+ return 'expired';
166
+ if (credential.expires === undefined)
167
+ return 'static';
168
+ return credential.expires - now <= EXPIRING_MS ? 'expiring' : 'ok';
169
+ }
170
+ if (!hasSecret(credential.access, undefined) && !hasSecret(credential.refresh, undefined))
171
+ return 'missing';
172
+ const expires = credential.expires;
173
+ const state = expiryState(expires, now);
174
+ if (state === 'invalid' || state === 'missing')
175
+ return 'missing';
176
+ if (state === 'expired')
177
+ return 'expired';
178
+ return expires - now <= OAUTH_WARN_MS ? 'expiring' : 'ok';
179
+ };
180
+ const entryPrimaryOf = (id) => {
181
+ const entries = configBox.config.agents?.entries;
182
+ const entry = entries?.[id];
183
+ const ref = entry?.model;
184
+ if (typeof ref === 'string')
185
+ return ref;
186
+ if (isPlainObject(ref) && typeof ref.primary === 'string')
187
+ return ref.primary;
188
+ return undefined;
189
+ };
190
+ const primaryOfId = (id) => entryPrimaryOf(id) ?? models.get(id);
191
+ const setPrimaryOfId = (id, primary) => {
192
+ models.set(id, primary);
193
+ const entries = configBox.config.agents?.entries;
194
+ const entry = entries?.[id];
195
+ if (entry && isPlainObject(entry)) {
196
+ const current = entry.model;
197
+ if (isPlainObject(current))
198
+ current.primary = primary;
199
+ else
200
+ entry.model = primary;
201
+ }
202
+ };
203
+ const signInAgent = (id, provider, extra) => {
118
204
  const agent = ensureAgent(id);
119
205
  if (!agent.providers.includes(provider))
120
206
  agent.providers.push(provider);
207
+ // The pin names the first profile of a provider `<provider>:default` and points the agent's model at it.
208
+ storeOf(id)[`${provider}:default`] = { provider, type: 'oauth', status: 'ok', access: 'fake-access',
209
+ refresh: 'fake-refresh', expires: Date.now() + 30 * 86_400_000, accountId: `fake-${id}`, ...extra };
210
+ orderOf(id)[provider] = [`${provider}:default`];
211
+ setPrimaryOfId(id, `${provider}/default@${provider}:default`);
121
212
  };
122
213
  // Every transport the factory made; events fan out to all of them, drop() closes all of them.
123
214
  const live = new Set();
@@ -222,13 +313,40 @@ export function fakeGateway(script, o) {
222
313
  ensureAgent(id, workspace);
223
314
  return { id };
224
315
  },
225
- 'models.authStatus': (p) => ({
226
- providers: (agents.get(String(p?.agentId ?? 'main'))?.providers ?? []).map((provider) => ({ provider })),
227
- }),
316
+ // One profile per signed-in provider, `<provider>:default`, as the pin lists an agent's own profiles.
317
+ 'models.authStatus': (p) => {
318
+ const id = String(p?.agentId ?? 'main');
319
+ const store = storeOf(id);
320
+ const now = Date.now();
321
+ return {
322
+ providers: (agents.get(id)?.providers ?? []).map((provider) => {
323
+ const profileId = `${provider}:default`;
324
+ const credential = store[profileId];
325
+ return { provider,
326
+ profiles: [{ profileId, type: credential?.type ?? 'oauth',
327
+ status: credential ? statusOf(credential, now) : 'ok' }] };
328
+ }),
329
+ };
330
+ },
228
331
  'models.authLogout': (p) => {
229
332
  const agent = agents.get(String(p?.agentId ?? 'main'));
333
+ // The pin refuses a profileIds list naming a profile the agent does not hold.
334
+ if (p.profileIds !== undefined && (!Array.isArray(p.profileIds) || !p.profileIds.length
335
+ || p.profileIds.some((id) => id !== `${p.provider}:default` || !agent?.providers.includes(String(p.provider)))))
336
+ throw new Error('profileIds contain unavailable auth profiles');
230
337
  if (agent)
231
338
  agent.providers = agent.providers.filter((provider) => provider !== p.provider);
339
+ const store = profiles.get(String(p.agentId ?? 'main'));
340
+ if (store)
341
+ for (const profileId of Object.keys(store))
342
+ if (store[profileId].provider === p.provider)
343
+ delete store[profileId];
344
+ const order = orders.get(String(p.agentId ?? 'main'));
345
+ if (order)
346
+ delete order[String(p.provider)];
347
+ const pin = models.get(String(p.agentId ?? 'main'));
348
+ if (typeof pin === 'string' && pin.includes(`@${String(p.provider)}:`))
349
+ models.delete(String(p.agentId ?? 'main'));
232
350
  if (keys.get(String(p.agentId))?.provider === p.provider)
233
351
  keys.delete(String(p.agentId));
234
352
  return {};
@@ -249,6 +367,102 @@ export function fakeGateway(script, o) {
249
367
  }
250
368
  return { ok: key.sealed, ...(key.sealed ? { model: key.model } : {}) };
251
369
  },
370
+ // Account sealing (5.15): the same five actions, branches and refusals the shipped plugin method serves.
371
+ 'byokit.accounts': (p) => {
372
+ const action = String(p.action ?? '');
373
+ const entries = configBox.config.agents?.entries;
374
+ const known = (id) => Boolean(id) && AGENT_ID.test(id) && ((Boolean(entries) && id in entries) || agents.has(id));
375
+ if (action === 'adopt') {
376
+ const from = String(p.from), to = String(p.to), provider = String(p.provider);
377
+ if (from === to || !known(from) || !known(to))
378
+ return { ok: false };
379
+ if (typeof p.who !== 'string' || !p.who)
380
+ return { ok: false };
381
+ const fromOrder = orderOf(from), named = fromOrder[provider] ?? [];
382
+ const fromId = named.length === 1 ? named[0] : undefined;
383
+ const credential = fromId ? storeOf(from)[fromId] : undefined;
384
+ if (!fromId || !credential || credential.provider !== provider || whoOf(credential) !== p.who)
385
+ return { ok: false };
386
+ const target = primaryOfId(to);
387
+ if (typeof target !== 'string')
388
+ return { ok: false };
389
+ const toStore = storeOf(to), toOrder = orderOf(to);
390
+ for (const [otherId, other] of Object.entries(toStore))
391
+ if (ACCOUNT_AGENT.test(to) ? otherId !== fromId : other.provider === provider && otherId !== fromId)
392
+ delete toStore[otherId];
393
+ toStore[fromId] = credential;
394
+ toOrder[provider] = [fromId];
395
+ if (!ensureAgent(to).providers.includes(provider))
396
+ ensureAgent(to).providers.push(provider);
397
+ delete storeOf(from)[fromId];
398
+ if (fromOrder[provider]) {
399
+ fromOrder[provider] = fromOrder[provider].filter((x) => x !== fromId);
400
+ if (!fromOrder[provider].length)
401
+ delete fromOrder[provider];
402
+ }
403
+ if (!Object.values(storeOf(from)).some((c) => c.provider === provider))
404
+ ensureAgent(from).providers = ensureAgent(from).providers.filter((x) => x !== provider);
405
+ const cut = target.lastIndexOf('@');
406
+ setPrimaryOfId(to, `${cut > 0 ? target.slice(0, cut) : target}@${fromId}`);
407
+ return { ok: true };
408
+ }
409
+ const id = String(p.agentId ?? '');
410
+ if (!known(id))
411
+ return { ok: false };
412
+ const store = storeOf(id), order = orderOf(id);
413
+ if (action === 'list')
414
+ return { ok: true, profiles: Object.entries(store).map(([profileId, credential]) => ({ provider: credential.provider,
415
+ profileId, type: credential.type, status: statusOf(credential, Date.now()), ...(whoOf(credential) ? { who: whoOf(credential) } : {}) })) };
416
+ if (action === 'clear') {
417
+ profiles.set(id, {});
418
+ orders.set(id, {});
419
+ const cleared = agents.get(id);
420
+ if (cleared)
421
+ cleared.providers = [];
422
+ return { ok: true };
423
+ }
424
+ if (action === 'ready') {
425
+ const provider = String(p.provider ?? '');
426
+ const named = order[provider] ?? [];
427
+ const credential = named.length === 1 ? store[named[0]] : undefined;
428
+ if (!provider || !credential || credential.provider !== provider)
429
+ return { ok: false };
430
+ const status = statusOf(credential, Date.now());
431
+ if (status === 'missing' || status === 'expired')
432
+ return { ok: false };
433
+ return { ok: true, type: credential.type, ...(typeof credential.expires === 'number' ? { expiresAt: credential.expires } : {}),
434
+ ...(whoOf(credential) ? { who: whoOf(credential) } : {}) };
435
+ }
436
+ if (action === 'seal') {
437
+ const primary = primaryOfId(id) ?? '';
438
+ const at = primary.lastIndexOf('@');
439
+ if (at < 1)
440
+ return { ok: false };
441
+ const profileId = primary.slice(at + 1);
442
+ const credential = store[profileId];
443
+ const account = ACCOUNT_AGENT.test(id);
444
+ if (!credential || credential.provider !== p.provider || (!account && credential.type === 'api_key'))
445
+ return { ok: false };
446
+ for (const [otherId, other] of Object.entries(store))
447
+ if (account ? otherId !== profileId : other.provider === p.provider && otherId !== profileId)
448
+ delete store[otherId];
449
+ // An account agent's whole order is replaced; a member's keeps its other providers, as plugin/accounts.js does.
450
+ const provider = String(p.provider);
451
+ if (account)
452
+ orders.set(id, { [provider]: [profileId] });
453
+ else
454
+ order[provider] = [profileId];
455
+ const sealedAgent = agents.get(id);
456
+ if (sealedAgent) {
457
+ if (account)
458
+ sealedAgent.providers = [provider];
459
+ else if (!sealedAgent.providers.includes(provider))
460
+ sealedAgent.providers.push(provider);
461
+ }
462
+ return { ok: true, ...(whoOf(credential) ? { who: whoOf(credential) } : {}) };
463
+ }
464
+ return { ok: false };
465
+ },
252
466
  'openclaw.setup.activate': (p) => {
253
467
  if (p.kind !== 'api-key')
254
468
  throw new Error('only the API-key setup path is simulated');
@@ -391,8 +605,7 @@ export function fakeGateway(script, o) {
391
605
  const hello = () => ({
392
606
  protocol: PROTOCOL_VERSION,
393
607
  server: { version: ENGINE_VERSION },
394
- // The methods the fake serves. The generated tables are type-only until O2 ships runtime names; O11's
395
- // generated.test.ts cross-checks the real engine's hello against the committed tables.
608
+ // The methods the fake serves. The generated tables are type-only until O2 ships runtime names.
396
609
  methods: [...handlers.keys()].sort(),
397
610
  events: [...EVENTS],
398
611
  });