@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/CHANGELOG.md +24 -0
- package/README.md +25 -11
- package/SECURITY.md +6 -2
- package/dist/accounts.d.ts +34 -0
- package/dist/accounts.js +533 -66
- package/dist/auth-store.d.ts +8 -0
- package/dist/auth-store.js +202 -25
- package/dist/engine.d.ts +10 -0
- package/dist/engine.js +67 -1
- package/dist/index.d.ts +2 -2
- package/dist/keys.d.ts +13 -2
- package/dist/keys.js +110 -59
- package/dist/kit.d.ts +14 -2
- package/dist/kit.js +71 -14
- package/dist/link.js +12 -6
- package/dist/pick.js +4 -0
- package/dist/runs.js +23 -6
- package/dist/signin.d.ts +4 -0
- package/dist/signin.js +80 -22
- package/dist/testing/fake-gateway.d.ts +7 -0
- package/dist/testing/fake-gateway.js +35 -1
- package/dist/thinking.d.ts +16 -0
- package/dist/thinking.js +62 -0
- package/dist/types.d.ts +8 -0
- package/dist/words.json +5 -1
- package/package.json +2 -1
- package/plugin/keys.js +3 -2
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
410
|
-
//
|
|
411
|
-
// first
|
|
412
|
-
// key run alike, so
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
442
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
543
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
559
|
-
ctx.
|
|
560
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
}
|