@byokit/openclaw 0.11.1 → 0.13.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 +22 -0
- package/README.md +38 -6
- package/SECURITY.md +3 -2
- package/dist/accounts.d.ts +19 -2
- package/dist/accounts.js +618 -165
- 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/auth-store.d.ts +6 -0
- package/dist/auth-store.js +135 -23
- 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 +4 -4
- package/dist/device.js +11 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +16 -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 +24 -2
- package/dist/kit.js +139 -29
- package/dist/link.js +21 -11
- package/dist/routes.d.ts +1 -0
- package/dist/routes.js +5 -3
- package/dist/routes.json +31 -31
- package/dist/runs.d.ts +1 -1
- package/dist/runs.js +32 -10
- package/dist/signin.d.ts +2 -0
- package/dist/signin.js +39 -15
- package/dist/testing/fake-gateway.d.ts +19 -0
- package/dist/testing/fake-gateway.js +137 -12
- package/dist/testing/model-stub.d.ts +1 -0
- package/dist/testing/model-stub.js +2 -1
- package/dist/words.json +8 -1
- package/package.json +1 -1
- 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,50 @@
|
|
|
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
|
-
|
|
3
|
+
// who? } }, defaults, gone? }`, where `gone` names the agent dirs a removal left for the orphan sweep. No credential,
|
|
4
|
+
// token, email or engine line. Listing, rename, defaults, models, room, adding with its identity merge (O14 steps
|
|
5
|
+
// S6, S7), the start-of-run pick (S10), session binding (S11), moving a conversation (S12) and removal with its
|
|
6
|
+
// orphan sweep (S8) live here.
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
8
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
7
9
|
import { join } from 'node:path';
|
|
8
10
|
import { providerOf, resolveSelection } from "./pick.js";
|
|
11
|
+
import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
|
|
9
12
|
import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, untilListed } from "./members.js";
|
|
10
13
|
import { routes, routeFor } from "./routes.js";
|
|
11
14
|
import { words } from "./words.js";
|
|
15
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
|
+
// `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
|
|
17
|
+
// reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
|
|
18
|
+
export const modelsOf = (provider, answer) => {
|
|
19
|
+
const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const candidate of rows) {
|
|
22
|
+
if (!record(candidate) || typeof candidate.provider !== 'string'
|
|
23
|
+
|| candidate.provider.toLowerCase() !== provider.toLowerCase())
|
|
24
|
+
continue;
|
|
25
|
+
if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
|
|
26
|
+
continue;
|
|
27
|
+
const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
|
|
28
|
+
if (providerOf(id) !== provider.toLowerCase())
|
|
29
|
+
continue;
|
|
30
|
+
const available = candidate.available === undefined
|
|
31
|
+
? candidate.unavailableReason === undefined : candidate.available === true;
|
|
32
|
+
const info = { id, name: candidate.name, available };
|
|
33
|
+
if ((candidate.tier === 'strong' || candidate.tier === 'fast'))
|
|
34
|
+
info.tier = candidate.tier;
|
|
35
|
+
if (!available) {
|
|
36
|
+
if (candidate.unavailableReason === 'missing-auth' || candidate.unavailableReason === 'auth-failed')
|
|
37
|
+
info.why = 'signed_out';
|
|
38
|
+
else if (candidate.unavailableReason === 'cooldown') {
|
|
39
|
+
info.why = 'resting';
|
|
40
|
+
if (typeof candidate.unavailableUntil === 'number' && Number.isFinite(candidate.unavailableUntil))
|
|
41
|
+
info.until = candidate.unavailableUntil;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
out.push(info);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
12
48
|
const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
|
|
13
49
|
const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
|
|
14
50
|
const IDENTITY = 'IDENTITY.md';
|
|
@@ -25,8 +61,116 @@ const validEntry = (v) => !!v && typeof v === 'object'
|
|
|
25
61
|
&& (v.until === undefined || Number.isFinite(v.until))
|
|
26
62
|
&& (v.model === undefined || typeof v.model === 'string')
|
|
27
63
|
&& (v.who === undefined || typeof v.who === 'string');
|
|
64
|
+
const indexPath = (root, member) => join(root, 'accounts', `${member}.json`);
|
|
65
|
+
const readIndex = (root, member) => {
|
|
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);
|
|
76
|
+
if (!file || typeof file !== 'object' || file.v !== 1)
|
|
77
|
+
return empty();
|
|
78
|
+
const accounts = {};
|
|
79
|
+
for (const [id, entry] of Object.entries(file.accounts ?? {}))
|
|
80
|
+
if (validEntry(entry))
|
|
81
|
+
accounts[id] = entry;
|
|
82
|
+
const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
|
|
83
|
+
const defaults = {};
|
|
84
|
+
if (typeof raw.account === 'string')
|
|
85
|
+
defaults.account = raw.account;
|
|
86
|
+
if (typeof raw.model === 'string')
|
|
87
|
+
defaults.model = raw.model;
|
|
88
|
+
if (typeof raw.auto === 'boolean')
|
|
89
|
+
defaults.auto = raw.auto;
|
|
90
|
+
const gone = Array.isArray(file.gone) ? file.gone.filter((a) => typeof a === 'string' && ACCOUNT_AGENT.test(a)) : [];
|
|
91
|
+
return { v: 1, accounts, defaults, ...(gone.length ? { gone } : {}) };
|
|
92
|
+
};
|
|
93
|
+
// Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
|
|
94
|
+
// own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
|
|
95
|
+
// agent, an `<member>--<hex6>` account agent included, maps only while the index names it under that member. Account
|
|
96
|
+
// agents share their member's workspace, so an unindexed account-shaped agent maps nowhere and stays unreachable.
|
|
97
|
+
export function memberOfAccount(root, ref) {
|
|
98
|
+
const agent = agentOf(ref);
|
|
99
|
+
if (agent === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const member = baseMemberOf(agent);
|
|
102
|
+
if (member === undefined || agent === member)
|
|
103
|
+
return member;
|
|
104
|
+
if (MEMBER_ID.test(agent) && existsSync(join(root, 'workspaces', agent)))
|
|
105
|
+
return agent;
|
|
106
|
+
try {
|
|
107
|
+
return Object.values(readIndex(root, member).accounts).some((e) => e.agent === agent) ? member : undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Every agent any member's index names. An index that cannot be read or parsed throws: its accounts are unknown,
|
|
114
|
+
// so no sweep may run.
|
|
115
|
+
function indexedAgents(root) {
|
|
116
|
+
const named = new Set();
|
|
117
|
+
const gone = new Set();
|
|
118
|
+
let files;
|
|
119
|
+
try {
|
|
120
|
+
files = readdirSync(join(root, 'accounts'));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code === 'ENOENT')
|
|
124
|
+
return { named, gone };
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
if (!file.endsWith('.json'))
|
|
129
|
+
continue;
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new Error(`account index is unreadable: accounts/${file}`, { cause: error });
|
|
136
|
+
}
|
|
137
|
+
for (const entry of Object.values(parsed.accounts ?? {}))
|
|
138
|
+
if (typeof entry?.agent === 'string')
|
|
139
|
+
named.add(entry.agent);
|
|
140
|
+
for (const agent of Array.isArray(parsed.gone) ? parsed.gone : [])
|
|
141
|
+
if (typeof agent === 'string')
|
|
142
|
+
gone.add(agent);
|
|
143
|
+
}
|
|
144
|
+
return { named, gone };
|
|
145
|
+
}
|
|
146
|
+
// Durable removal tombstones, except ids an index now names again. Shared by the live and sealed-store sweeps.
|
|
147
|
+
export function removedAccountAgents(root) {
|
|
148
|
+
const { named, gone } = indexedAgents(root);
|
|
149
|
+
return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent)));
|
|
150
|
+
}
|
|
151
|
+
// Removes each `<root>/state/agents/<member>--<hex6>` directory that a removal recorded as gone and no index names.
|
|
152
|
+
// Only recorded directories go: a legacy member's own directory is never recorded, so it stays. The directories are
|
|
153
|
+
// listed before the index is read: each listed one was created after its entry was written.
|
|
154
|
+
export function sweepOrphans(root) {
|
|
155
|
+
let dirs;
|
|
156
|
+
try {
|
|
157
|
+
dirs = readdirSync(join(root, 'state', 'agents'));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code === 'ENOENT')
|
|
161
|
+
return;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const gone = removedAccountAgents(root);
|
|
165
|
+
for (const dir of dirs) {
|
|
166
|
+
if (!gone.has(dir))
|
|
167
|
+
continue;
|
|
168
|
+
rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
28
171
|
export function createAccounts(ctx) {
|
|
29
|
-
const path = (member) =>
|
|
172
|
+
const path = (member) => indexPath(ctx.root, member);
|
|
173
|
+
const read = (member) => readIndex(ctx.root, member);
|
|
30
174
|
// Accounts whose sign-in is running, as `<member>\n<id>`, counted per add: listed `signing` until the last one ends.
|
|
31
175
|
const signing = new Map();
|
|
32
176
|
// The index file's own lock, apart from any agent's: a run's shared hold on the member agent never blocks an edit.
|
|
@@ -35,33 +179,13 @@ export function createAccounts(ctx) {
|
|
|
35
179
|
if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
|
|
36
180
|
throw new Error(`invalid member id: ${member}`);
|
|
37
181
|
};
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error.code === 'ENOENT')
|
|
45
|
-
return empty();
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
const file = JSON.parse(text);
|
|
49
|
-
if (!file || typeof file !== 'object' || file.v !== 1)
|
|
50
|
-
return empty();
|
|
51
|
-
const accounts = {};
|
|
52
|
-
for (const [id, entry] of Object.entries(file.accounts ?? {}))
|
|
53
|
-
if (validEntry(entry))
|
|
54
|
-
accounts[id] = entry;
|
|
55
|
-
const raw = file.defaults && typeof file.defaults === 'object' ? file.defaults : {};
|
|
56
|
-
const defaults = {};
|
|
57
|
-
if (typeof raw.account === 'string')
|
|
58
|
-
defaults.account = raw.account;
|
|
59
|
-
if (typeof raw.model === 'string')
|
|
60
|
-
defaults.model = raw.model;
|
|
61
|
-
if (typeof raw.auto === 'boolean')
|
|
62
|
-
defaults.auto = raw.auto;
|
|
63
|
-
return { v: 1, accounts, defaults };
|
|
182
|
+
const dropEntry = (file, id) => {
|
|
183
|
+
delete file.accounts[id];
|
|
184
|
+
if (file.defaults.account === id)
|
|
185
|
+
delete file.defaults.account;
|
|
64
186
|
};
|
|
187
|
+
const markGone = (file, agent) => { if (!file.gone?.includes(agent))
|
|
188
|
+
file.gone = [...(file.gone ?? []), agent]; };
|
|
65
189
|
const write = (member, file) => {
|
|
66
190
|
mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
|
|
67
191
|
const tmp = `${path(member)}.${randomUUID()}.tmp`;
|
|
@@ -84,8 +208,8 @@ export function createAccounts(ctx) {
|
|
|
84
208
|
const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
|
|
85
209
|
?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
|
|
86
210
|
?? provider;
|
|
87
|
-
const
|
|
88
|
-
|
|
211
|
+
const agentRoster = () => ctx.request('agents.list').then((r) => new Set(r.agents.map((a) => a.id)));
|
|
212
|
+
const rosterOf = () => agentRoster().catch(() => new Set());
|
|
89
213
|
const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
|
|
90
214
|
.then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
|
|
91
215
|
const stateOf = (route, status, until) => {
|
|
@@ -118,7 +242,8 @@ export function createAccounts(ctx) {
|
|
|
118
242
|
file.accounts[provider] = entry;
|
|
119
243
|
dirty = true;
|
|
120
244
|
}
|
|
121
|
-
|
|
245
|
+
// Not while the member agent's own sign-in runs: settle decides whose that `who` is (it may be adopted away).
|
|
246
|
+
if (typeof profiles[0]?.who === 'string' && entry.who !== profiles[0].who && !signing.has(`${member}\n${provider}`)) {
|
|
122
247
|
entry.who = profiles[0].who;
|
|
123
248
|
dirty = true;
|
|
124
249
|
}
|
|
@@ -147,6 +272,11 @@ export function createAccounts(ctx) {
|
|
|
147
272
|
const provider = id.split('.')[0];
|
|
148
273
|
const meta = metaOf(entry.route);
|
|
149
274
|
const profiles = await profilesOf(entry.agent);
|
|
275
|
+
const who = profiles.find((p) => p.provider === provider)?.who;
|
|
276
|
+
if (!entry.who && typeof who === 'string' && !signing.has(`${member}\n${id}`)) {
|
|
277
|
+
entry.who = who;
|
|
278
|
+
dirty = true;
|
|
279
|
+
}
|
|
150
280
|
agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
|
|
151
281
|
billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
|
|
152
282
|
...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
|
|
@@ -178,39 +308,6 @@ export function createAccounts(ctx) {
|
|
|
178
308
|
throw new Error(`unknown account: ${id}`);
|
|
179
309
|
return { row, entry };
|
|
180
310
|
};
|
|
181
|
-
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
182
|
-
// `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
|
|
183
|
-
// reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
|
|
184
|
-
const modelsOf = (provider, answer) => {
|
|
185
|
-
const rows = record(answer) && Array.isArray(answer.models) ? answer.models : [];
|
|
186
|
-
const out = [];
|
|
187
|
-
for (const candidate of rows) {
|
|
188
|
-
if (!record(candidate) || typeof candidate.provider !== 'string'
|
|
189
|
-
|| candidate.provider.toLowerCase() !== provider.toLowerCase())
|
|
190
|
-
continue;
|
|
191
|
-
if (typeof candidate.id !== 'string' || !candidate.id || typeof candidate.name !== 'string')
|
|
192
|
-
continue;
|
|
193
|
-
const id = candidate.id.includes('/') ? candidate.id : `${candidate.provider}/${candidate.id}`;
|
|
194
|
-
if (providerOf(id) !== provider.toLowerCase())
|
|
195
|
-
continue;
|
|
196
|
-
const available = candidate.available === undefined
|
|
197
|
-
? candidate.unavailableReason === undefined : candidate.available === true;
|
|
198
|
-
const info = { id, name: candidate.name, available };
|
|
199
|
-
if ((candidate.tier === 'strong' || candidate.tier === 'fast'))
|
|
200
|
-
info.tier = candidate.tier;
|
|
201
|
-
if (!available) {
|
|
202
|
-
if (candidate.unavailableReason === 'missing-auth' || candidate.unavailableReason === 'auth-failed')
|
|
203
|
-
info.why = 'signed_out';
|
|
204
|
-
else if (candidate.unavailableReason === 'cooldown') {
|
|
205
|
-
info.why = 'resting';
|
|
206
|
-
if (typeof candidate.unavailableUntil === 'number' && Number.isFinite(candidate.unavailableUntil))
|
|
207
|
-
info.until = candidate.unavailableUntil;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
out.push(info);
|
|
211
|
-
}
|
|
212
|
-
return out;
|
|
213
|
-
};
|
|
214
311
|
const spanOf = (label) => {
|
|
215
312
|
const lower = label.toLowerCase();
|
|
216
313
|
if (lower === 'session')
|
|
@@ -343,25 +440,229 @@ export function createAccounts(ctx) {
|
|
|
343
440
|
}
|
|
344
441
|
return roomOf(row.provider, answer);
|
|
345
442
|
});
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
443
|
+
// 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 when the held account is not API-billed). This
|
|
445
|
+
// gives the accounts a binding holds, in list order, and the name a refusal shows (`account.another` when it holds none).
|
|
446
|
+
const boundOf = (file, binding) => binding.id !== undefined ? [binding.id]
|
|
447
|
+
: Object.entries(file.accounts).filter(([, e]) => e.agent === binding.agentId)
|
|
448
|
+
.sort(([a, x], [b, y]) => x.addedAt - y.addedAt || (a < b ? -1 : 1)).map(([id]) => id);
|
|
449
|
+
const nameOf = (file, ids) => {
|
|
450
|
+
const first = ids[0] === undefined ? undefined : file.accounts[ids[0]];
|
|
451
|
+
return first?.name ?? words('account.another');
|
|
452
|
+
};
|
|
453
|
+
// A pick's refusal: `bound` names an id outside the binding's accounts, `paid` an id other than an API-billed
|
|
454
|
+
// binding's own. A named id the index does not hold is left to the pick, which reports it as unknown.
|
|
455
|
+
const refusalOf = (file, binding, sel) => {
|
|
456
|
+
const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
|
|
457
|
+
if (named !== undefined && file.accounts[named] === undefined)
|
|
458
|
+
return undefined;
|
|
459
|
+
const ids = boundOf(file, binding);
|
|
460
|
+
const held = binding.id === undefined ? undefined : file.accounts[binding.id];
|
|
461
|
+
if (held && metaOf(held.route).billing === 'api' && named !== binding.id)
|
|
462
|
+
return { code: 'paid', name: held.name };
|
|
463
|
+
if (named !== undefined && !ids.includes(named))
|
|
464
|
+
return { code: 'bound', name: nameOf(file, ids) };
|
|
465
|
+
return undefined;
|
|
466
|
+
};
|
|
467
|
+
// An API-key run (Runs step 2): its key continues only an API-billed binding; any other binding is refused.
|
|
468
|
+
const keyRefusal = async (member, binding) => {
|
|
469
|
+
const held = binding.id === undefined ? undefined : read(member).accounts[binding.id];
|
|
470
|
+
if (held && metaOf(held.route).billing === 'api')
|
|
471
|
+
return undefined;
|
|
472
|
+
await list(member);
|
|
473
|
+
const file = read(member);
|
|
474
|
+
return words('account.bound', { name: nameOf(file, boundOf(file, binding)) });
|
|
475
|
+
};
|
|
476
|
+
// Runs step 1 (5.15): a key `agent:<member>--<hex6>:<tail>` of an indexed account agent is bound to that account; a
|
|
477
|
+
// key `agent:<member>:<tail>` to whichever of the member's agents holds `agent:<that agent>:<tail>` (member agent
|
|
478
|
+
// first), else unbound. Any other key is not the member's. With no account agent, the member agent and an unbound
|
|
479
|
+
// key run alike, so nothing is asked.
|
|
480
|
+
async function bind(member, sessionKey) {
|
|
481
|
+
check(member);
|
|
482
|
+
const agents = new Map(Object.entries(read(member).accounts)
|
|
483
|
+
.filter(([, e]) => ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`)).map(([id, e]) => [e.agent, id]));
|
|
484
|
+
if (!sessionKey.startsWith(`agent:${member}:`)) {
|
|
485
|
+
const agent = /^agent:([^:]+):/.exec(sessionKey)?.[1];
|
|
486
|
+
const id = agent === undefined ? undefined : agents.get(agent);
|
|
487
|
+
if (id === undefined)
|
|
488
|
+
throw new Error(`refused: "${sessionKey}" is not a session of member "${member}"`);
|
|
489
|
+
return { agentId: agent, id };
|
|
490
|
+
}
|
|
491
|
+
if (!agents.size)
|
|
492
|
+
return undefined;
|
|
493
|
+
const tail = sessionKey.slice(`agent:${member}:`.length);
|
|
494
|
+
const { agentId } = await ctx.ensure(member);
|
|
495
|
+
const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
|
|
496
|
+
for (const [agent, id] of [[agentId, undefined], ...agents]) {
|
|
497
|
+
if (agent !== agentId && !roster.has(agent))
|
|
498
|
+
continue;
|
|
499
|
+
// The pin answers a found session with its canonical `key` and a missing one with `{ ok: false }`.
|
|
500
|
+
const found = await ctx.request('sessions.resolve', { key: `agent:${agent}:${tail}`, agentId: agent, allowMissing: true });
|
|
501
|
+
if (found.key !== undefined)
|
|
502
|
+
return id === undefined ? { agentId: agent } : { agentId: agent, id };
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
// Choosing (5.15): every listed account's room and models are read live once, then the pure `resolveSelection`
|
|
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 member agent chooses only among its own accounts. A refusal (`bound`, `paid`) is
|
|
509
|
+
// decided from the index, after the account listing when the index is cold, and so has no `considered` rows.
|
|
510
|
+
// An unknown id stays the caller bug it always was. `agent` is the picked account's engine agent. A model unavailable
|
|
511
|
+
// only because the account is signed out or resting is still in its plan: the account's state and the run's ready
|
|
512
|
+
// check judge that.
|
|
349
513
|
// The pinned engine may hold no prepared model catalog for an agent added since it started, and refuses its models; that
|
|
350
514
|
// row's models are then unknown (`undefined`), and `resolveSelection` host-validates its membership by provider.
|
|
351
|
-
async function pick(member, sel, skip = []) {
|
|
352
|
-
|
|
515
|
+
async function pick(member, sel, skip = [], binding) {
|
|
516
|
+
check(member);
|
|
517
|
+
const refused = (refusal) => ({ pick: { ok: false, code: refusal.code,
|
|
518
|
+
reason: words(`account.${refusal.code}`, { name: refusal.name }), considered: [] } });
|
|
519
|
+
const early = binding?.id !== undefined ? refusalOf(read(member), binding, sel) : undefined;
|
|
520
|
+
if (early)
|
|
521
|
+
return refused(early);
|
|
522
|
+
const listed = await list(member);
|
|
353
523
|
const file = read(member);
|
|
524
|
+
const late = binding && refusalOf(file, binding, sel);
|
|
525
|
+
if (late)
|
|
526
|
+
return refused(late);
|
|
527
|
+
const rows = listed.filter((r) => !skip.includes(r.id));
|
|
528
|
+
const named = sel.account === 'auto' || sel.account === 'default' ? undefined : sel.account;
|
|
529
|
+
const known = named === undefined || listed.some((r) => r.id === named);
|
|
530
|
+
if (binding?.id !== undefined && known)
|
|
531
|
+
sel = { ...sel, account: binding.id };
|
|
532
|
+
const bound = binding && known ? boundOf(file, binding) : undefined;
|
|
354
533
|
const reads = new Map(await Promise.all(rows.map(async (row) => {
|
|
355
534
|
const entry = file.accounts[row.id];
|
|
356
535
|
const models = await readModels(row, entry).then((ms) => ms.map((m) => (m.why ? { ...m, available: true } : m)), () => undefined);
|
|
357
536
|
return [row.id, { room: await readRoom(row, entry), models }];
|
|
358
537
|
})));
|
|
359
|
-
const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models);
|
|
538
|
+
const chosen = resolveSelection(rows, file.defaults, sel, (a) => reads.get(a.id).room, Date.now(), (a) => reads.get(a.id).models, bound);
|
|
360
539
|
return { pick: chosen, ...(chosen.ok ? { agent: file.accounts[chosen.account.id].agent } : {}) };
|
|
361
540
|
}
|
|
541
|
+
// Moving a conversation (5.15, O14 step S12): the key binds as a run's does (with no account agent binding asks
|
|
542
|
+
// nothing, and the member agent holds it if anything does), and its `sessions.list` row decides unsupported and busy
|
|
543
|
+
// before any lock. Same agent: nothing moves, the next run names the account. Otherwise, under both agents' locks and
|
|
544
|
+
// on the row read again there, the source's whole transcript forks into `agent:<target>:<tail>` (never `chat.history`
|
|
545
|
+
// plus `chat.inject`, which drop tool rows), then the source is deleted only if it is still the session read. A
|
|
546
|
+
// refused fork leaves the source untouched; a refused delete leaves both, the new key live.
|
|
547
|
+
async function move(member, sessionKey, to) {
|
|
548
|
+
check(member);
|
|
549
|
+
const failed = (code, message, live) => ({ ok: false, code, message, ...(live === undefined ? {} : { live }) });
|
|
550
|
+
const { agentId: memberAgent } = await ctx.ensure(member);
|
|
551
|
+
const { agentId: source } = await bind(member, sessionKey) ?? { agentId: memberAgent };
|
|
552
|
+
const { row, entry } = await entryOf(member, to);
|
|
553
|
+
const tail = sessionKey.slice(sessionKey.indexOf(':', 'agent:'.length) + 1);
|
|
554
|
+
const from = `agent:${source}:${tail}`;
|
|
555
|
+
const into = `agent:${entry.agent}:${tail}`;
|
|
556
|
+
const read = async () => {
|
|
557
|
+
const listed = await ctx.request('sessions.list', { agentId: source, search: from });
|
|
558
|
+
const found = (Array.isArray(listed.sessions) ? listed.sessions : [])
|
|
559
|
+
.find((r) => record(r) && typeof r.key === 'string' && r.key.toLowerCase() === from.toLowerCase());
|
|
560
|
+
if (!found)
|
|
561
|
+
return { refused: failed('unsupported', words('move.unsupported')) };
|
|
562
|
+
const live = found.hasActiveRun === true || [...ctx.routes].some(([key, route]) => key === sessionKey || route.session === from);
|
|
563
|
+
return live ? { refused: failed('busy', words('move.busy')) } : { found };
|
|
564
|
+
};
|
|
565
|
+
const first = await read();
|
|
566
|
+
if ('refused' in first)
|
|
567
|
+
return first.refused;
|
|
568
|
+
if (row.state !== 'ready')
|
|
569
|
+
return failed('start_failed', words('move.notReady', { name: row.name }));
|
|
570
|
+
if (entry.agent === source)
|
|
571
|
+
return { ok: true, session: into };
|
|
572
|
+
return ctx.locks.exclusive([source, entry.agent], async () => {
|
|
573
|
+
const again = await read();
|
|
574
|
+
if ('refused' in again)
|
|
575
|
+
return again.refused;
|
|
576
|
+
const session = again.found;
|
|
577
|
+
// A conversation already under the target key is never overwritten: the move is refused, the source untouched.
|
|
578
|
+
const taken = await ctx.request('sessions.resolve', { key: into, agentId: entry.agent, allowMissing: true });
|
|
579
|
+
if (taken.key !== undefined)
|
|
580
|
+
return failed('start_failed', words('move.taken', { name: row.name }));
|
|
581
|
+
// The session's model when the target lists it, else the target's first available; the read takes no shared
|
|
582
|
+
// hold (this move holds the target exclusively). With no answer the fork keeps the parent's own selection.
|
|
583
|
+
const models = await ctx.request('models.list', { agentId: entry.agent, view: 'default' })
|
|
584
|
+
.then((answer) => modelsOf(row.provider, answer), () => []);
|
|
585
|
+
const current = typeof session.model !== 'string' ? undefined
|
|
586
|
+
: (typeof session.modelProvider === 'string' && !session.model.includes('/') ? `${session.modelProvider}/${session.model}` : session.model).toLowerCase();
|
|
587
|
+
const model = models.find((m) => m.id.toLowerCase() === current)?.id ?? models.find((m) => m.available)?.id;
|
|
588
|
+
try {
|
|
589
|
+
await ctx.request('sessions.create', { agentId: entry.agent, key: into, parentSessionKey: from, fork: true, ...(model ? { model } : {}) });
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
return failed('start_failed', (error instanceof Error ? error.message : String(error)).slice(0, 200));
|
|
593
|
+
}
|
|
594
|
+
const closed = await ctx.request('sessions.delete', { key: from, agentId: source, deleteTranscript: true,
|
|
595
|
+
...(typeof session.sessionId === 'string' ? { expectedSessionId: session.sessionId } : {}),
|
|
596
|
+
...(typeof session.updatedAt === 'number' ? { expectedSessionUpdatedAt: session.updatedAt } : {}) })
|
|
597
|
+
.then((answer) => record(answer) && answer.deleted === true, (error) => {
|
|
598
|
+
ctx.log?.(`move: ${from} is kept: ${error instanceof Error ? error.message : String(error)}`);
|
|
599
|
+
return false;
|
|
600
|
+
});
|
|
601
|
+
if (!closed)
|
|
602
|
+
return failed('close_failed', words('move.closeFailed', { name: row.name }), into);
|
|
603
|
+
return { ok: true, session: into };
|
|
604
|
+
});
|
|
605
|
+
}
|
|
362
606
|
// A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
|
|
363
607
|
const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
|
|
364
608
|
entry.until = until; });
|
|
609
|
+
const agentDir = (agent) => join(ctx.root, 'state', 'agents', agent);
|
|
610
|
+
// Removing (5.15): a member-agent account goes through `models.authLogout` with its sealed profileIds, then its
|
|
611
|
+
// entry is dropped. An account agent goes through `clear`, an empty `list`, `agents.delete` with
|
|
612
|
+
// `deleteFiles: false`, removal of its state dir, then the entry is dropped. Refused `busy` while a run holds
|
|
613
|
+
// the agent; the member agent stays ready (only the named provider is logged out or cleared).
|
|
614
|
+
const providerProfileIds = async (agentId, provider) => {
|
|
615
|
+
const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
|
|
616
|
+
if (listed.ok !== true || !Array.isArray(listed.profiles))
|
|
617
|
+
throw new Error('removeAccount: the sign-ins could not be read');
|
|
618
|
+
return listed.profiles.filter((p) => p.provider === provider && typeof p.profileId === 'string' && p.profileId).map((p) => p.profileId);
|
|
619
|
+
};
|
|
620
|
+
async function remove(member, id) {
|
|
621
|
+
check(member);
|
|
622
|
+
const { agentId: memberAgent } = await ctx.ensure(member);
|
|
623
|
+
const entry = read(member).accounts[id];
|
|
624
|
+
if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
|
|
625
|
+
throw new Error(`unknown account: ${id}`);
|
|
626
|
+
const agent = entry.agent;
|
|
627
|
+
const provider = id.split('.')[0];
|
|
628
|
+
if (ctx.locks.live(agent) || signing.has(`${member}\n${id}`))
|
|
629
|
+
throw new Error('removeAccount: busy');
|
|
630
|
+
if (agent === memberAgent) {
|
|
631
|
+
await ctx.locks.exclusive([agent, indexLock(member)], async () => {
|
|
632
|
+
const profileIds = await providerProfileIds(agent, provider);
|
|
633
|
+
if (profileIds.length)
|
|
634
|
+
await ctx.request('models.authLogout', { provider, agentId: agent, profileIds });
|
|
635
|
+
if ((await providerProfileIds(agent, provider)).length)
|
|
636
|
+
throw new Error('removeAccount: the account is still signing in');
|
|
637
|
+
const file = read(member);
|
|
638
|
+
dropEntry(file, id);
|
|
639
|
+
write(member, file);
|
|
640
|
+
// The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
|
|
641
|
+
await ctx.reseal?.();
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
await ctx.locks.exclusive([agent, indexLock(member)], async () => {
|
|
646
|
+
const roster = await agentRoster();
|
|
647
|
+
if (roster.has(agent)) {
|
|
648
|
+
await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
|
|
649
|
+
const listed = await ctx.request('byokit.accounts', { action: 'list', agentId: agent });
|
|
650
|
+
if (listed.ok !== true || !Array.isArray(listed.profiles))
|
|
651
|
+
throw new Error('removeAccount: the sign-ins could not be read');
|
|
652
|
+
if (listed.profiles.length)
|
|
653
|
+
throw new Error('removeAccount: the account is still signing in');
|
|
654
|
+
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
655
|
+
}
|
|
656
|
+
rmSync(agentDir(agent), { recursive: true, force: true });
|
|
657
|
+
// Already holding the index lock: write directly instead of `edit`, which would wait on itself.
|
|
658
|
+
const file = read(member);
|
|
659
|
+
dropEntry(file, id);
|
|
660
|
+
markGone(file, agent);
|
|
661
|
+
write(member, file);
|
|
662
|
+
// The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
|
|
663
|
+
await ctx.reseal?.();
|
|
664
|
+
});
|
|
665
|
+
}
|
|
365
666
|
// Index edits take the index lock, as listing's own write does.
|
|
366
667
|
const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
|
|
367
668
|
const identityOf = async (agentId) => {
|
|
@@ -372,6 +673,28 @@ export function createAccounts(ctx) {
|
|
|
372
673
|
if (before !== undefined && await identityOf(memberAgent) !== before)
|
|
373
674
|
await ctx.request('agents.files.set', { agentId: memberAgent, name: IDENTITY, content: before });
|
|
374
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
|
+
};
|
|
375
698
|
const onGateway = (agent) => untilListed(ctx.request, agent)
|
|
376
699
|
.then(() => true, () => ctx.request('agents.list')
|
|
377
700
|
.then((r) => r.agents.some((a) => a.id === agent)));
|
|
@@ -382,11 +705,18 @@ export function createAccounts(ctx) {
|
|
|
382
705
|
await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
|
|
383
706
|
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
384
707
|
}
|
|
385
|
-
rmSync(
|
|
708
|
+
rmSync(agentDir(agent), { recursive: true, force: true });
|
|
386
709
|
await edit(member, (file) => { delete file.accounts[id]; });
|
|
387
710
|
})().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
711
|
+
// Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
|
|
712
|
+
// profile its own add activated, and the next add's read sees what the earlier one left.
|
|
713
|
+
const holdAdd = (member, provider) => new Promise((held) => {
|
|
714
|
+
void ctx.locks.exclusive([`add:${member}:${provider}`], () => new Promise((release) => held(release)));
|
|
715
|
+
});
|
|
388
716
|
async function add(member, o, on) {
|
|
389
717
|
check(member);
|
|
718
|
+
if (o && typeof o === 'object' && 'key' in o)
|
|
719
|
+
throw new Error('addAccount: keys are host-only');
|
|
390
720
|
if (!NEW_MEMBER_ID.test(member))
|
|
391
721
|
throw new Error(words('account.legacyMember'));
|
|
392
722
|
const choice = o?.authChoice === 'openai-codex' ? 'openai' : o?.authChoice;
|
|
@@ -398,102 +728,225 @@ export function createAccounts(ctx) {
|
|
|
398
728
|
const { agentId: memberAgent, workspace } = await ctx.ensure(member);
|
|
399
729
|
if (o.again !== undefined && (!read(member).accounts[o.again] || o.again.split('.')[0] !== provider))
|
|
400
730
|
throw new Error(`unknown account: ${o.again}`);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
731
|
+
let releaseAdd = () => { };
|
|
732
|
+
try {
|
|
733
|
+
// The member agent is the target only while it holds no local profile of the provider (5.15).
|
|
734
|
+
const holdsProvider = async () => {
|
|
735
|
+
const own = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent });
|
|
736
|
+
if (!own.ok || !Array.isArray(own.profiles))
|
|
737
|
+
throw new Error('addAccount: the member\'s sign-ins could not be read');
|
|
738
|
+
return own.profiles.some((p) => p.provider === provider);
|
|
739
|
+
};
|
|
740
|
+
let fresh = o.again !== undefined || await holdsProvider();
|
|
741
|
+
// Only the member-agent path clears a profile it did not create, so only it takes the provider's add lock, held to
|
|
742
|
+
// settle. The read repeats under the lock: the add that held it may have sealed its sign-in on the member agent.
|
|
743
|
+
if (!fresh) {
|
|
744
|
+
releaseAdd = await holdAdd(member, provider);
|
|
745
|
+
fresh = await holdsProvider();
|
|
746
|
+
if (fresh)
|
|
747
|
+
releaseAdd();
|
|
748
|
+
}
|
|
749
|
+
// The member agent's primary model before this sign-in or any merge on it: restored when its profile moves away.
|
|
750
|
+
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}` });
|
|
753
|
+
let id = provider;
|
|
754
|
+
let agent = memberAgent;
|
|
755
|
+
let added = false;
|
|
756
|
+
if (!fresh) {
|
|
757
|
+
await edit(member, (file) => { if (!file.accounts[id]) {
|
|
758
|
+
file.accounts[id] = entryOf(file, agent);
|
|
759
|
+
added = true;
|
|
760
|
+
} });
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
// `<member>--<hex6>`, drawn again while that agent exists; its entry is written before the agent.
|
|
764
|
+
const roster = new Set((await ctx.request('agents.list')).agents.map((a) => a.id));
|
|
765
|
+
await edit(member, (file) => {
|
|
766
|
+
const taken = new Set([...roster, ...Object.values(file.accounts).map((e) => e.agent)]);
|
|
767
|
+
do {
|
|
768
|
+
const hex = randomBytes(3).toString('hex');
|
|
769
|
+
id = `${provider}.${hex}`;
|
|
770
|
+
agent = `${member}--${hex}`;
|
|
771
|
+
} while (taken.has(agent) || file.accounts[id]);
|
|
772
|
+
file.accounts[id] = entryOf(file, agent);
|
|
773
|
+
});
|
|
414
774
|
added = true;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
775
|
+
let created = false;
|
|
776
|
+
// The pin rewrites the shared workspace's IDENTITY.md Name line on create, so the create window is one lock per member.
|
|
777
|
+
await ctx.locks.exclusive([`${member}#identity`], async () => {
|
|
778
|
+
let before;
|
|
779
|
+
try {
|
|
780
|
+
before = await identityOf(memberAgent);
|
|
781
|
+
const made = await ctx.request('agents.create', { name: agent, workspace });
|
|
782
|
+
created = true;
|
|
783
|
+
if (made.agentId !== agent)
|
|
784
|
+
throw new Error(`addAccount: agents.create answered ${String(made.agentId)}, not ${agent}`);
|
|
785
|
+
await untilListed(ctx.request, agent);
|
|
786
|
+
await restoreIdentity(memberAgent, before);
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
await restoreIdentity(memberAgent, before)
|
|
790
|
+
.catch((restoreError) => ctx.log?.(`addAccount: IDENTITY.md is not restored: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`));
|
|
791
|
+
const exists = created || await onGateway(agent).catch((listError) => {
|
|
792
|
+
ctx.log?.(`addAccount: ${agent} is not removed yet: ${listError instanceof Error ? listError.message : String(listError)}`);
|
|
793
|
+
return undefined;
|
|
794
|
+
});
|
|
795
|
+
if (exists !== undefined)
|
|
796
|
+
await discard(member, id, agent, exists);
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
const key = `${member}\n${id}`;
|
|
802
|
+
signing.set(key, (signing.get(key) ?? 0) + 1);
|
|
803
|
+
const handle = ctx.signIn(agent, { authChoice: route.choice, ...(o.via ? { via: o.via } : {}) }, (v) => { if (v.state === 'waiting')
|
|
804
|
+
on(v); }, fresh);
|
|
805
|
+
// Moves the profile `who` names from `from` into `to`, under both agents' locks; refused, nothing changes.
|
|
806
|
+
const profilesOf = (agent) => ctx.request('byokit.accounts', { action: 'list', agentId: agent })
|
|
807
|
+
.then((r) => (r.ok === true && Array.isArray(r.profiles) ? r.profiles : undefined), () => undefined);
|
|
808
|
+
const profileIdOf = (profiles, who) => profiles?.find((p) => p.provider === provider && p.who === who)?.profileId;
|
|
809
|
+
const moveTo = (from, to, who) => ctx.locks.exclusive([memberAgent, from, to], async () => {
|
|
810
|
+
const sent = profileIdOf(await profilesOf(from), who);
|
|
811
|
+
const answer = await ctx.request('byokit.accounts', { action: 'adopt', from, to, provider, who })
|
|
812
|
+
.catch(async (error) => {
|
|
813
|
+
ctx.log?.(`addAccount: adopt has no answer: ${error instanceof Error ? error.message : String(error)}`);
|
|
814
|
+
// A lost reply can follow a committed move: the destination holding the sign-in's own profile means it moved.
|
|
815
|
+
const landed = sent !== undefined && profileIdOf(await profilesOf(to), who) === sent;
|
|
816
|
+
return { ok: landed };
|
|
817
|
+
});
|
|
818
|
+
if (!answer.ok)
|
|
819
|
+
return false;
|
|
820
|
+
if (from === memberAgent)
|
|
821
|
+
await restoreModel(memberAgent, memberModel);
|
|
822
|
+
return true;
|
|
823
|
+
}).catch(() => false);
|
|
824
|
+
// `adopt` moves the sign-in into X's agent and clears it from the target; refused, the sign-in stays a new
|
|
825
|
+
// account and keeps its `who`.
|
|
826
|
+
const adopt = (who, to) => moveTo(agent, to, who).then(async (ok) => {
|
|
827
|
+
if (ok)
|
|
828
|
+
return true;
|
|
829
|
+
await edit(member, (file) => { const own = file.accounts[id]; if (own)
|
|
830
|
+
own.who = who; });
|
|
831
|
+
return false;
|
|
832
|
+
}).catch(() => false);
|
|
833
|
+
const removeEntry = () => agent !== memberAgent ? discard(member, id, agent, true)
|
|
834
|
+
: edit(member, (file) => { delete file.accounts[id]; })
|
|
835
|
+
.catch((error) => ctx.log?.(`addAccount: ${id} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
|
|
836
|
+
const drop = () => agent !== memberAgent || added ? removeEntry() : Promise.resolve();
|
|
837
|
+
// A live account holding the same `who` merges into the earliest one before the sign-in does. When the earliest is
|
|
838
|
+
// the sign-in's own row, the duplicate is discarded so the sign-in's credential survives; otherwise its profile is
|
|
839
|
+
// moved into the earliest one, then an account agent is removed and the member agent's entry dropped. A refused
|
|
840
|
+
// move keeps that duplicate, logged.
|
|
841
|
+
const collapse = async (dup, keep, who) => {
|
|
842
|
+
if (keep[0] === id) {
|
|
843
|
+
if (dup[1].agent !== memberAgent) {
|
|
844
|
+
await discard(member, dup[0], dup[1].agent, true);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
ctx.log?.(`addAccount: ${dup[0]} is kept: the member agent's profile is not cleared alone`);
|
|
848
|
+
return;
|
|
442
849
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const exists = created || await onGateway(agent).catch((listError) => {
|
|
447
|
-
ctx.log?.(`addAccount: ${agent} is not removed yet: ${listError instanceof Error ? listError.message : String(listError)}`);
|
|
448
|
-
return undefined;
|
|
449
|
-
});
|
|
450
|
-
if (exists !== undefined)
|
|
451
|
-
await discard(member, id, agent, exists);
|
|
452
|
-
throw error;
|
|
850
|
+
if (!await moveTo(dup[1].agent, keep[1].agent, who)) {
|
|
851
|
+
ctx.log?.(`addAccount: ${dup[0]} is kept: its sign-in is not moved`);
|
|
852
|
+
return;
|
|
453
853
|
}
|
|
854
|
+
if (dup[1].agent !== memberAgent) {
|
|
855
|
+
await discard(member, dup[0], dup[1].agent, true);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
await edit(member, (file) => { delete file.accounts[dup[0]]; })
|
|
859
|
+
.catch((error) => ctx.log?.(`addAccount: ${dup[0]} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
|
|
860
|
+
};
|
|
861
|
+
// A row holds the identity only while its agent holds it: the member's row while the member holds that `who`, an
|
|
862
|
+
// account while its agent is on the roster (a removal that stopped partway leaves an entry whose agent is gone).
|
|
863
|
+
const liveEntry = (roster, memberHolds, e) => e.agent === memberAgent ? memberHolds
|
|
864
|
+
: ACCOUNT_AGENT.test(e.agent) && e.agent.startsWith(`${member}--`) && roster.has(e.agent);
|
|
865
|
+
// Sealed before the person sees done: the agent keeps only this sign-in. Done only when sealed; anything else leaves
|
|
866
|
+
// nothing behind. Identity is its `who`, compared only
|
|
867
|
+
// within this member and provider: the live accounts holding it (the target's own included; several only from
|
|
868
|
+
// before O14) merge into the earliest `addedAt`, whatever `again` names; no `who` never merges. The email never decides.
|
|
869
|
+
const settle = async (view) => {
|
|
870
|
+
if (view.state === 'done') {
|
|
871
|
+
const sealed = await ctx.locks.exclusive([memberAgent, agent, indexLock(member)], async () => {
|
|
872
|
+
const answer = await ctx.request('byokit.accounts', { action: 'seal', agentId: agent, provider,
|
|
873
|
+
...(route.keyEntry ? { planKey: true } : {}) });
|
|
874
|
+
if (!answer.ok)
|
|
875
|
+
return 'Sign-in could not be saved. Try again.';
|
|
876
|
+
const roster = await ctx.request('agents.list')
|
|
877
|
+
.then((r) => new Set(r.agents.map((a) => a.id)), () => undefined);
|
|
878
|
+
const held = await ctx.request('byokit.accounts', { action: 'list', agentId: memberAgent })
|
|
879
|
+
.catch(() => undefined);
|
|
880
|
+
const mergeable = roster !== undefined && held?.ok === true && Array.isArray(held.profiles);
|
|
881
|
+
if (!mergeable)
|
|
882
|
+
ctx.log?.(`addAccount: the roster or ${memberAgent} profiles are unreadable; the sign-in is kept as its own row`);
|
|
883
|
+
const memberHolds = (held?.profiles ?? []).some((p) => p.provider === provider && p.who === answer.who);
|
|
884
|
+
const file = read(member);
|
|
885
|
+
if (roster && mergeable && (await build(member, memberAgent, file, roster, held?.profiles ?? [])).dirty)
|
|
886
|
+
write(member, file);
|
|
887
|
+
const own = file.accounts[id];
|
|
888
|
+
const sharing = answer.who && roster && mergeable ? Object.entries(file.accounts)
|
|
889
|
+
.filter(([other, e]) => other.split('.')[0] === provider && e.who === answer.who && liveEntry(roster, memberHolds, e))
|
|
890
|
+
.sort(([, a], [, b]) => a.addedAt - b.addedAt) : [];
|
|
891
|
+
const keep = sharing[0];
|
|
892
|
+
const dups = sharing.slice(1).filter(([other]) => other !== id);
|
|
893
|
+
if (!(keep && keep[0] !== id) && own) {
|
|
894
|
+
if (answer.who)
|
|
895
|
+
own.who = answer.who;
|
|
896
|
+
else
|
|
897
|
+
delete own.who;
|
|
898
|
+
write(member, file);
|
|
899
|
+
}
|
|
900
|
+
return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
|
|
901
|
+
}).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
|
|
902
|
+
: (error instanceof Error ? error.message : String(error)).slice(0, 200));
|
|
903
|
+
if (typeof sealed === 'string') {
|
|
904
|
+
let error = sealed;
|
|
905
|
+
// A seal failure on the member agent (the non-fresh path) leaves its new credential with no index entry,
|
|
906
|
+
// so listing hides it but runs could use it. Clear only this provider: the bare clear wipes every profile.
|
|
907
|
+
if (agent === memberAgent) {
|
|
908
|
+
const cleared = await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
|
|
909
|
+
.then((answer) => answer?.ok === true, () => false);
|
|
910
|
+
if (!cleared) {
|
|
911
|
+
ctx.log?.(`addAccount: ${memberAgent} ${provider} is not cleared`);
|
|
912
|
+
error = `${sealed} Its credential is still on the member agent: remove this account.`;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
await drop();
|
|
916
|
+
return { view: { state: 'failed', via: view.via, why: 'failed', error }, id };
|
|
917
|
+
}
|
|
918
|
+
for (const dup of sealed.dups)
|
|
919
|
+
await collapse(dup, sealed.keep, sealed.who);
|
|
920
|
+
const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
|
|
921
|
+
if (!into || !await adopt(sealed.who, into[1].agent))
|
|
922
|
+
return { view, id };
|
|
923
|
+
await removeEntry();
|
|
924
|
+
return { view, id: into[0] };
|
|
925
|
+
}
|
|
926
|
+
// A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
|
|
927
|
+
if (agent === memberAgent)
|
|
928
|
+
await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
|
|
929
|
+
.catch((error) => ctx.log?.(`addAccount: ${provider} is not cleared yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
930
|
+
await drop();
|
|
931
|
+
return { view, id };
|
|
932
|
+
};
|
|
933
|
+
const done = handle.done.then(settle).finally(() => {
|
|
934
|
+
releaseAdd();
|
|
935
|
+
const left = signing.get(key) - 1;
|
|
936
|
+
if (left)
|
|
937
|
+
signing.set(key, left);
|
|
938
|
+
else
|
|
939
|
+
signing.delete(key);
|
|
940
|
+
}).then((result) => {
|
|
941
|
+
on(result.view);
|
|
942
|
+
return result;
|
|
454
943
|
});
|
|
944
|
+
return { id, paste: handle.paste, cancel: handle.cancel, done };
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
releaseAdd();
|
|
948
|
+
throw error;
|
|
455
949
|
}
|
|
456
|
-
const key = `${member}\n${id}`;
|
|
457
|
-
signing.set(key, (signing.get(key) ?? 0) + 1);
|
|
458
|
-
const handle = ctx.signIn(agent, { authChoice: route.choice, ...(o.via ? { via: o.via } : {}) }, (v) => { if (v.state === 'waiting')
|
|
459
|
-
on(v); }, fresh);
|
|
460
|
-
// Sealed before the person sees done: the agent keeps only this sign-in, and its `who` goes to the index.
|
|
461
|
-
const settle = async (view) => {
|
|
462
|
-
if (view.state === 'done') {
|
|
463
|
-
const sealed = await ctx.locks.exclusive([memberAgent, agent, indexLock(member)], async () => {
|
|
464
|
-
const answer = await ctx.request('byokit.accounts', { action: 'seal', agentId: agent, provider });
|
|
465
|
-
if (!answer.ok)
|
|
466
|
-
return 'Sign-in could not be saved. Try again.';
|
|
467
|
-
const file = read(member);
|
|
468
|
-
const entry = file.accounts[id];
|
|
469
|
-
if (entry && answer.who) {
|
|
470
|
-
entry.who = answer.who;
|
|
471
|
-
write(member, file);
|
|
472
|
-
}
|
|
473
|
-
return true;
|
|
474
|
-
}).catch((error) => (error instanceof Error ? error.message : String(error)).slice(0, 200));
|
|
475
|
-
if (sealed === true)
|
|
476
|
-
return view;
|
|
477
|
-
view = { state: 'failed', via: view.via, why: 'failed', error: sealed };
|
|
478
|
-
}
|
|
479
|
-
if (agent !== memberAgent)
|
|
480
|
-
await discard(member, id, agent, true);
|
|
481
|
-
else if (added)
|
|
482
|
-
await edit(member, (file) => { delete file.accounts[id]; })
|
|
483
|
-
.catch((error) => ctx.log?.(`addAccount: ${id} entry is kept: ${error instanceof Error ? error.message : String(error)}`));
|
|
484
|
-
return view;
|
|
485
|
-
};
|
|
486
|
-
const done = handle.done.then(settle).finally(() => {
|
|
487
|
-
const left = signing.get(key) - 1;
|
|
488
|
-
if (left)
|
|
489
|
-
signing.set(key, left);
|
|
490
|
-
else
|
|
491
|
-
signing.delete(key);
|
|
492
|
-
}).then((view) => {
|
|
493
|
-
on(view);
|
|
494
|
-
return { view, id };
|
|
495
|
-
});
|
|
496
|
-
return { id, paste: handle.paste, cancel: handle.cancel, done };
|
|
497
950
|
}
|
|
498
|
-
return { list, add, rename, defaults, setDefaults, models, room, pick, rest };
|
|
951
|
+
return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move, remove };
|
|
499
952
|
}
|