@byokit/openclaw 0.13.0 → 0.14.2
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 +40 -0
- package/README.md +29 -12
- package/SECURITY.md +6 -2
- package/dist/accounts.d.ts +31 -1
- package/dist/accounts.js +433 -101
- package/dist/approvals.js +1 -1
- package/dist/auth-status.js +1 -1
- package/dist/auth-store.d.ts +2 -0
- package/dist/auth-store.js +73 -8
- package/dist/bridge.js +1 -1
- package/dist/browser/host.js +3 -3
- package/dist/browser/verify.js +1 -1
- package/dist/config.js +2 -2
- package/dist/constants.d.ts +1 -1
- package/dist/day-usage.js +1 -1
- package/dist/device.js +7 -7
- package/dist/engine.d.ts +12 -0
- package/dist/engine.js +77 -13
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -8
- package/dist/keys.d.ts +17 -2
- package/dist/keys.js +133 -64
- package/dist/kit.d.ts +15 -3
- package/dist/kit.js +89 -36
- package/dist/link.js +17 -11
- package/dist/migrate.js +3 -3
- package/dist/pick.js +5 -1
- package/dist/runs.js +28 -11
- package/dist/signin.d.ts +4 -0
- package/dist/signin.js +85 -27
- package/dist/testing/browser.js +2 -2
- package/dist/testing/contract.js +3 -3
- package/dist/testing/fake-gateway.js +28 -5
- package/dist/testing/index.js +4 -4
- package/dist/testing/model-stub.d.ts +4 -0
- package/dist/testing/model-stub.js +8 -1
- package/dist/thinking.d.ts +16 -0
- package/dist/thinking.js +62 -0
- package/dist/transport.d.ts +1 -1
- package/dist/transport.js +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/words.json +5 -1
- package/package.json +6 -5
- package/plugin/keys.js +3 -2
package/dist/accounts.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
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, gone? }`, where `
|
|
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.
|
|
7
9
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
10
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
9
11
|
import { join } from 'node:path';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
import { providerOf, resolveSelection } from './pick.js';
|
|
14
|
+
import { agentOf, memberOf as baseMemberOf } from './attribution.js';
|
|
15
|
+
import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed } from './members.js';
|
|
16
|
+
import { routes, routeFor } from './routes.js';
|
|
17
|
+
import { words } from './words.js';
|
|
15
18
|
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
19
|
// `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
|
|
17
20
|
// reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
|
|
@@ -60,19 +63,18 @@ const validEntry = (v) => !!v && typeof v === 'object'
|
|
|
60
63
|
&& typeof v.route === 'string' && Number.isFinite(v.addedAt)
|
|
61
64
|
&& (v.until === undefined || Number.isFinite(v.until))
|
|
62
65
|
&& (v.model === undefined || typeof v.model === 'string')
|
|
63
|
-
&& (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
|
+
};
|
|
64
76
|
const indexPath = (root, member) => join(root, 'accounts', `${member}.json`);
|
|
65
|
-
const
|
|
66
|
-
let text;
|
|
67
|
-
try {
|
|
68
|
-
text = readFileSync(indexPath(root, member), 'utf8');
|
|
69
|
-
}
|
|
70
|
-
catch (error) {
|
|
71
|
-
if (error.code === 'ENOENT')
|
|
72
|
-
return empty();
|
|
73
|
-
throw error;
|
|
74
|
-
}
|
|
75
|
-
const file = JSON.parse(text);
|
|
77
|
+
const normalizeIndex = (file) => {
|
|
76
78
|
if (!file || typeof file !== 'object' || file.v !== 1)
|
|
77
79
|
return empty();
|
|
78
80
|
const accounts = {};
|
|
@@ -88,8 +90,44 @@ const readIndex = (root, member) => {
|
|
|
88
90
|
if (typeof raw.auto === 'boolean')
|
|
89
91
|
defaults.auto = raw.auto;
|
|
90
92
|
const gone = Array.isArray(file.gone) ? file.gone.filter((a) => typeof a === 'string' && ACCOUNT_AGENT.test(a)) : [];
|
|
91
|
-
|
|
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 } : {}) };
|
|
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;
|
|
92
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
|
+
}
|
|
93
131
|
// Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
|
|
94
132
|
// own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
|
|
95
133
|
// agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
|
|
@@ -110,29 +148,43 @@ export function memberOfAccount(root, ref) {
|
|
|
110
148
|
return undefined;
|
|
111
149
|
}
|
|
112
150
|
}
|
|
113
|
-
// Every agent any member's index names. An
|
|
114
|
-
//
|
|
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.
|
|
115
154
|
function indexedAgents(root) {
|
|
116
155
|
const named = new Set();
|
|
117
156
|
const gone = new Set();
|
|
157
|
+
const damaged = new Set();
|
|
118
158
|
let files;
|
|
119
159
|
try {
|
|
120
160
|
files = readdirSync(join(root, 'accounts'));
|
|
121
161
|
}
|
|
122
162
|
catch (error) {
|
|
123
163
|
if (error.code === 'ENOENT')
|
|
124
|
-
return { named, gone };
|
|
164
|
+
return { named, gone, damaged };
|
|
125
165
|
throw error;
|
|
126
166
|
}
|
|
127
167
|
for (const file of files) {
|
|
128
168
|
if (!file.endsWith('.json'))
|
|
129
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
|
+
}
|
|
130
180
|
let parsed;
|
|
131
181
|
try {
|
|
132
|
-
parsed = JSON.parse(
|
|
182
|
+
parsed = JSON.parse(text);
|
|
133
183
|
}
|
|
134
|
-
catch
|
|
135
|
-
|
|
184
|
+
catch {
|
|
185
|
+
if (MEMBER_ID.test(member))
|
|
186
|
+
damaged.add(member);
|
|
187
|
+
continue;
|
|
136
188
|
}
|
|
137
189
|
for (const entry of Object.values(parsed.accounts ?? {}))
|
|
138
190
|
if (typeof entry?.agent === 'string')
|
|
@@ -141,36 +193,170 @@ function indexedAgents(root) {
|
|
|
141
193
|
if (typeof agent === 'string')
|
|
142
194
|
gone.add(agent);
|
|
143
195
|
}
|
|
144
|
-
return { named, gone };
|
|
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;
|
|
145
201
|
}
|
|
146
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.
|
|
147
204
|
export function removedAccountAgents(root) {
|
|
148
|
-
const { named, gone } = indexedAgents(root);
|
|
149
|
-
return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent)));
|
|
205
|
+
const { named, gone, damaged } = indexedAgents(root);
|
|
206
|
+
return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent) && !heldBackAgent(agent, damaged)));
|
|
150
207
|
}
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
|
156
216
|
try {
|
|
157
|
-
|
|
217
|
+
files = readdirSync(join(root, 'accounts'));
|
|
158
218
|
}
|
|
159
219
|
catch (error) {
|
|
160
220
|
if (error.code === 'ENOENT')
|
|
161
|
-
return;
|
|
221
|
+
return out;
|
|
162
222
|
throw error;
|
|
163
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
|
+
}
|
|
164
341
|
const gone = removedAccountAgents(root);
|
|
165
342
|
for (const dir of dirs) {
|
|
166
343
|
if (!gone.has(dir))
|
|
167
344
|
continue;
|
|
168
345
|
rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
|
|
169
346
|
}
|
|
347
|
+
sweepMemberAuthProfiles(root);
|
|
170
348
|
}
|
|
171
349
|
export function createAccounts(ctx) {
|
|
172
350
|
const path = (member) => indexPath(ctx.root, member);
|
|
173
|
-
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
|
+
};
|
|
174
360
|
// Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
|
|
175
361
|
const signing = new Map();
|
|
176
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.
|
|
@@ -186,6 +372,17 @@ export function createAccounts(ctx) {
|
|
|
186
372
|
};
|
|
187
373
|
const markGone = (file, agent) => { if (!file.gone?.includes(agent))
|
|
188
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
|
+
};
|
|
189
386
|
const write = (member, file) => {
|
|
190
387
|
mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
|
|
191
388
|
const tmp = `${path(member)}.${randomUUID()}.tmp`;
|
|
@@ -234,6 +431,9 @@ export function createAccounts(ctx) {
|
|
|
234
431
|
const memberRows = [];
|
|
235
432
|
const seen = new Set();
|
|
236
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;
|
|
237
437
|
let entry = file.accounts[provider];
|
|
238
438
|
if (!entry || entry.agent !== agentId) {
|
|
239
439
|
const route = entry?.agent === agentId && typeof entry.route === 'string' ? entry.route : synthesizedRoute(provider);
|
|
@@ -283,7 +483,26 @@ export function createAccounts(ctx) {
|
|
|
283
483
|
}
|
|
284
484
|
memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
|
|
285
485
|
agentRows.sort((a, b) => a.addedAt - b.addedAt);
|
|
286
|
-
|
|
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];
|
|
287
506
|
for (const row of rows)
|
|
288
507
|
if (signing.has(`${member}\n${row.id}`))
|
|
289
508
|
row.state = 'signing';
|
|
@@ -441,7 +660,7 @@ export function createAccounts(ctx) {
|
|
|
441
660
|
return roomOf(row.provider, answer);
|
|
442
661
|
});
|
|
443
662
|
// Runs step 2 reads the index alone: the index-only refusal makes no request, and a cold index adds one account
|
|
444
|
-
// 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
|
|
445
664
|
// gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
|
|
446
665
|
const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
|
|
447
666
|
: Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
|
|
@@ -464,23 +683,27 @@ export function createAccounts(ctx) {
|
|
|
464
683
|
return { code: 'bound', name: nameOf(file, ids) };
|
|
465
684
|
return undefined;
|
|
466
685
|
};
|
|
467
|
-
// 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.
|
|
468
688
|
const keyRefusal = async (member, binding) => {
|
|
469
|
-
|
|
470
|
-
if (held && metaOf(held.route).billing === 'api')
|
|
689
|
+
if (binding.agentId === keyAgentId(member))
|
|
471
690
|
return undefined;
|
|
472
691
|
await list(member);
|
|
473
692
|
const file = read(member);
|
|
474
693
|
return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
|
|
475
694
|
};
|
|
476
|
-
// Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account
|
|
477
|
-
//
|
|
478
|
-
// first
|
|
479
|
-
// 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.
|
|
480
700
|
async function bind(member, sessionKey) {
|
|
481
701
|
check(member);
|
|
482
|
-
const
|
|
702
|
+
const file = read(member);
|
|
703
|
+
const agents = new Map(Object.entries(file.accounts)
|
|
483
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];
|
|
484
707
|
if (!sessionKey.startsWith(`agent:${member}:`)) {
|
|
485
708
|
const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
|
|
486
709
|
const id = agent === undefined ? undefined : agents.get(agent);
|
|
@@ -488,12 +711,13 @@ export function createAccounts(ctx) {
|
|
|
488
711
|
throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
|
|
489
712
|
return { agentId: agent, id };
|
|
490
713
|
}
|
|
491
|
-
if (!agents.size)
|
|
714
|
+
if (!agents.size && laneId === undefined)
|
|
492
715
|
return undefined;
|
|
493
716
|
const tail = sessionKey.slice(`agent:${member}:`.length);
|
|
494
717
|
const { agentId } = await ctx.ensure(member);
|
|
495
718
|
const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
|
|
496
|
-
|
|
719
|
+
const laneProbe = laneId === undefined ? [] : [[lane, laneId]];
|
|
720
|
+
for (const [agent, id] of [[agentId, undefined], ...laneProbe, ...agents]) {
|
|
497
721
|
if (agent !== agentId && !roster.has(agent))
|
|
498
722
|
continue;
|
|
499
723
|
// The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
|
|
@@ -503,10 +727,11 @@ export function createAccounts(ctx) {
|
|
|
503
727
|
}
|
|
504
728
|
return undefined;
|
|
505
729
|
}
|
|
506
|
-
// Choosing (5.15): every
|
|
507
|
-
// decides. A binding narrows it (Runs step 2): a key bound to an account agent resolves 'auto', 'default' and no id to
|
|
508
|
-
// that account; a key bound to the
|
|
509
|
-
//
|
|
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.
|
|
510
735
|
// An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
|
|
511
736
|
// only because the account is signed out or resting is still in its plan: the account's state and the run's ready
|
|
512
737
|
// check judge that.
|
|
@@ -524,18 +749,25 @@ export function createAccounts(ctx) {
|
|
|
524
749
|
const late = binding && refusalOf(file, binding, sel);
|
|
525
750
|
if (late)
|
|
526
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: [] } };
|
|
527
754
|
const rows = listed.filter((r) => !skip.includes(r.id));
|
|
528
755
|
const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
|
|
529
756
|
const known = named === undefined || listed.some((r) => r.id === named);
|
|
530
757
|
if (binding?.id !== undefined && known)
|
|
531
758
|
sel = { ...sel, account: binding.id };
|
|
532
759
|
const bound = binding && known ? boundOf(file, binding) : undefined;
|
|
533
|
-
|
|
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) => {
|
|
534
766
|
const entry = file.accounts[row.id];
|
|
535
767
|
const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
|
|
536
768
|
return [row.id, { room: await readRoom(row, entry), models }];
|
|
537
769
|
})));
|
|
538
|
-
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);
|
|
539
771
|
return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
|
|
540
772
|
}
|
|
541
773
|
// Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
|
|
@@ -607,10 +839,127 @@ export function createAccounts(ctx) {
|
|
|
607
839
|
const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
|
|
608
840
|
entry.until = until; });
|
|
609
841
|
const agentDir = (agent) => join(ctx.root, 'state', 'agents', agent);
|
|
610
|
-
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
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
|
+
});
|
|
916
|
+
const identityOf = async (agentId) => {
|
|
917
|
+
const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
|
|
918
|
+
return file.missing ? undefined : file.content;
|
|
919
|
+
};
|
|
920
|
+
const restoreIdentity = async (memberAgent, before) => {
|
|
921
|
+
if (before !== undefined && await identityOf(memberAgent) !== before)
|
|
922
|
+
await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
|
|
923
|
+
};
|
|
924
|
+
const readModel = async (agentId) => {
|
|
925
|
+
const got = await ctx.request('config.get');
|
|
926
|
+
return got.config?.agents?.entries?.[agentId]?.model;
|
|
927
|
+
};
|
|
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.
|
|
930
|
+
const restoreModel = async (memberAgent, model) => {
|
|
931
|
+
const clear = async () => {
|
|
932
|
+
await ctx.request('agents.update', { agentId: memberAgent, model: null });
|
|
933
|
+
};
|
|
934
|
+
const patch = async () => {
|
|
935
|
+
const { hash } = await ctx.request('config.get');
|
|
936
|
+
await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
|
|
937
|
+
};
|
|
938
|
+
try {
|
|
939
|
+
if (model === undefined || model === null) {
|
|
940
|
+
await clear().catch(() => clear());
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
await patch().catch(() => patch());
|
|
944
|
+
}
|
|
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
|
+
}
|
|
955
|
+
ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
|
|
956
|
+
}
|
|
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).
|
|
614
963
|
const providerProfileIds = async (agentId, provider) => {
|
|
615
964
|
const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
|
|
616
965
|
if (listed.ok !== true || !Array.isArray(listed.profiles))
|
|
@@ -621,6 +970,8 @@ export function createAccounts(ctx) {
|
|
|
621
970
|
check(member);
|
|
622
971
|
const { agentId: memberAgent } = await ctx.ensure(member);
|
|
623
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`);
|
|
624
975
|
if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
|
|
625
976
|
throw new Error(`unknown account: ${id}`);
|
|
626
977
|
const agent = entry.agent;
|
|
@@ -635,7 +986,11 @@ export function createAccounts(ctx) {
|
|
|
635
986
|
if ((await providerProfileIds(agent, provider)).length)
|
|
636
987
|
throw new Error('removeAccount: the account is still signing in');
|
|
637
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);
|
|
638
992
|
dropEntry(file, id);
|
|
993
|
+
markDropped(file, provider);
|
|
639
994
|
write(member, file);
|
|
640
995
|
// The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
|
|
641
996
|
await ctx.reseal?.();
|
|
@@ -663,38 +1018,6 @@ export function createAccounts(ctx) {
|
|
|
663
1018
|
await ctx.reseal?.();
|
|
664
1019
|
});
|
|
665
1020
|
}
|
|
666
|
-
// Index edits take the index lock, as listing's own write does.
|
|
667
|
-
const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
|
|
668
|
-
const identityOf = async (agentId) => {
|
|
669
|
-
const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
|
|
670
|
-
return file.missing ? undefined : file.content;
|
|
671
|
-
};
|
|
672
|
-
const restoreIdentity = async (memberAgent, before) => {
|
|
673
|
-
if (before !== undefined && await identityOf(memberAgent) !== before)
|
|
674
|
-
await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
|
|
675
|
-
};
|
|
676
|
-
const readModel = async (agentId) => {
|
|
677
|
-
const got = await ctx.request('config.get');
|
|
678
|
-
return got.config?.agents?.entries?.[agentId]?.model;
|
|
679
|
-
};
|
|
680
|
-
// ponytail: a merge patch cannot delete a key, so a member with no model entry before its sign-in keeps the pinned one.
|
|
681
|
-
const restoreModel = async (memberAgent, model) => {
|
|
682
|
-
if (model === undefined) {
|
|
683
|
-
ctx.log?.(`addAccount: ${memberAgent} had no model entry to restore`);
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
// A config write that lands between the read and the patch moves the hash: read and patch once more.
|
|
687
|
-
const patch = async () => {
|
|
688
|
-
const { hash } = await ctx.request('config.get');
|
|
689
|
-
await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
|
|
690
|
-
};
|
|
691
|
-
try {
|
|
692
|
-
await patch().catch(() => patch());
|
|
693
|
-
}
|
|
694
|
-
catch (error) {
|
|
695
|
-
ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
|
|
696
|
-
}
|
|
697
|
-
};
|
|
698
1021
|
const onGateway = (agent) => untilListed(ctx.request, agent)
|
|
699
1022
|
.then(() => true, () => ctx.request('agents.list')
|
|
700
1023
|
.then((r) => r.agents.some((a) => a.id === agent)));
|
|
@@ -746,18 +1069,27 @@ export function createAccounts(ctx) {
|
|
|
746
1069
|
if (fresh)
|
|
747
1070
|
releaseAdd();
|
|
748
1071
|
}
|
|
749
|
-
// 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).
|
|
750
1074
|
const memberModel = await readModel(memberAgent);
|
|
751
|
-
const entryOf = (file, agent) => ({ agent, route: route.choice, addedAt: Date.now(),
|
|
752
|
-
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 } : {}) });
|
|
753
1078
|
let id = provider;
|
|
754
1079
|
let agent = memberAgent;
|
|
755
1080
|
let added = false;
|
|
756
1081
|
if (!fresh) {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
+
});
|
|
761
1093
|
}
|
|
762
1094
|
else {
|
|
763
1095
|
// `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
|
|
@@ -948,5 +1280,5 @@ export function createAccounts(ctx) {
|
|
|
948
1280
|
throw error;
|
|
949
1281
|
}
|
|
950
1282
|
}
|
|
951
|
-
return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move, remove };
|
|
1283
|
+
return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, key, unkey, backfill, pick, rest, move, remove };
|
|
952
1284
|
}
|