@byokit/openclaw 0.11.0 → 0.12.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 +27 -0
- package/README.md +45 -7
- package/SECURITY.md +3 -2
- package/dist/accounts.d.ts +45 -1
- package/dist/accounts.js +641 -46
- package/dist/activation.d.ts +17 -0
- package/dist/activation.js +9 -0
- package/dist/approvals.d.ts +2 -0
- package/dist/approvals.js +9 -9
- package/dist/attribution.d.ts +3 -0
- package/dist/attribution.js +25 -0
- package/dist/bridge.d.ts +3 -1
- package/dist/bridge.js +4 -6
- package/dist/config.js +10 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -0
- package/dist/device.d.ts +5 -2
- package/dist/device.js +11 -1
- package/dist/engine.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/keys.d.ts +19 -5
- package/dist/keys.js +135 -23
- package/dist/kit.d.ts +38 -1
- package/dist/kit.js +153 -28
- package/dist/link.js +21 -11
- package/dist/members.d.ts +3 -0
- package/dist/members.js +13 -9
- package/dist/pick.d.ts +7 -3
- package/dist/pick.js +34 -5
- package/dist/routes.d.ts +1 -0
- package/dist/routes.js +5 -3
- package/dist/routes.json +31 -31
- package/dist/runs.d.ts +7 -0
- package/dist/runs.js +111 -11
- package/dist/signin.d.ts +3 -0
- package/dist/signin.js +51 -17
- package/dist/testing/fake-gateway.d.ts +18 -0
- package/dist/testing/fake-gateway.js +210 -16
- package/dist/testing/model-stub.d.ts +1 -0
- package/dist/testing/model-stub.js +2 -1
- package/dist/types.d.ts +19 -0
- package/dist/words.json +8 -1
- package/package.json +2 -2
- package/plugin/accounts.js +12 -3
- package/plugin/catalog.js +48 -0
- package/plugin/index.js +2 -1
- package/plugin/keys.js +22 -4
package/dist/accounts.js
CHANGED
|
@@ -1,14 +1,58 @@
|
|
|
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
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
import {
|
|
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';
|
|
7
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
|
-
import {
|
|
9
|
+
import { providerOf, resolveSelection } from "./pick.js";
|
|
10
|
+
import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
|
|
11
|
+
import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
|
|
9
12
|
import { routes, routeFor } from "./routes.js";
|
|
13
|
+
import { words } from "./words.js";
|
|
14
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
15
|
+
// `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
|
|
16
|
+
// reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
|
|
17
|
+
export const modelsOf = (provider, answer) => {
|
|
18
|
+
const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const candidate of rows) {
|
|
21
|
+
if (!record(candidate) || typeof candidate.provider !== 'string'
|
|
22
|
+
|| candidate.provider.toLowerCase() !== provider.toLowerCase())
|
|
23
|
+
continue;
|
|
24
|
+
if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
|
|
25
|
+
continue;
|
|
26
|
+
const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
|
|
27
|
+
if (providerOf(id) !== provider.toLowerCase())
|
|
28
|
+
continue;
|
|
29
|
+
const available = candidate.available === undefined
|
|
30
|
+
? candidate.unavailableReason === undefined : candidate.available === true;
|
|
31
|
+
const info = { id, name: candidate.name, available };
|
|
32
|
+
if ((candidate.tier === 'strong' || candidate.tier === 'fast'))
|
|
33
|
+
info.tier = candidate.tier;
|
|
34
|
+
if (!available) {
|
|
35
|
+
if (candidate.unavailableReason === 'missing-auth' || candidate.unavailableReason === 'auth-failed')
|
|
36
|
+
info.why = 'signed_out';
|
|
37
|
+
else if (candidate.unavailableReason === 'cooldown') {
|
|
38
|
+
info.why = 'resting';
|
|
39
|
+
if (typeof candidate.unavailableUntil === 'number' && Number.isFinite(candidate.unavailableUntil))
|
|
40
|
+
info.until = candidate.unavailableUntil;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
out.push(info);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
10
47
|
const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
|
|
11
48
|
const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
|
|
49
|
+
const IDENTITY = 'IDENTITY.md';
|
|
50
|
+
const named = (name, op) => {
|
|
51
|
+
const trimmed = typeof name === 'string' ? name.trim() : '';
|
|
52
|
+
if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed))
|
|
53
|
+
throw new Error(`${op}: name is empty`);
|
|
54
|
+
return trimmed;
|
|
55
|
+
};
|
|
12
56
|
const empty = () => ({ v: 1, accounts: {}, defaults: {} });
|
|
13
57
|
const validEntry = (v) => !!v && typeof v === 'object'
|
|
14
58
|
&& typeof v.agent === 'string' && typeof v.name === 'string'
|
|
@@ -16,39 +60,65 @@ const validEntry = (v) => !!v && typeof v === 'object'
|
|
|
16
60
|
&& (v.until === undefined || Number.isFinite(v.until))
|
|
17
61
|
&& (v.model === undefined || typeof v.model === 'string')
|
|
18
62
|
&& (v.who === undefined || typeof v.who === 'string');
|
|
63
|
+
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);
|
|
75
|
+
if (!file || typeof file !== 'object' || file.v !== 1)
|
|
76
|
+
return empty();
|
|
77
|
+
const accounts = {};
|
|
78
|
+
for (const [id, entry] of Object.entries(file.accounts ?? {}))
|
|
79
|
+
if (validEntry(entry))
|
|
80
|
+
accounts[id] = entry;
|
|
81
|
+
const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
|
|
82
|
+
const defaults = {};
|
|
83
|
+
if (typeof raw.account === 'string')
|
|
84
|
+
defaults.account = raw.account;
|
|
85
|
+
if (typeof raw.model === 'string')
|
|
86
|
+
defaults.model = raw.model;
|
|
87
|
+
if (typeof raw.auto === 'boolean')
|
|
88
|
+
defaults.auto = raw.auto;
|
|
89
|
+
return { v: 1, accounts, defaults };
|
|
90
|
+
};
|
|
91
|
+
// Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
|
|
92
|
+
// own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
|
|
93
|
+
// agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
|
|
94
|
+
// agents share their member's workspace, so an unindexed account-shaped agent maps nowhere and stays unreachable.
|
|
95
|
+
export function memberOfAccount(root, ref) {
|
|
96
|
+
const agent = agentOf(ref);
|
|
97
|
+
if (agent === undefined)
|
|
98
|
+
return undefined;
|
|
99
|
+
const member = baseMemberOf(agent);
|
|
100
|
+
if (member === undefined || agent === member)
|
|
101
|
+
return member;
|
|
102
|
+
if (MEMBER_ID.test(agent) && existsSync(join(root, 'workspaces', agent)))
|
|
103
|
+
return agent;
|
|
104
|
+
try {
|
|
105
|
+
return Object.values(readIndex(root, member).accounts).some((e) => e.agent === agent) ? member : undefined;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
19
111
|
export function createAccounts(ctx) {
|
|
20
|
-
const path = (member) =>
|
|
112
|
+
const path = (member) => indexPath(ctx.root, member);
|
|
113
|
+
const read = (member) => readIndex(ctx.root, member);
|
|
114
|
+
// Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
|
|
115
|
+
const signing = new Map();
|
|
116
|
+
// The index file's own lock, apart from any agent's: a run's shared hold on the member agent never blocks an edit.
|
|
117
|
+
const indexLock = (member) => `${member}#index`;
|
|
21
118
|
const check = (member) => {
|
|
22
119
|
if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
|
|
23
120
|
throw new Error(`invalid member id: ${member}`);
|
|
24
121
|
};
|
|
25
|
-
const read = (member) => {
|
|
26
|
-
let text;
|
|
27
|
-
try {
|
|
28
|
-
text = readFileSync(path(member), 'utf8');
|
|
29
|
-
}
|
|
30
|
-
catch (error) {
|
|
31
|
-
if (error.code === 'ENOENT')
|
|
32
|
-
return empty();
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
const file = JSON.parse(text);
|
|
36
|
-
if (!file || typeof file !== 'object' || file.v !== 1)
|
|
37
|
-
return empty();
|
|
38
|
-
const accounts = {};
|
|
39
|
-
for (const [id, entry] of Object.entries(file.accounts ?? {}))
|
|
40
|
-
if (validEntry(entry))
|
|
41
|
-
accounts[id] = entry;
|
|
42
|
-
const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
|
|
43
|
-
const defaults = {};
|
|
44
|
-
if (typeof raw.account === 'string')
|
|
45
|
-
defaults.account = raw.account;
|
|
46
|
-
if (typeof raw.model === 'string')
|
|
47
|
-
defaults.model = raw.model;
|
|
48
|
-
if (typeof raw.auto === 'boolean')
|
|
49
|
-
defaults.auto = raw.auto;
|
|
50
|
-
return { v: 1, accounts, defaults };
|
|
51
|
-
};
|
|
52
122
|
const write = (member, file) => {
|
|
53
123
|
mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
|
|
54
124
|
const tmp = `${path(member)}.${randomUUID()}.tmp`;
|
|
@@ -105,7 +175,8 @@ export function createAccounts(ctx) {
|
|
|
105
175
|
file.accounts[provider] = entry;
|
|
106
176
|
dirty = true;
|
|
107
177
|
}
|
|
108
|
-
|
|
178
|
+
// Not while the member agent's own sign-in runs: settle decides whose that `who` is (it may be adopted away).
|
|
179
|
+
if (typeof profiles[0]?.who === 'string' && entry.who !== profiles[0].who && !signing.has(`${member}\n${provider}`)) {
|
|
109
180
|
entry.who = profiles[0].who;
|
|
110
181
|
dirty = true;
|
|
111
182
|
}
|
|
@@ -134,6 +205,11 @@ export function createAccounts(ctx) {
|
|
|
134
205
|
const provider = id.split('.')[0];
|
|
135
206
|
const meta = metaOf(entry.route);
|
|
136
207
|
const profiles = await profilesOf(entry.agent);
|
|
208
|
+
const who = profiles.find((p) => p.provider === provider)?.who;
|
|
209
|
+
if (!entry.who && typeof who === 'string' && !signing.has(`${member}\n${id}`)) {
|
|
210
|
+
entry.who = who;
|
|
211
|
+
dirty = true;
|
|
212
|
+
}
|
|
137
213
|
agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
|
|
138
214
|
billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
|
|
139
215
|
...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
|
|
@@ -141,6 +217,9 @@ export function createAccounts(ctx) {
|
|
|
141
217
|
memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
|
|
142
218
|
agentRows.sort((a, b) => a.addedAt - b.addedAt);
|
|
143
219
|
const rows = [...memberRows, ...agentRows];
|
|
220
|
+
for (const row of rows)
|
|
221
|
+
if (signing.has(`${member}\n${row.id}`))
|
|
222
|
+
row.state = 'signing';
|
|
144
223
|
if (file.defaults.account !== undefined && !rows.some((r) => r.id === file.defaults.account)) {
|
|
145
224
|
delete file.defaults.account;
|
|
146
225
|
dirty = true;
|
|
@@ -153,15 +232,68 @@ export function createAccounts(ctx) {
|
|
|
153
232
|
const [roster, profiles] = await gather(agentId);
|
|
154
233
|
return { ...(await build(member, agentId, file, roster, profiles)), file };
|
|
155
234
|
};
|
|
235
|
+
// The account's index entry, only when `list` names it: an unindexed agent is never reachable, whatever the
|
|
236
|
+
// file holds.
|
|
237
|
+
const entryOf = async (member, id) => {
|
|
238
|
+
const row = (await list(member)).find((r) => r.id === id);
|
|
239
|
+
const entry = read(member).accounts[id];
|
|
240
|
+
if (!row || !entry)
|
|
241
|
+
throw new Error(`unknown account: ${id}`);
|
|
242
|
+
return { row, entry };
|
|
243
|
+
};
|
|
244
|
+
const spanOf = (label) => {
|
|
245
|
+
const lower = label.toLowerCase();
|
|
246
|
+
if (lower === 'session')
|
|
247
|
+
return 'session';
|
|
248
|
+
if (lower === 'weekly')
|
|
249
|
+
return 'week';
|
|
250
|
+
if (lower === 'monthly')
|
|
251
|
+
return 'month';
|
|
252
|
+
return 'tightest';
|
|
253
|
+
};
|
|
254
|
+
// The `models.authStatus { agentId }` row for the account's provider: of its `usage.windows` entries the one
|
|
255
|
+
// with the highest numeric `usedPercent` gives `left`, `resetsAt` and `span`. A window applies to every model
|
|
256
|
+
// unless the engine scopes it to models; the pin's rows carry no scope, so the demand never narrows them today
|
|
257
|
+
// (the tightest known). A window without a numeric `usedPercent` is skipped, never read as unused. `at` is left
|
|
258
|
+
// out: the pin exposes no reading age, so the kit never stamps receipt time as reading time. The kit adds no
|
|
259
|
+
// cache: every call reads live.
|
|
260
|
+
const roomOf = (provider, answer) => {
|
|
261
|
+
const unknown = { left: 'unknown' };
|
|
262
|
+
const rows = record(answer) && Array.isArray(answer.providers) ? answer.providers : undefined;
|
|
263
|
+
if (!rows)
|
|
264
|
+
return unknown;
|
|
265
|
+
const row = rows.find((r) => record(r) && typeof r.provider === 'string'
|
|
266
|
+
&& r.provider.toLowerCase() === provider.toLowerCase());
|
|
267
|
+
if (!row || !record(row))
|
|
268
|
+
return unknown;
|
|
269
|
+
const windows = record(row.usage) && Array.isArray(row.usage.windows) ? row.usage.windows : undefined;
|
|
270
|
+
if (!windows)
|
|
271
|
+
return unknown;
|
|
272
|
+
let best;
|
|
273
|
+
for (const candidate of windows) {
|
|
274
|
+
if (!record(candidate))
|
|
275
|
+
continue;
|
|
276
|
+
if (typeof candidate.usedPercent !== 'number' || !Number.isFinite(candidate.usedPercent))
|
|
277
|
+
continue;
|
|
278
|
+
const span = spanOf(typeof candidate.label === 'string' ? candidate.label : '');
|
|
279
|
+
const resetAt = typeof candidate.resetAt === 'number' && Number.isFinite(candidate.resetAt)
|
|
280
|
+
? candidate.resetAt : undefined;
|
|
281
|
+
if (!best || candidate.usedPercent > best.used)
|
|
282
|
+
best = { used: candidate.usedPercent, ...(resetAt !== undefined ? { resetAt } : {}), span };
|
|
283
|
+
}
|
|
284
|
+
if (!best)
|
|
285
|
+
return unknown;
|
|
286
|
+
return { left: 100 - best.used, span: best.span, ...(best.resetAt !== undefined ? { resetsAt: best.resetAt } : {}) };
|
|
287
|
+
};
|
|
156
288
|
async function list(member) {
|
|
157
289
|
check(member);
|
|
158
290
|
const { agentId } = await ctx.ensure(member);
|
|
159
|
-
const first = await ctx.locks.shared(
|
|
291
|
+
const first = await ctx.locks.shared(indexLock(member), () => buildRows(member, agentId));
|
|
160
292
|
if (!first.dirty)
|
|
161
293
|
return first.rows;
|
|
162
294
|
// A write is needed (synthesized entry, `who` backfill or dropped default): redo it under the
|
|
163
295
|
// exclusive lock so two first-lists cannot interleave their read-modify-write.
|
|
164
|
-
return ctx.locks.exclusive([
|
|
296
|
+
return ctx.locks.exclusive([indexLock(member)], async () => {
|
|
165
297
|
const { rows, dirty, file } = await buildRows(member, agentId);
|
|
166
298
|
if (dirty)
|
|
167
299
|
write(member, file);
|
|
@@ -170,15 +302,10 @@ export function createAccounts(ctx) {
|
|
|
170
302
|
}
|
|
171
303
|
async function rename(member, id, name) {
|
|
172
304
|
check(member);
|
|
173
|
-
|
|
174
|
-
throw new Error('renameAccount: name is empty');
|
|
175
|
-
const trimmed = name.trim();
|
|
176
|
-
if (!trimmed || trimmed.length > 100 || /[\u0000-\u001f\u007f]/.test(trimmed))
|
|
177
|
-
throw new Error('renameAccount: name is empty');
|
|
178
|
-
const { agentId } = await ctx.ensure(member);
|
|
305
|
+
const trimmed = named(name, 'renameAccount');
|
|
179
306
|
if (!(await list(member)).some((r) => r.id === id))
|
|
180
307
|
throw new Error(`unknown account: ${id}`);
|
|
181
|
-
await ctx.locks.exclusive([
|
|
308
|
+
await ctx.locks.exclusive([indexLock(member)], async () => {
|
|
182
309
|
const file = read(member);
|
|
183
310
|
const entry = file.accounts[id];
|
|
184
311
|
if (!entry)
|
|
@@ -205,8 +332,8 @@ export function createAccounts(ctx) {
|
|
|
205
332
|
throw new Error('setDefaults: defaults are invalid');
|
|
206
333
|
if (d.auto !== undefined && typeof d.auto !== 'boolean')
|
|
207
334
|
throw new Error('setDefaults: defaults are invalid');
|
|
208
|
-
|
|
209
|
-
return ctx.locks.exclusive([
|
|
335
|
+
await ctx.ensure(member);
|
|
336
|
+
return ctx.locks.exclusive([indexLock(member)], async () => {
|
|
210
337
|
const file = read(member);
|
|
211
338
|
const next = {};
|
|
212
339
|
if (d.account !== undefined)
|
|
@@ -218,5 +345,473 @@ export function createAccounts(ctx) {
|
|
|
218
345
|
write(member, { ...file, defaults: next });
|
|
219
346
|
});
|
|
220
347
|
}
|
|
221
|
-
|
|
348
|
+
async function models(member, id) {
|
|
349
|
+
check(member);
|
|
350
|
+
await ctx.ensure(member);
|
|
351
|
+
const { row, entry } = await entryOf(member, id);
|
|
352
|
+
return readModels(row, entry);
|
|
353
|
+
}
|
|
354
|
+
// The shared hold covers the read, so no kit read of the agent is in flight while it is removed.
|
|
355
|
+
const readModels = (row, entry) => ctx.locks.shared(entry.agent, async () => modelsOf(row.provider, await ctx.request('models.list', { agentId: entry.agent, view: 'default' })));
|
|
356
|
+
async function room(member, id, demand) {
|
|
357
|
+
check(member);
|
|
358
|
+
await ctx.ensure(member);
|
|
359
|
+
const { row, entry } = await entryOf(member, id);
|
|
360
|
+
return readRoom(row, entry);
|
|
361
|
+
}
|
|
362
|
+
// An API-key account is used only when chosen, never by Auto: its room is always unknown, with no read. A demand
|
|
363
|
+
// never narrows the windows (see `roomOf`), so the read takes none.
|
|
364
|
+
const readRoom = (row, entry) => row.billing === 'api'
|
|
365
|
+
? Promise.resolve({ left: 'unknown' })
|
|
366
|
+
: ctx.locks.shared(entry.agent, async () => {
|
|
367
|
+
let answer;
|
|
368
|
+
try {
|
|
369
|
+
answer = await ctx.request('models.authStatus', { agentId: entry.agent }, { timeoutMs: 20_000 });
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return { left: 'unknown' };
|
|
373
|
+
}
|
|
374
|
+
return roomOf(row.provider, answer);
|
|
375
|
+
});
|
|
376
|
+
// 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
|
|
378
|
+
// gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
|
|
379
|
+
const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
|
|
380
|
+
: Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
|
|
381
|
+
.sort(([a, x], [b, y]) => x.addedAt - y.addedAt || (a < b ? -1 : 1)).map(([id]) => id);
|
|
382
|
+
const nameOf = (file, ids) => {
|
|
383
|
+
const first = ids[0] === undefined ? undefined : file.accounts[ids[0]];
|
|
384
|
+
return first?.name ?? words('account.another');
|
|
385
|
+
};
|
|
386
|
+
// A pick's refusal: `bound` names an id outside the binding's accounts, `paid` an id other than an API-billed
|
|
387
|
+
// binding's own. A named id the index does not hold is left to the pick, which reports it as unknown.
|
|
388
|
+
const refusalOf = (file, binding, sel) => {
|
|
389
|
+
const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
|
|
390
|
+
if (named !== undefined && file.accounts[named] === undefined)
|
|
391
|
+
return undefined;
|
|
392
|
+
const ids = boundOf(file, binding);
|
|
393
|
+
const held = binding.id === undefined ? undefined : file.accounts[binding.id];
|
|
394
|
+
if (held && metaOf(held.route).billing === 'api' && named !== binding.id)
|
|
395
|
+
return { code: 'paid', name: held.name };
|
|
396
|
+
if (named !== undefined && !ids.includes(named))
|
|
397
|
+
return { code: 'bound', name: nameOf(file, ids) };
|
|
398
|
+
return undefined;
|
|
399
|
+
};
|
|
400
|
+
// An API-key run (Runs step 2): its key continues only an API-billed binding; any other binding is refused.
|
|
401
|
+
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')
|
|
404
|
+
return undefined;
|
|
405
|
+
await list(member);
|
|
406
|
+
const file = read(member);
|
|
407
|
+
return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
|
|
408
|
+
};
|
|
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.
|
|
413
|
+
async function bind(member, sessionKey) {
|
|
414
|
+
check(member);
|
|
415
|
+
const agents = new Map(Object.entries(read(member).accounts)
|
|
416
|
+
.filter(([, e]) => ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`)).map(([id, e]) => [e.agent, id]));
|
|
417
|
+
if (!sessionKey.startsWith(`agent:${member}:`)) {
|
|
418
|
+
const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
|
|
419
|
+
const id = agent === undefined ? undefined : agents.get(agent);
|
|
420
|
+
if (id === undefined)
|
|
421
|
+
throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
|
|
422
|
+
return { agentId: agent, id };
|
|
423
|
+
}
|
|
424
|
+
if (!agents.size)
|
|
425
|
+
return undefined;
|
|
426
|
+
const tail = sessionKey.slice(`agent:${member}:`.length);
|
|
427
|
+
const { agentId } = await ctx.ensure(member);
|
|
428
|
+
const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
|
|
429
|
+
for (const [agent, id] of [[agentId, undefined], ...agents]) {
|
|
430
|
+
if (agent !== agentId && !roster.has(agent))
|
|
431
|
+
continue;
|
|
432
|
+
// The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
|
|
433
|
+
const found = await ctx.request('sessions.resolve', { key: `agent:${agent}:${tail}`, agentId: agent, allowMissing: true });
|
|
434
|
+
if (found.key !== undefined)
|
|
435
|
+
return id === undefined ? { agentId: agent } : { agentId: agent, id };
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
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.
|
|
443
|
+
// An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
|
|
444
|
+
// only because the account is signed out or resting is still in its plan: the account's state and the run's ready
|
|
445
|
+
// check judge that.
|
|
446
|
+
// The pinned engine may hold no prepared model catalog for an agent added since it started, and refuses its models; that
|
|
447
|
+
// row's models are then unknown (`undefined`), and `resolveSelection` host-validates its membership by provider.
|
|
448
|
+
async function pick(member, sel, skip = [], binding) {
|
|
449
|
+
check(member);
|
|
450
|
+
const refused = (refusal) => ({ pick: { ok: false, code: refusal.code,
|
|
451
|
+
reason: words(`account.${refusal.code}`, { name: refusal.name }), considered: [] } });
|
|
452
|
+
const early = binding?.id !== undefined ? refusalOf(read(member), binding, sel) : undefined;
|
|
453
|
+
if (early)
|
|
454
|
+
return refused(early);
|
|
455
|
+
const listed = await list(member);
|
|
456
|
+
const file = read(member);
|
|
457
|
+
const late = binding && refusalOf(file, binding, sel);
|
|
458
|
+
if (late)
|
|
459
|
+
return refused(late);
|
|
460
|
+
const rows = listed.filter((r) => !skip.includes(r.id));
|
|
461
|
+
const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
|
|
462
|
+
const known = named === undefined || listed.some((r) => r.id === named);
|
|
463
|
+
if (binding?.id !== undefined && known)
|
|
464
|
+
sel = { ...sel, account: binding.id };
|
|
465
|
+
const bound = binding && known ? boundOf(file, binding) : undefined;
|
|
466
|
+
const reads = new Map(await Promise.all(rows.map(async (row) => {
|
|
467
|
+
const entry = file.accounts[row.id];
|
|
468
|
+
const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
|
|
469
|
+
return [row.id, { room: await readRoom(row, entry), models }];
|
|
470
|
+
})));
|
|
471
|
+
const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
|
|
472
|
+
return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
|
|
473
|
+
}
|
|
474
|
+
// Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
|
|
475
|
+
// nothing, and the member agent holds it if anything does), and its `sessions.list` row decides unsupported and busy
|
|
476
|
+
// before any lock. Same agent: nothing moves, the next run names the account. Otherwise, under both agents' locks and
|
|
477
|
+
// on the row read again there, the source's whole transcript forks into `agent:<target>:<tail>` (never `chat.history`
|
|
478
|
+
// plus `chat.inject`, which drop tool rows), then the source is deleted only if it is still the session read. A
|
|
479
|
+
// refused fork leaves the source untouched; a refused delete leaves both, the new key live.
|
|
480
|
+
async function move(member, sessionKey, to) {
|
|
481
|
+
check(member);
|
|
482
|
+
const failed = (code, message, live) => ({ ok: false, code, message, ...(live === undefined ? {} : { live }) });
|
|
483
|
+
const { agentId: memberAgent } = await ctx.ensure(member);
|
|
484
|
+
const { agentId: source } = await bind(member, sessionKey) ?? { agentId: memberAgent };
|
|
485
|
+
const { row, entry } = await entryOf(member, to);
|
|
486
|
+
const tail = sessionKey.slice(sessionKey.indexOf(':', 'agent:'.length) + 1);
|
|
487
|
+
const from = `agent:${source}:${tail}`;
|
|
488
|
+
const into = `agent:${entry.agent}:${tail}`;
|
|
489
|
+
const read = async () => {
|
|
490
|
+
const listed = await ctx.request('sessions.list', { agentId: source, search: from });
|
|
491
|
+
const found = (Array.isArray(listed.sessions) ? listed.sessions : [])
|
|
492
|
+
.find((r) => record(r) && typeof r.key === 'string' && r.key.toLowerCase() === from.toLowerCase());
|
|
493
|
+
if (!found)
|
|
494
|
+
return { refused: failed('unsupported', words('move.unsupported')) };
|
|
495
|
+
const live = found.hasActiveRun === true || [...ctx.routes].some(([key, route]) => key === sessionKey || route.session === from);
|
|
496
|
+
return live ? { refused: failed('busy', words('move.busy')) } : { found };
|
|
497
|
+
};
|
|
498
|
+
const first = await read();
|
|
499
|
+
if ('refused' in first)
|
|
500
|
+
return first.refused;
|
|
501
|
+
if (row.state !== 'ready')
|
|
502
|
+
return failed('start_failed', words('move.notReady', { name: row.name }));
|
|
503
|
+
if (entry.agent === source)
|
|
504
|
+
return { ok: true, session: into };
|
|
505
|
+
return ctx.locks.exclusive([source, entry.agent], async () => {
|
|
506
|
+
const again = await read();
|
|
507
|
+
if ('refused' in again)
|
|
508
|
+
return again.refused;
|
|
509
|
+
const session = again.found;
|
|
510
|
+
// A conversation already under the target key is never overwritten: the move is refused, the source untouched.
|
|
511
|
+
const taken = await ctx.request('sessions.resolve', { key: into, agentId: entry.agent, allowMissing: true });
|
|
512
|
+
if (taken.key !== undefined)
|
|
513
|
+
return failed('start_failed', words('move.taken', { name: row.name }));
|
|
514
|
+
// The session's model when the target lists it, else the target's first available; the read takes no shared
|
|
515
|
+
// hold (this move holds the target exclusively). With no answer the fork keeps the parent's own selection.
|
|
516
|
+
const models = await ctx.request('models.list', { agentId: entry.agent, view: 'default' })
|
|
517
|
+
.then((answer) => modelsOf(row.provider, answer), () => []);
|
|
518
|
+
const current = typeof session.model !== 'string' ? undefined
|
|
519
|
+
: (typeof session.modelProvider === 'string' && !session.model.includes('/') ? `${session.modelProvider}/${session.model}` : session.model).toLowerCase();
|
|
520
|
+
const model = models.find((m) => m.id.toLowerCase() === current)?.id ?? models.find((m) => m.available)?.id;
|
|
521
|
+
try {
|
|
522
|
+
await ctx.request('sessions.create', { agentId: entry.agent, key: into, parentSessionKey: from, fork: true, ...(model ? { model } : {}) });
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
return failed('start_failed', (error instanceof Error ? error.message : String(error)).slice(0, 200));
|
|
526
|
+
}
|
|
527
|
+
const closed = await ctx.request('sessions.delete', { key: from, agentId: source, deleteTranscript: true,
|
|
528
|
+
...(typeof session.sessionId === 'string' ? { expectedSessionId: session.sessionId } : {}),
|
|
529
|
+
...(typeof session.updatedAt === 'number' ? { expectedSessionUpdatedAt: session.updatedAt } : {}) })
|
|
530
|
+
.then((answer) => record(answer) && answer.deleted === true, (error) => {
|
|
531
|
+
ctx.log?.(`move: ${from} is kept: ${error instanceof Error ? error.message : String(error)}`);
|
|
532
|
+
return false;
|
|
533
|
+
});
|
|
534
|
+
if (!closed)
|
|
535
|
+
return failed('close_failed', words('move.closeFailed', { name: row.name }), into);
|
|
536
|
+
return { ok: true, session: into };
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
// A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
|
|
540
|
+
const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
|
|
541
|
+
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); });
|
|
544
|
+
const identityOf = async (agentId) => {
|
|
545
|
+
const { file } = await ctx.request('agents.files.get', { agentId, name: IDENTITY });
|
|
546
|
+
return file.missing ? undefined : file.content;
|
|
547
|
+
};
|
|
548
|
+
const restoreIdentity = async (memberAgent, before) => {
|
|
549
|
+
if (before !== undefined && await identityOf(memberAgent) !== before)
|
|
550
|
+
await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
|
|
551
|
+
};
|
|
552
|
+
const readModel = async (agentId) => {
|
|
553
|
+
const got = await ctx.request('config.get');
|
|
554
|
+
return got.config?.agents?.entries?.[agentId]?.model;
|
|
555
|
+
};
|
|
556
|
+
// ponytail: a merge patch cannot delete a key, so a member with no model entry before its sign-in keeps the pinned one.
|
|
557
|
+
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.
|
|
563
|
+
const patch = async () => {
|
|
564
|
+
const { hash } = await ctx.request('config.get');
|
|
565
|
+
await ctx.request('config.patch', { raw: JSON.stringify({ agents: { entries: { [memberAgent]: { model } } } }), baseHash: hash });
|
|
566
|
+
};
|
|
567
|
+
try {
|
|
568
|
+
await patch().catch(() => patch());
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
ctx.log?.(`addAccount: ${memberAgent} model is not restored: ${error instanceof Error ? error.message : String(error)}`);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
const onGateway = (agent) => untilListed(ctx.request, agent)
|
|
575
|
+
.then(() => true, () => ctx.request('agents.list')
|
|
576
|
+
.then((r) => r.agents.some((a) => a.id === agent)));
|
|
577
|
+
// A new agent whose sign-in did not finish goes with its entry (5.15); the shared workspace stays. The entry is
|
|
578
|
+
// dropped only once the agent's directory is gone, so a later removal can finish what this one could not.
|
|
579
|
+
const discard = (member, id, agent, created) => (async () => {
|
|
580
|
+
if (created) {
|
|
581
|
+
await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
|
|
582
|
+
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
583
|
+
}
|
|
584
|
+
rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
|
|
585
|
+
await edit(member, (file) => { delete file.accounts[id]; });
|
|
586
|
+
})().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
587
|
+
// Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
|
|
588
|
+
// profile its own add activated, and the next add's read sees what the earlier one left.
|
|
589
|
+
const holdAdd = (member, provider) => new Promise((held) => {
|
|
590
|
+
void ctx.locks.exclusive([`add:${member}:${provider}`], () => new Promise((release) => held(release)));
|
|
591
|
+
});
|
|
592
|
+
async function add(member, o, on) {
|
|
593
|
+
check(member);
|
|
594
|
+
if (o && typeof o === 'object' && 'key' in o)
|
|
595
|
+
throw new Error('addAccount: keys are host-only');
|
|
596
|
+
if (!NEW_MEMBER_ID.test(member))
|
|
597
|
+
throw new Error(words('account.legacyMember'));
|
|
598
|
+
const choice = o?.authChoice === 'openai-codex' ? 'openai' : o?.authChoice;
|
|
599
|
+
const route = routes().find((r) => r.choice === choice && r.billing === 'subscription');
|
|
600
|
+
if (!route)
|
|
601
|
+
throw new Error(`addAccount: ${String(o?.authChoice)} is not a subscription sign-in`);
|
|
602
|
+
const name = o.name === undefined ? undefined : named(o.name, 'addAccount');
|
|
603
|
+
const provider = route.provider;
|
|
604
|
+
const { agentId: memberAgent, workspace } = await ctx.ensure(member);
|
|
605
|
+
if (o.again !== undefined && (!read(member).accounts[o.again] || o.again.split('.')[0] !== provider))
|
|
606
|
+
throw new Error(`unknown account: ${o.again}`);
|
|
607
|
+
let releaseAdd = () => { };
|
|
608
|
+
try {
|
|
609
|
+
// The member agent is the target only while it holds no local profile of the provider (5.15).
|
|
610
|
+
const holdsProvider = async () => {
|
|
611
|
+
const own = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent });
|
|
612
|
+
if (!own.ok || !Array.isArray(own.profiles))
|
|
613
|
+
throw new Error('addAccount: the member\'s sign-ins could not be read');
|
|
614
|
+
return own.profiles.some((p) => p.provider === provider);
|
|
615
|
+
};
|
|
616
|
+
let fresh = o.again !== undefined || await holdsProvider();
|
|
617
|
+
// Only the member-agent path clears a profile it did not create, so only it takes the provider's add lock, held to
|
|
618
|
+
// settle. The read repeats under the lock: the add that held it may have sealed its sign-in on the member agent.
|
|
619
|
+
if (!fresh) {
|
|
620
|
+
releaseAdd = await holdAdd(member, provider);
|
|
621
|
+
fresh = await holdsProvider();
|
|
622
|
+
if (fresh)
|
|
623
|
+
releaseAdd();
|
|
624
|
+
}
|
|
625
|
+
// The member agent's primary model before this sign-in or any merge on it: restored when its profile moves away.
|
|
626
|
+
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}` });
|
|
629
|
+
let id = provider;
|
|
630
|
+
let agent = memberAgent;
|
|
631
|
+
let added = false;
|
|
632
|
+
if (!fresh) {
|
|
633
|
+
await edit(member, (file) => { if (!file.accounts[id]) {
|
|
634
|
+
file.accounts[id] = entryOf(file, agent);
|
|
635
|
+
added = true;
|
|
636
|
+
} });
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
// `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
|
|
640
|
+
const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
|
|
641
|
+
await edit(member, (file) => {
|
|
642
|
+
const taken = new Set([...roster, ...Object.values(file.accounts).map((e) => e.agent)]);
|
|
643
|
+
do {
|
|
644
|
+
const hex = randomBytes(3).toString('hex');
|
|
645
|
+
id = `${provider}.${hex}`;
|
|
646
|
+
agent = `${member}--${hex}`;
|
|
647
|
+
} while (taken.has(agent) || file.accounts[id]);
|
|
648
|
+
file.accounts[id] = entryOf(file, agent);
|
|
649
|
+
});
|
|
650
|
+
added = true;
|
|
651
|
+
let created = false;
|
|
652
|
+
// The pin rewrites the shared workspace's IDENTITY.md Name line on create, so the create window is one lock per member.
|
|
653
|
+
await ctx.locks.exclusive([`${member}#identity`], async () => {
|
|
654
|
+
let before;
|
|
655
|
+
try {
|
|
656
|
+
before = await identityOf(memberAgent);
|
|
657
|
+
const made = await ctx.request('agents.create', { name: agent, workspace });
|
|
658
|
+
created = true;
|
|
659
|
+
if (made.agentId !== agent)
|
|
660
|
+
throw new Error(`addAccount: agents.create answered ${String(made.agentId)}, not ${agent}`);
|
|
661
|
+
await untilListed(ctx.request, agent);
|
|
662
|
+
await restoreIdentity(memberAgent, before);
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
await restoreIdentity(memberAgent, before)
|
|
666
|
+
.catch((restoreError) => ctx.log?.(`addAccount: IDENTITY.md is not restored: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`));
|
|
667
|
+
const exists = created || await onGateway(agent).catch((listError) => {
|
|
668
|
+
ctx.log?.(`addAccount: ${agent} is not removed yet: ${listError instanceof Error ? listError.message : String(listError)}`);
|
|
669
|
+
return undefined;
|
|
670
|
+
});
|
|
671
|
+
if (exists !== undefined)
|
|
672
|
+
await discard(member, id, agent, exists);
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const key = `${member}\n${id}`;
|
|
678
|
+
signing.set(key, (signing.get(key) ?? 0) + 1);
|
|
679
|
+
const handle = ctx.signIn(agent, { authChoice: route.choice, ...(o.via ? { via: o.via } : {}) }, (v) => { if (v.state === 'waiting')
|
|
680
|
+
on(v); }, fresh);
|
|
681
|
+
// Moves the profile `who` names from `from` into `to`, under both agents' locks; refused, nothing changes.
|
|
682
|
+
const profilesOf = (agent) => ctx.request('byokit.accounts', { action: 'list', agentId: agent })
|
|
683
|
+
.then((r) => (r.ok === true && Array.isArray(r.profiles) ? r.profiles : undefined), () => undefined);
|
|
684
|
+
const profileIdOf = (profiles, who) => profiles?.find((p) => p.provider === provider && p.who === who)?.profileId;
|
|
685
|
+
const moveTo = (from, to, who) => ctx.locks.exclusive([memberAgent, from, to], async () => {
|
|
686
|
+
const sent = profileIdOf(await profilesOf(from), who);
|
|
687
|
+
const answer = await ctx.request('byokit.accounts', { action: 'adopt', from, to, provider, who })
|
|
688
|
+
.catch(async (error) => {
|
|
689
|
+
ctx.log?.(`addAccount: adopt has no answer: ${error instanceof Error ? error.message : String(error)}`);
|
|
690
|
+
// A lost reply can follow a committed move: the destination holding the sign-in's own profile means it moved.
|
|
691
|
+
const landed = sent !== undefined && profileIdOf(await profilesOf(to), who) === sent;
|
|
692
|
+
return { ok: landed };
|
|
693
|
+
});
|
|
694
|
+
if (!answer.ok)
|
|
695
|
+
return false;
|
|
696
|
+
if (from === memberAgent)
|
|
697
|
+
await restoreModel(memberAgent, memberModel);
|
|
698
|
+
return true;
|
|
699
|
+
}).catch(() => false);
|
|
700
|
+
// `adopt` moves the sign-in into X's agent and clears it from the target; refused, the sign-in stays a new
|
|
701
|
+
// account and keeps its `who`.
|
|
702
|
+
const adopt = (who, to) => moveTo(agent, to, who).then(async (ok) => {
|
|
703
|
+
if (ok)
|
|
704
|
+
return true;
|
|
705
|
+
await edit(member, (file) => { const own = file.accounts[id]; if (own)
|
|
706
|
+
own.who = who; });
|
|
707
|
+
return false;
|
|
708
|
+
}).catch(() => false);
|
|
709
|
+
const removeEntry = () => agent !== memberAgent ? discard(member, id, agent, true)
|
|
710
|
+
: edit(member, (file) => { delete file.accounts[id]; })
|
|
711
|
+
.catch((error) => ctx.log?.(`addAccount: ${id} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
|
|
712
|
+
const drop = () => agent !== memberAgent || added ? removeEntry() : Promise.resolve();
|
|
713
|
+
// A live account holding the same `who` merges into the earliest one before the sign-in does. When the earliest is
|
|
714
|
+
// the sign-in's own row, the duplicate is discarded so the sign-in's credential survives; otherwise its profile is
|
|
715
|
+
// moved into the earliest one, then an account agent is removed and the member agent's entry dropped. A refused
|
|
716
|
+
// move keeps that duplicate, logged.
|
|
717
|
+
const collapse = async (dup, keep, who) => {
|
|
718
|
+
if (keep[0] === id) {
|
|
719
|
+
if (dup[1].agent !== memberAgent) {
|
|
720
|
+
await discard(member, dup[0], dup[1].agent, true);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
ctx.log?.(`addAccount: ${dup[0]} is kept: the member agent's profile is not cleared alone`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!await moveTo(dup[1].agent, keep[1].agent, who)) {
|
|
727
|
+
ctx.log?.(`addAccount: ${dup[0]} is kept: its sign-in is not moved`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (dup[1].agent !== memberAgent) {
|
|
731
|
+
await discard(member, dup[0], dup[1].agent, true);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
await edit(member, (file) => { delete file.accounts[dup[0]]; })
|
|
735
|
+
.catch((error) => ctx.log?.(`addAccount: ${dup[0]} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
|
|
736
|
+
};
|
|
737
|
+
// A row holds the identity only while its agent holds it: the member's row while the member holds that `who`, an
|
|
738
|
+
// account while its agent is on the roster (a removal that stopped partway leaves an entry whose agent is gone).
|
|
739
|
+
const liveEntry = (roster, memberHolds, e) => e.agent === memberAgent ? memberHolds
|
|
740
|
+
: ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`) && roster.has(e.agent);
|
|
741
|
+
// Sealed before the person sees done: the agent keeps only this sign-in. Done only when sealed; anything else leaves
|
|
742
|
+
// nothing behind. Identity is its `who`, compared only
|
|
743
|
+
// within this member and provider: the live accounts holding it (the target's own included; several only from
|
|
744
|
+
// before O14) merge into the earliest `addedAt`, whatever `again` names; no `who` never merges. The email never decides.
|
|
745
|
+
const settle = async (view) => {
|
|
746
|
+
if (view.state === 'done') {
|
|
747
|
+
const sealed = await ctx.locks.exclusive([memberAgent, agent, indexLock(member)], async () => {
|
|
748
|
+
const answer = await ctx.request('byokit.accounts', { action: 'seal', agentId: agent, provider,
|
|
749
|
+
...(route.keyEntry ? { planKey: true } : {}) });
|
|
750
|
+
if (!answer.ok)
|
|
751
|
+
return 'Sign-in could not be saved. Try again.';
|
|
752
|
+
const roster = await ctx.request('agents.list')
|
|
753
|
+
.then((r) => new Set(r.agents.map((a) => a.id)), () => undefined);
|
|
754
|
+
const held = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent })
|
|
755
|
+
.catch(() => undefined);
|
|
756
|
+
const mergeable = roster !== undefined && held?.ok === true && Array.isArray(held.profiles);
|
|
757
|
+
if (!mergeable)
|
|
758
|
+
ctx.log?.(`addAccount: the roster or ${memberAgent} profiles are unreadable; the sign-in is kept as its own row`);
|
|
759
|
+
const memberHolds = (held?.profiles ?? []).some((p) => p.provider === provider && p.who === answer.who);
|
|
760
|
+
const file = read(member);
|
|
761
|
+
if (roster && mergeable && (await build(member, memberAgent, file, roster, held?.profiles ?? [])).dirty)
|
|
762
|
+
write(member, file);
|
|
763
|
+
const own = file.accounts[id];
|
|
764
|
+
const sharing = answer.who && roster && mergeable ? Object.entries(file.accounts)
|
|
765
|
+
.filter(([other, e]) => other.split('.')[0] === provider && e.who === answer.who && liveEntry(roster, memberHolds, e))
|
|
766
|
+
.sort(([, a], [, b]) => a.addedAt - b.addedAt) : [];
|
|
767
|
+
const keep = sharing[0];
|
|
768
|
+
const dups = sharing.slice(1).filter(([other]) => other !== id);
|
|
769
|
+
if (!(keep && keep[0] !== id) && own) {
|
|
770
|
+
if (answer.who)
|
|
771
|
+
own.who = answer.who;
|
|
772
|
+
else
|
|
773
|
+
delete own.who;
|
|
774
|
+
write(member, file);
|
|
775
|
+
}
|
|
776
|
+
return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
|
|
777
|
+
}).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
|
|
778
|
+
: (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] };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
|
|
792
|
+
if (agent === memberAgent)
|
|
793
|
+
await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
|
|
794
|
+
.catch((error) => ctx.log?.(`addAccount: ${provider} is not cleared yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
795
|
+
await drop();
|
|
796
|
+
return { view, id };
|
|
797
|
+
};
|
|
798
|
+
const done = handle.done.then(settle).finally(() => {
|
|
799
|
+
releaseAdd();
|
|
800
|
+
const left = signing.get(key) - 1;
|
|
801
|
+
if (left)
|
|
802
|
+
signing.set(key, left);
|
|
803
|
+
else
|
|
804
|
+
signing.delete(key);
|
|
805
|
+
}).then((result) => {
|
|
806
|
+
on(result.view);
|
|
807
|
+
return result;
|
|
808
|
+
});
|
|
809
|
+
return { id, paste: handle.paste, cancel: handle.cancel, done };
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
releaseAdd();
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move };
|
|
222
817
|
}
|