@byokit/openclaw 0.12.0 → 0.14.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/accounts.js CHANGED
@@ -1,14 +1,18 @@
1
1
  // The per-member account index (5.15, O14 step S5): `<root>/accounts/<member>.json` (0600, whole-file temp
2
2
  // plus rename) is the only kit state: `{ v: 1, accounts: { [id]: { agent, name, route, addedAt, until?, model?,
3
- // who? } }, defaults }`. No credential, token, email or engine line. Listing, rename, defaults, models, room,
4
- // adding with its identity merge (O14 steps S6, S7), the start-of-run pick (S10), session binding (S11) and moving a
5
- // conversation (S12) live here; remove arrives in a later step.
6
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ // who?, priorModel? } }, defaults, gone?, dropped? }`, where `priorModel` is the member agent's primary before a
4
+ // member-agent sign-in pinned it (empty when none), `gone` names account-agent dirs a removal left for the orphan
5
+ // sweep, and `dropped` names providers removed from the member agent so a late refresh cannot restore them. No
6
+ // credential, token, email or engine line. Listing, rename, defaults, models, room, adding with its identity merge
7
+ // (O14 steps S6, S7), the start-of-run pick (S10), session binding (S11), moving a conversation (S12) and removal
8
+ // with its orphan sweep (S8) live here.
9
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
10
  import { randomBytes, randomUUID } from 'node:crypto';
8
11
  import { join } from 'node:path';
12
+ import { DatabaseSync } from 'node:sqlite';
9
13
  import { providerOf, resolveSelection } from "./pick.js";
10
14
  import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
11
- import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
15
+ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed } from "./members.js";
12
16
  import { routes, routeFor } from "./routes.js";
13
17
  import { words } from "./words.js";
14
18
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -59,19 +63,18 @@ const validEntry = (v) => !!v && typeof v === 'object'
59
63
  && typeof v.route === 'string' && Number.isFinite(v.addedAt)
60
64
  && (v.until === undefined || Number.isFinite(v.until))
61
65
  && (v.model === undefined || typeof v.model === 'string')
62
- && (v.who === undefined || typeof v.who === 'string');
66
+ && (v.who === undefined || typeof v.who === 'string')
67
+ && (v.priorModel === undefined || typeof v.priorModel === 'string');
68
+ /** The member agent's primary as a durable index string; empty when there was none (or it is not a string primary). */
69
+ const priorOf = (model) => {
70
+ if (typeof model === 'string')
71
+ return model;
72
+ if (model && typeof model === 'object' && typeof model.primary === 'string')
73
+ return model.primary;
74
+ return '';
75
+ };
63
76
  const indexPath = (root, member) => join(root, 'accounts', `${member}.json`);
64
- const readIndex = (root, member) => {
65
- let text;
66
- try {
67
- text = readFileSync(indexPath(root, member), 'utf8');
68
- }
69
- catch (error) {
70
- if (error.code === 'ENOENT')
71
- return empty();
72
- throw error;
73
- }
74
- const file = JSON.parse(text);
77
+ const normalizeIndex = (file) => {
75
78
  if (!file || typeof file !== 'object' || file.v !== 1)
76
79
  return empty();
77
80
  const accounts = {};
@@ -86,8 +89,45 @@ const readIndex = (root, member) => {
86
89
  defaults.model = raw.model;
87
90
  if (typeof raw.auto === 'boolean')
88
91
  defaults.auto = raw.auto;
89
- return { v: 1, accounts, defaults };
92
+ const gone = Array.isArray(file.gone) ? file.gone.filter((a) => typeof a === 'string' && ACCOUNT_AGENT.test(a)) : [];
93
+ const dropped = Array.isArray(file.dropped)
94
+ ? file.dropped.filter((p) => typeof p === 'string' && !!p && !p.includes('.')) : [];
95
+ return { v: 1, accounts, defaults, ...(gone.length ? { gone } : {}), ...(dropped.length ? { dropped } : {}) };
90
96
  };
97
+ /** Load one member's index. `damaged` means the file exists but is not JSON; the bytes are left alone for repair. */
98
+ export function loadIndex(root, member) {
99
+ let text;
100
+ try {
101
+ text = readFileSync(indexPath(root, member), 'utf8');
102
+ }
103
+ catch (error) {
104
+ if (error.code === 'ENOENT')
105
+ return { file: empty(), damaged: false };
106
+ throw error;
107
+ }
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ }
112
+ catch {
113
+ return { file: empty(), damaged: true };
114
+ }
115
+ return { file: normalizeIndex(parsed), damaged: false };
116
+ }
117
+ const readIndex = (root, member) => {
118
+ const loaded = loadIndex(root, member);
119
+ if (loaded.damaged)
120
+ throw new Error(words('account.indexDamaged', { member }));
121
+ return loaded.file;
122
+ };
123
+ /** True when `agent` is the member agent or an `<member>--<hex6>` account agent of a damaged index. */
124
+ export function heldBackAgent(agent, damaged) {
125
+ if (damaged.has(agent))
126
+ return true;
127
+ if (!ACCOUNT_AGENT.test(agent))
128
+ return false;
129
+ return damaged.has(agent.slice(0, agent.lastIndexOf('--')));
130
+ }
91
131
  // Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
92
132
  // own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
93
133
  // agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
@@ -108,9 +148,215 @@ export function memberOfAccount(root, ref) {
108
148
  return undefined;
109
149
  }
110
150
  }
151
+ // Every agent any readable member's index names. An unreadable `accounts/<member>.json` quarantines only that
152
+ // member (held back: not restored, not swept, not resealed away); other members keep restoring, starting and
153
+ // persisting. The damaged file is never deleted — re-add rewrites a good index.
154
+ function indexedAgents(root) {
155
+ const named = new Set();
156
+ const gone = new Set();
157
+ const damaged = new Set();
158
+ let files;
159
+ try {
160
+ files = readdirSync(join(root, 'accounts'));
161
+ }
162
+ catch (error) {
163
+ if (error.code === 'ENOENT')
164
+ return { named, gone, damaged };
165
+ throw error;
166
+ }
167
+ for (const file of files) {
168
+ if (!file.endsWith('.json'))
169
+ continue;
170
+ const member = file.slice(0, -'.json'.length);
171
+ let text;
172
+ try {
173
+ text = readFileSync(join(root, 'accounts', file), 'utf8');
174
+ }
175
+ catch {
176
+ if (MEMBER_ID.test(member))
177
+ damaged.add(member);
178
+ continue;
179
+ }
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(text);
183
+ }
184
+ catch {
185
+ if (MEMBER_ID.test(member))
186
+ damaged.add(member);
187
+ continue;
188
+ }
189
+ for (const entry of Object.values(parsed.accounts ?? {}))
190
+ if (typeof entry?.agent === 'string')
191
+ named.add(entry.agent);
192
+ for (const agent of Array.isArray(parsed.gone) ? parsed.gone : [])
193
+ if (typeof agent === 'string')
194
+ gone.add(agent);
195
+ }
196
+ return { named, gone, damaged };
197
+ }
198
+ /** Members whose `accounts/<member>.json` exists but is not readable JSON. */
199
+ export function damagedAccountMembers(root) {
200
+ return indexedAgents(root).damaged;
201
+ }
202
+ // Durable removal tombstones, except ids an index now names again. Shared by the live and sealed-store sweeps.
203
+ // A damaged member contributes no tombstones and never expands the blast radius of another member's removal.
204
+ export function removedAccountAgents(root) {
205
+ const { named, gone, damaged } = indexedAgents(root);
206
+ return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent) && !heldBackAgent(agent, damaged)));
207
+ }
208
+ // Providers a removal recorded as dropped from the member agent, except ones an index entry on that member agent names
209
+ // again. Parallel to `gone` for account-agent directories: only an intentional removeAccount tombstone is swept, so a
210
+ // live subscription that listing synthesizes from the store is never cleared just because the index omitted it.
211
+ // Damaged members contribute no tombstones.
212
+ function droppedMemberProviders(root) {
213
+ const out = new Map();
214
+ const { damaged } = indexedAgents(root);
215
+ let files;
216
+ try {
217
+ files = readdirSync(join(root, 'accounts'));
218
+ }
219
+ catch (error) {
220
+ if (error.code === 'ENOENT')
221
+ return out;
222
+ throw error;
223
+ }
224
+ for (const file of files) {
225
+ if (!file.endsWith('.json'))
226
+ continue;
227
+ const member = file.slice(0, -'.json'.length);
228
+ if (damaged.has(member))
229
+ continue;
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
233
+ }
234
+ catch {
235
+ continue;
236
+ }
237
+ const named = new Set();
238
+ for (const [id, entry] of Object.entries(parsed.accounts ?? {})) {
239
+ if (typeof entry?.agent !== 'string' || entry.agent !== member || !id)
240
+ continue;
241
+ named.add(id.split('.')[0]);
242
+ }
243
+ const drop = new Set();
244
+ for (const provider of Array.isArray(parsed.dropped) ? parsed.dropped : []) {
245
+ if (typeof provider !== 'string' || !provider || provider.includes('.') || named.has(provider))
246
+ continue;
247
+ drop.add(provider);
248
+ }
249
+ if (drop.size)
250
+ out.set(member, drop);
251
+ }
252
+ return out;
253
+ }
254
+ function dropProviders(store, drop) {
255
+ let changed = false;
256
+ const profiles = store.profiles && typeof store.profiles === 'object' ? store.profiles : {};
257
+ for (const [profileId, profile] of Object.entries(profiles)) {
258
+ if (typeof profile?.provider === 'string' && !drop.has(profile.provider))
259
+ continue;
260
+ delete profiles[profileId];
261
+ changed = true;
262
+ }
263
+ store.profiles = profiles;
264
+ if (store.order && typeof store.order === 'object') {
265
+ for (const provider of Object.keys(store.order)) {
266
+ if (!drop.has(provider))
267
+ continue;
268
+ delete store.order[provider];
269
+ changed = true;
270
+ }
271
+ }
272
+ if (store.lastGood && typeof store.lastGood === 'object') {
273
+ for (const provider of Object.keys(store.lastGood)) {
274
+ if (!drop.has(provider))
275
+ continue;
276
+ delete store.lastGood[provider];
277
+ changed = true;
278
+ }
279
+ }
280
+ return changed;
281
+ }
282
+ function putAuthProfilesJson(path, store) {
283
+ const staging = `${path}.staging-${process.pid}`;
284
+ writeFileSync(staging, JSON.stringify(store), { mode: 0o600 });
285
+ renameSync(staging, path);
286
+ try {
287
+ chmodSync(path, 0o600);
288
+ }
289
+ catch { /* best-effort mode */ }
290
+ }
291
+ function sweepMemberAuthProfiles(root) {
292
+ for (const [member, drop] of droppedMemberProviders(root)) {
293
+ const agentDir = join(root, 'state', 'agents', member, 'agent');
294
+ const jsonPath = join(agentDir, 'auth-profiles.json');
295
+ if (existsSync(jsonPath)) {
296
+ try {
297
+ const store = JSON.parse(readFileSync(jsonPath, 'utf8'));
298
+ if (store && typeof store === 'object' && dropProviders(store, drop))
299
+ putAuthProfilesJson(jsonPath, store);
300
+ }
301
+ catch { /* leave an unreadable store alone; prepare must still finish */ }
302
+ }
303
+ const dbPath = join(agentDir, 'openclaw-agent.sqlite');
304
+ if (!existsSync(dbPath))
305
+ continue;
306
+ try {
307
+ const db = new DatabaseSync(dbPath);
308
+ try {
309
+ const row = db.prepare('SELECT store_json FROM auth_profile_store').get();
310
+ if (typeof row?.store_json !== 'string')
311
+ continue;
312
+ const store = JSON.parse(row.store_json);
313
+ if (!store || typeof store !== 'object' || !dropProviders(store, drop))
314
+ continue;
315
+ db.prepare('UPDATE auth_profile_store SET store_json = ?').run(JSON.stringify(store));
316
+ }
317
+ finally {
318
+ db.close();
319
+ }
320
+ }
321
+ catch { /* leave an unreadable native store alone */ }
322
+ }
323
+ }
324
+ // Removes each `<root>/state/agents/<member>--<hex6>` directory that a removal recorded as gone and no index names,
325
+ // and drops member-agent auth profiles a removal recorded as `dropped`. Only recorded directories go: a legacy member's
326
+ // own directory is never recorded, so it stays. The directories are listed before the index is read: each listed one was
327
+ // created after its entry was written. Damaged members' agents are never swept (their gone/dropped lists are unknown);
328
+ // `log` reports each damaged index once.
329
+ export function sweepOrphans(root, log) {
330
+ const { damaged } = indexedAgents(root);
331
+ for (const member of damaged)
332
+ log?.(words('account.indexDamaged', { member }));
333
+ let dirs = [];
334
+ try {
335
+ dirs = readdirSync(join(root, 'state', 'agents'));
336
+ }
337
+ catch (error) {
338
+ if (error.code !== 'ENOENT')
339
+ throw error;
340
+ }
341
+ const gone = removedAccountAgents(root);
342
+ for (const dir of dirs) {
343
+ if (!gone.has(dir))
344
+ continue;
345
+ rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
346
+ }
347
+ sweepMemberAuthProfiles(root);
348
+ }
111
349
  export function createAccounts(ctx) {
112
350
  const path = (member) => indexPath(ctx.root, member);
113
- const read = (member) => readIndex(ctx.root, member);
351
+ const read = (member) => {
352
+ const loaded = loadIndex(ctx.root, member);
353
+ if (loaded.damaged) {
354
+ const message = words('account.indexDamaged', { member });
355
+ ctx.log?.(message);
356
+ throw new Error(message);
357
+ }
358
+ return loaded.file;
359
+ };
114
360
  // Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
115
361
  const signing = new Map();
116
362
  // The index file's own lock, apart from any agent's: a run's shared hold on the member agent never blocks an edit.
@@ -119,6 +365,24 @@ export function createAccounts(ctx) {
119
365
  if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
120
366
  throw new Error(`invalid member id: ${member}`);
121
367
  };
368
+ const dropEntry = (file, id) => {
369
+ delete file.accounts[id];
370
+ if (file.defaults.account === id)
371
+ delete file.defaults.account;
372
+ };
373
+ const markGone = (file, agent) => { if (!file.gone?.includes(agent))
374
+ file.gone = [...(file.gone ?? []), agent]; };
375
+ const markDropped = (file, provider) => {
376
+ if (!file.dropped?.includes(provider))
377
+ file.dropped = [...(file.dropped ?? []), provider];
378
+ };
379
+ const clearDropped = (file, provider) => {
380
+ if (!file.dropped?.includes(provider))
381
+ return;
382
+ file.dropped = file.dropped.filter((p) => p !== provider);
383
+ if (!file.dropped.length)
384
+ delete file.dropped;
385
+ };
122
386
  const write = (member, file) => {
123
387
  mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
124
388
  const tmp = `${path(member)}.${randomUUID()}.tmp`;
@@ -141,8 +405,8 @@ export function createAccounts(ctx) {
141
405
  const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
142
406
  ?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
143
407
  ?? provider;
144
- const rosterOf = () => ctx.request('agents.list')
145
- .then((r) => new Set(r.agents.map((a) => a.id)), () => new Set());
408
+ const agentRoster = () => ctx.request('agents.list').then((r) => new Set(r.agents.map((a) => a.id)));
409
+ const rosterOf = () => agentRoster().catch(() => new Set());
146
410
  const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
147
411
  .then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
148
412
  const stateOf = (route, status, until) => {
@@ -167,6 +431,9 @@ export function createAccounts(ctx) {
167
431
  const memberRows = [];
168
432
  const seen = new Set();
169
433
  for (const [provider, profiles] of [...byProvider.entries()].sort(([a], [b]) => a < b ? -1 : 1)) {
434
+ // A removeAccount tombstone wins until the person signs in again: do not synthesize the removed row back.
435
+ if (file.dropped?.includes(provider))
436
+ continue;
170
437
  let entry = file.accounts[provider];
171
438
  if (!entry || entry.agent !== agentId) {
172
439
  const route = entry?.agent === agentId && typeof entry.route === 'string' ? entry.route : synthesizedRoute(provider);
@@ -216,7 +483,26 @@ export function createAccounts(ctx) {
216
483
  }
217
484
  memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
218
485
  agentRows.sort((a, b) => a.addedAt - b.addedAt);
219
- const rows = [...memberRows, ...agentRows];
486
+ // The API-key lane: the member's single key agent, indexed once as `<provider>.key`. Only what the index
487
+ // names is listed; a key agent the index does not name holds no account.
488
+ const keyRows = [];
489
+ for (const [id, entry] of Object.entries(file.accounts)) {
490
+ if (entry.agent !== keyAgentId(member) || !roster.has(entry.agent))
491
+ continue;
492
+ const provider = id.split('.')[0];
493
+ const meta = metaOf(entry.route);
494
+ const profiles = await profilesOf(entry.agent);
495
+ const who = profiles.find((p) => p.provider === provider)?.who;
496
+ if (!entry.who && typeof who === 'string' && !signing.has(`${member}\n${id}`)) {
497
+ entry.who = who;
498
+ dirty = true;
499
+ }
500
+ keyRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
501
+ billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
502
+ ...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
503
+ }
504
+ keyRows.sort((a, b) => a.addedAt - b.addedAt);
505
+ const rows = [...memberRows, ...agentRows, ...keyRows];
220
506
  for (const row of rows)
221
507
  if (signing.has(`${member}\n${row.id}`))
222
508
  row.state = 'signing';
@@ -374,7 +660,7 @@ export function createAccounts(ctx) {
374
660
  return roomOf(row.provider, answer);
375
661
  });
376
662
  // Runs step 2 reads the index alone: the index-only refusal makes no request, and a cold index adds one account
377
- // listing (the pick's late check after `list`; `keyRefusal` lists when the held account is not API-billed). This
663
+ // listing (the pick's late check after `list`; `keyRefusal` lists unless the binding is the key lane). This
378
664
  // gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
379
665
  const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
380
666
  : Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
@@ -397,23 +683,27 @@ export function createAccounts(ctx) {
397
683
  return { code: 'bound', name: nameOf(file, ids) };
398
684
  return undefined;
399
685
  };
400
- // An API-key run (Runs step 2): its key continues only an API-billed binding; any other binding is refused.
686
+ // An API-key run (Runs step 2): its key continues its own lane's conversation whatever the key bills (a
687
+ // plan key bills subscription), and any other binding is refused.
401
688
  const keyRefusal = async (member, binding) => {
402
- const held = binding.id === undefined ? undefined : read(member).accounts[binding.id];
403
- if (held && metaOf(held.route).billing === 'api')
689
+ if (binding.agentId === keyAgentId(member))
404
690
  return undefined;
405
691
  await list(member);
406
692
  const file = read(member);
407
693
  return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
408
694
  };
409
- // Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account; a
410
- // key `agent:<member>:<tail>` to whichever of the member's agents holds `agent:<that agent>:<tail>` (member agent
411
- // first), else unbound. Any other key is not the member's. With no account agent, the member agent and an unbound
412
- // key run alike, so nothing is asked.
695
+ // Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account. A key
696
+ // `agent:<member>:<tail>` is bound to whichever of the member's agents holds `agent:<that agent>:<tail>` (member agent
697
+ // first, then the key lane, whose account is its `<provider>.key`, then the account agents), else unbound. Any other key
698
+ // is not the member's. With no account agent and no indexed lane, the member agent and an unbound key run alike, so
699
+ // nothing is asked.
413
700
  async function bind(member, sessionKey) {
414
701
  check(member);
415
- const agents = new Map(Object.entries(read(member).accounts)
702
+ const file = read(member);
703
+ const agents = new Map(Object.entries(file.accounts)
416
704
  .filter(([, e]) => ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`)).map(([id, e]) => [e.agent, id]));
705
+ const lane = keyAgentId(member);
706
+ const laneId = Object.entries(file.accounts).find(([, e]) => e.agent === lane)?.[0];
417
707
  if (!sessionKey.startsWith(`agent:${member}:`)) {
418
708
  const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
419
709
  const id = agent === undefined ? undefined : agents.get(agent);
@@ -421,12 +711,13 @@ export function createAccounts(ctx) {
421
711
  throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
422
712
  return { agentId: agent, id };
423
713
  }
424
- if (!agents.size)
714
+ if (!agents.size && laneId === undefined)
425
715
  return undefined;
426
716
  const tail = sessionKey.slice(`agent:${member}:`.length);
427
717
  const { agentId } = await ctx.ensure(member);
428
718
  const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
429
- for (const [agent, id] of [[agentId, undefined], ...agents]) {
719
+ const laneProbe = laneId === undefined ? [] : [[lane, laneId]];
720
+ for (const [agent, id] of [[agentId, undefined], ...laneProbe, ...agents]) {
430
721
  if (agent !== agentId && !roster.has(agent))
431
722
  continue;
432
723
  // The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
@@ -436,10 +727,11 @@ export function createAccounts(ctx) {
436
727
  }
437
728
  return undefined;
438
729
  }
439
- // Choosing (5.15): every listed account's room and models are read live once, then the pure `resolveSelection`
440
- // decides. A binding narrows it (Runs step 2): a key bound to an account agent resolves 'auto', 'default' and no id to
441
- // that account; a key bound to the member agent chooses only among its own accounts. A refusal (`bound`, `paid`) is
442
- // decided from the index, after the account listing when the index is cold, and so has no `considered` rows.
730
+ // Choosing (5.15): every pick input's room and models are read live once (unnamed key lanes are excluded),
731
+ // then the pure `resolveSelection` decides. A binding narrows it (Runs step 2): a key bound to an account agent resolves 'auto', 'default' and no id to
732
+ // that account; a key bound to the key lane resolves to its `<provider>.key` account; a key bound to the member agent
733
+ // chooses only among its own accounts. A refusal (`bound`, `paid`) is decided from the index, after the account
734
+ // listing when the index is cold, and so has no `considered` rows.
443
735
  // An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
444
736
  // only because the account is signed out or resting is still in its plan: the account's state and the run's ready
445
737
  // check judge that.
@@ -457,18 +749,25 @@ export function createAccounts(ctx) {
457
749
  const late = binding && refusalOf(file, binding, sel);
458
750
  if (late)
459
751
  return refused(late);
752
+ if (binding?.id !== undefined && !listed.some((r) => r.id === binding.id))
753
+ return { pick: { ok: false, code: 'none', reason: words('auto.noneBound'), considered: [] } };
460
754
  const rows = listed.filter((r) => !skip.includes(r.id));
461
755
  const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
462
756
  const known = named === undefined || listed.some((r) => r.id === named);
463
757
  if (binding?.id !== undefined && known)
464
758
  sel = { ...sel, account: binding.id };
465
759
  const bound = binding && known ? boundOf(file, binding) : undefined;
466
- const reads = new Map(await Promise.all(rows.map(async (row) => {
760
+ // The key lane is reached only by naming it (or the saved default): an unnamed lane row is not a pick input, so
761
+ // Auto and a default fallback see exactly the accounts they saw before the lane was indexed.
762
+ const lanes = new Set(Object.entries(file.accounts).filter(([, e]) => e.agent === keyAgentId(member)).map(([id]) => id));
763
+ const pickRows = rows.filter((r) => !lanes.has(r.id) || sel.account === r.id ||
764
+ (sel.account === 'default' && file.defaults.account === r.id));
765
+ const reads = new Map(await Promise.all(pickRows.map(async (row) => {
467
766
  const entry = file.accounts[row.id];
468
767
  const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
469
768
  return [row.id, { room: await readRoom(row, entry), models }];
470
769
  })));
471
- const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
770
+ const chosen = resolveSelection(pickRows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
472
771
  return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
473
772
  }
474
773
  // Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
@@ -539,8 +838,81 @@ export function createAccounts(ctx) {
539
838
  // A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
540
839
  const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
541
840
  entry.until = until; });
542
- // Index edits take the index lock, as listing's own write does.
543
- const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
841
+ const agentDir = (agent) => join(ctx.root, 'state', 'agents', agent);
842
+ const laneEntry = (member, route, addedAt) => ({ agent: keyAgentId(member), name: route.label, route: route.choice, addedAt });
843
+ // The API-key lane's index entry (5.15): one per member, `<provider>.key` on the `byokit-key-<member>` agent.
844
+ // A new key replaces the old one: whatever lane entry the member holds is dropped first, keeping its `addedAt`.
845
+ // The key itself is never stored: only the agent, the route choice and the name.
846
+ async function key(member, route) {
847
+ check(member);
848
+ const lane = keyAgentId(member);
849
+ return ctx.locks.exclusive([indexLock(member)], async () => {
850
+ const loaded = loadIndex(ctx.root, member);
851
+ if (loaded.damaged) {
852
+ ctx.log?.(words('account.indexDamaged', { member }));
853
+ return;
854
+ }
855
+ const file = loaded.file;
856
+ const kept = Object.values(file.accounts).find((e) => e.agent === lane)?.addedAt;
857
+ for (const [other, e] of Object.entries(file.accounts))
858
+ if (e.agent === lane)
859
+ delete file.accounts[other];
860
+ file.accounts[`${route.provider}.key`] = laneEntry(member, route, kept ?? Date.now());
861
+ write(member, file);
862
+ });
863
+ }
864
+ // The removal's counterpart to `key`: drop the lane's index entry, so `list` stops naming the removed key.
865
+ // `provider` scopes it to that provider's entry (a scoped removal that kept another provider's key keeps its
866
+ // entry too); without one every lane entry goes. A damaged index is left alone, like `key` leaves it.
867
+ async function unkey(member, provider) {
868
+ check(member);
869
+ const lane = keyAgentId(member);
870
+ return ctx.locks.exclusive([indexLock(member)], async () => {
871
+ const loaded = loadIndex(ctx.root, member);
872
+ if (loaded.damaged) {
873
+ ctx.log?.(words('account.indexDamaged', { member }));
874
+ return;
875
+ }
876
+ const file = loaded.file;
877
+ for (const [id, e] of Object.entries(file.accounts)) {
878
+ if (e.agent !== lane || (provider !== undefined && id.split('.')[0] !== provider))
879
+ continue;
880
+ dropEntry(file, id);
881
+ }
882
+ write(member, file);
883
+ });
884
+ }
885
+ // The connect sweep's backfill: a lane that holds no entry is indexed under the one route its model's provider has;
886
+ // any other count is logged and left unindexed. A lane that holds an entry is never touched, nor a damaged index.
887
+ async function backfill(member, found) {
888
+ check(member);
889
+ const lane = keyAgentId(member);
890
+ return ctx.locks.exclusive([indexLock(member)], async () => {
891
+ const loaded = loadIndex(ctx.root, member);
892
+ if (loaded.damaged) {
893
+ ctx.log?.(words('account.indexDamaged', { member }));
894
+ return;
895
+ }
896
+ if (Object.values(loaded.file.accounts).some((e) => e.agent === lane))
897
+ return;
898
+ if (found.length !== 1) {
899
+ ctx.log?.(`key lane ${member}: its provider has no single key route, so it is not indexed; add the key again to index it`);
900
+ return;
901
+ }
902
+ loaded.file.accounts[`${found[0].provider}.key`] = laneEntry(member, found[0], Date.now());
903
+ write(member, loaded.file);
904
+ });
905
+ }
906
+ // Index edits take the index lock, as listing's own write does. A damaged index starts empty and is rewritten
907
+ // (re-add's repair path); the bad bytes are replaced, never left as the live index after a successful write.
908
+ const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => {
909
+ const loaded = loadIndex(ctx.root, member);
910
+ if (loaded.damaged)
911
+ ctx.log?.(words('account.indexDamaged', { member }));
912
+ const file = loaded.damaged ? empty() : loaded.file;
913
+ fn(file);
914
+ write(member, file);
915
+ });
544
916
  const identityOf = async (agentId) => {
545
917
  const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
546
918
  return file.missing ? undefined : file.content;
@@ -553,24 +925,99 @@ export function createAccounts(ctx) {
553
925
  const got = await ctx.request('config.get');
554
926
  return got.config?.agents?.entries?.[agentId]?.model;
555
927
  };
556
- // ponytail: a merge patch cannot delete a key, so a member with no model entry before its sign-in keeps the pinned one.
928
+ // A merge patch cannot delete a key: with no prior primary, `agents.update { model: null }` clears the pin so the
929
+ // engine default applies. A write that lands between the read and the patch moves the hash: try once more.
557
930
  const restoreModel = async (memberAgent, model) => {
558
- if (model === undefined) {
559
- ctx.log?.(`addAccount: ${memberAgent} had no model entry to restore`);
560
- return;
561
- }
562
- // A config write that lands between the read and the patch moves the hash: read and patch once more.
931
+ const clear = async () => {
932
+ await ctx.request('agents.update', { agentId: memberAgent, model: null });
933
+ };
563
934
  const patch = async () => {
564
935
  const { hash } = await ctx.request('config.get');
565
936
  await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
566
937
  };
567
938
  try {
939
+ if (model === undefined || model === null) {
940
+ await clear().catch(() => clear());
941
+ return;
942
+ }
568
943
  await patch().catch(() => patch());
569
944
  }
570
945
  catch (error) {
946
+ // A damaged prior must not leave the removed provider's pin: fall back to clearing.
947
+ if (model !== undefined && model !== null) {
948
+ try {
949
+ await clear().catch(() => clear());
950
+ }
951
+ catch (clearError) {
952
+ ctx.log?.(`addAccount: ${memberAgent} model pin is not cleared: ${clearError instanceof Error ? clearError.message : String(clearError)}`);
953
+ }
954
+ }
571
955
  ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
572
956
  }
573
957
  };
958
+ // Removing (5.15): a member-agent account goes through `models.authLogout` with its sealed profileIds, restores or
959
+ // clears the member primary from `priorModel`, then drops its entry. An account agent goes through `clear`, an
960
+ // empty `list`, `agents.delete` with `deleteFiles: false`, removal of its state dir, then the entry is dropped.
961
+ // Refused `busy` while a run holds the agent; the member agent stays ready (only the named provider is logged out
962
+ // or cleared).
963
+ const providerProfileIds = async (agentId, provider) => {
964
+ const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
965
+ if (listed.ok !== true || !Array.isArray(listed.profiles))
966
+ throw new Error('removeAccount: the sign-ins could not be read');
967
+ return listed.profiles.filter((p) => p.provider === provider && typeof p.profileId === 'string' && p.profileId).map((p) => p.profileId);
968
+ };
969
+ async function remove(member, id) {
970
+ check(member);
971
+ const { agentId: memberAgent } = await ctx.ensure(member);
972
+ const entry = read(member).accounts[id];
973
+ if (entry?.agent === keyAgentId(member))
974
+ throw new Error(`removeAccount: ${id} is a key lane; use removeKey`);
975
+ if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
976
+ throw new Error(`unknown account: ${id}`);
977
+ const agent = entry.agent;
978
+ const provider = id.split('.')[0];
979
+ if (ctx.locks.live(agent) || signing.has(`${member}\n${id}`))
980
+ throw new Error('removeAccount: busy');
981
+ if (agent === memberAgent) {
982
+ await ctx.locks.exclusive([agent, indexLock(member)], async () => {
983
+ const profileIds = await providerProfileIds(agent, provider);
984
+ if (profileIds.length)
985
+ await ctx.request('models.authLogout', { provider, agentId: agent, profileIds });
986
+ if ((await providerProfileIds(agent, provider)).length)
987
+ throw new Error('removeAccount: the account is still signing in');
988
+ const file = read(member);
989
+ const prior = file.accounts[id]?.priorModel;
990
+ // Restore the pre-sign-in primary (or clear the pin) before dropping the entry; failure must not block removal.
991
+ await restoreModel(memberAgent, typeof prior === 'string' && prior ? prior : undefined);
992
+ dropEntry(file, id);
993
+ markDropped(file, provider);
994
+ write(member, file);
995
+ // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
996
+ await ctx.reseal?.();
997
+ });
998
+ return;
999
+ }
1000
+ await ctx.locks.exclusive([agent, indexLock(member)], async () => {
1001
+ const roster = await agentRoster();
1002
+ if (roster.has(agent)) {
1003
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
1004
+ const listed = await ctx.request('byokit.accounts', { action: 'list', agentId: agent });
1005
+ if (listed.ok !== true || !Array.isArray(listed.profiles))
1006
+ throw new Error('removeAccount: the sign-ins could not be read');
1007
+ if (listed.profiles.length)
1008
+ throw new Error('removeAccount: the account is still signing in');
1009
+ await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
1010
+ }
1011
+ rmSync(agentDir(agent), { recursive: true, force: true });
1012
+ // Already holding the index lock: write directly instead of `edit`, which would wait on itself.
1013
+ const file = read(member);
1014
+ dropEntry(file, id);
1015
+ markGone(file, agent);
1016
+ write(member, file);
1017
+ // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
1018
+ await ctx.reseal?.();
1019
+ });
1020
+ }
574
1021
  const onGateway = (agent) => untilListed(ctx.request, agent)
575
1022
  .then(() => true, () => ctx.request('agents.list')
576
1023
  .then((r) => r.agents.some((a) => a.id === agent)));
@@ -581,7 +1028,7 @@ export function createAccounts(ctx) {
581
1028
  await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
582
1029
  await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
583
1030
  }
584
- rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
1031
+ rmSync(agentDir(agent), { recursive: true, force: true });
585
1032
  await edit(member, (file) => { delete file.accounts[id]; });
586
1033
  })().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
587
1034
  // Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
@@ -622,18 +1069,27 @@ export function createAccounts(ctx) {
622
1069
  if (fresh)
623
1070
  releaseAdd();
624
1071
  }
625
- // The member agent's primary model before this sign-in or any merge on it: restored when its profile moves away.
1072
+ // The member agent's primary before this sign-in or any merge on it: restored when its profile moves away or
1073
+ // the member-agent account is removed. Durable on the member entry as `priorModel` (empty when none).
626
1074
  const memberModel = await readModel(memberAgent);
627
- const entryOf = (file, agent) => ({ agent, route: route.choice, addedAt: Date.now(),
628
- name: name ?? `${route.label} ${Object.values(file.accounts).filter((e) => e.route === route.choice).length + 1}` });
1075
+ const entryOf = (file, agent, prior) => ({ agent, route: route.choice, addedAt: Date.now(),
1076
+ name: name ?? `${route.label} ${Object.values(file.accounts).filter((e) => e.route === route.choice).length + 1}`,
1077
+ ...(prior !== undefined ? { priorModel: prior } : {}) });
629
1078
  let id = provider;
630
1079
  let agent = memberAgent;
631
1080
  let added = false;
632
1081
  if (!fresh) {
633
- await edit(member, (file) => { if (!file.accounts[id]) {
634
- file.accounts[id] = entryOf(file, agent);
635
- added = true;
636
- } });
1082
+ const prior = priorOf(memberModel);
1083
+ await edit(member, (file) => {
1084
+ if (!file.accounts[id]) {
1085
+ file.accounts[id] = entryOf(file, agent, prior);
1086
+ added = true;
1087
+ }
1088
+ // Keep the first pre-sign-in primary across a later re-sign-in of the same member row.
1089
+ else if (file.accounts[id].priorModel === undefined)
1090
+ file.accounts[id].priorModel = prior;
1091
+ clearDropped(file, provider);
1092
+ });
637
1093
  }
638
1094
  else {
639
1095
  // `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
@@ -776,17 +1232,28 @@ export function createAccounts(ctx) {
776
1232
  return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
777
1233
  }).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
778
1234
  : (error instanceof Error ? error.message : String(error)).slice(0, 200));
779
- if (typeof sealed === 'string')
780
- view = { state: 'failed', via: view.via, why: 'failed', error: sealed };
781
- else {
782
- for (const dup of sealed.dups)
783
- await collapse(dup, sealed.keep, sealed.who);
784
- const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
785
- if (!into || !await adopt(sealed.who, into[1].agent))
786
- return { view, id };
787
- await removeEntry();
788
- return { view, id: into[0] };
1235
+ if (typeof sealed === 'string') {
1236
+ let error = sealed;
1237
+ // A seal failure on the member agent (the non-fresh path) leaves its new credential with no index entry,
1238
+ // so listing hides it but runs could use it. Clear only this provider: the bare clear wipes every profile.
1239
+ if (agent === memberAgent) {
1240
+ const cleared = await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
1241
+ .then((answer) => answer?.ok === true, () => false);
1242
+ if (!cleared) {
1243
+ ctx.log?.(`addAccount: ${memberAgent} ${provider} is not cleared`);
1244
+ error = `${sealed} Its credential is still on the member agent: remove this account.`;
1245
+ }
1246
+ }
1247
+ await drop();
1248
+ return { view: { state: 'failed', via: view.via, why: 'failed', error }, id };
789
1249
  }
1250
+ for (const dup of sealed.dups)
1251
+ await collapse(dup, sealed.keep, sealed.who);
1252
+ const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
1253
+ if (!into || !await adopt(sealed.who, into[1].agent))
1254
+ return { view, id };
1255
+ await removeEntry();
1256
+ return { view, id: into[0] };
790
1257
  }
791
1258
  // A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
792
1259
  if (agent === memberAgent)
@@ -813,5 +1280,5 @@ export function createAccounts(ctx) {
813
1280
  throw error;
814
1281
  }
815
1282
  }
816
- return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move };
1283
+ return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, key, unkey, backfill, pick, rest, move, remove };
817
1284
  }