@byokit/openclaw 0.10.0 → 0.11.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 +10 -0
- package/dist/accounts.d.ts +16 -0
- package/dist/accounts.js +222 -0
- package/dist/auth-store.js +20 -0
- package/dist/engine.js +1 -1
- package/dist/kit.d.ts +7 -1
- package/dist/kit.js +29 -0
- package/dist/locks.d.ts +5 -0
- package/dist/locks.js +85 -0
- package/dist/testing/contract.js +0 -1
- package/dist/testing/fake-gateway.js +215 -8
- package/package.json +6 -6
- package/plugin/accounts.js +224 -0
- package/plugin/index.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.11.0 (2026-10-12)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/ui 0.7.0.
|
|
8
|
+
- Dependency update: pins @byokit/secrets 0.5.1.
|
|
9
|
+
- Dependency update: pins @byokit/discover 0.8.0.
|
|
10
|
+
- Dependency update: pins @byokit/relay 0.6.1.
|
|
11
|
+
- Dependency update: pins @byokit/pair 0.10.0.
|
|
12
|
+
|
|
13
|
+
- FEAT: `byokit.accounts` seals an agent's own sign-ins (list/seal/ready/adopt/clear), loaded as the `plugin/accounts.js` gateway method; a failed `adopt` rolls the model and both profile stores back so no partial move or dangling pin survives, a self-adopt is refused, and the fake gateway mirrors every branch.
|
|
14
|
+
|
|
5
15
|
## 0.10.0 (2026-10-11)
|
|
6
16
|
|
|
7
17
|
- Dependency update: pins @byokit/discover 0.7.0.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createLocks } from './locks.ts';
|
|
2
|
+
import type { Account, AccountId, Defaults, GatewayTransport, Member } from './types.ts';
|
|
3
|
+
export declare function createAccounts(ctx: {
|
|
4
|
+
request: GatewayTransport['request'];
|
|
5
|
+
root: string;
|
|
6
|
+
ensure(member: Member): Promise<{
|
|
7
|
+
agentId: string;
|
|
8
|
+
workspace: string;
|
|
9
|
+
}>;
|
|
10
|
+
locks: ReturnType<typeof createLocks>;
|
|
11
|
+
}): {
|
|
12
|
+
list(member: Member): Promise<Account[]>;
|
|
13
|
+
rename(member: Member, id: AccountId, name: string): Promise<Account>;
|
|
14
|
+
defaults(member: Member): Promise<Defaults>;
|
|
15
|
+
setDefaults(member: Member, d: Defaults): Promise<void>;
|
|
16
|
+
};
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The per-member account index (5.15, O14 step S5): `<root>/accounts/<member>.json` (0600, whole-file temp
|
|
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 and defaults live here;
|
|
4
|
+
// add, remove, models, room, pick and move arrive in later steps.
|
|
5
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { KEY_PREFIX, MEMBER_ID } from "./members.js";
|
|
9
|
+
import { routes, routeFor } from "./routes.js";
|
|
10
|
+
const ACCOUNT_AGENT = /^[a-z][a-z0-9-]{0,31}--[0-9a-f]{6}$/;
|
|
11
|
+
const usable = (status) => status === 'ok' || status === 'expiring' || status === 'static';
|
|
12
|
+
const empty = () => ({ v: 1, accounts: {}, defaults: {} });
|
|
13
|
+
const validEntry = (v) => !!v && typeof v === 'object'
|
|
14
|
+
&& typeof v.agent === 'string' && typeof v.name === 'string'
|
|
15
|
+
&& typeof v.route === 'string' && Number.isFinite(v.addedAt)
|
|
16
|
+
&& (v.until === undefined || Number.isFinite(v.until))
|
|
17
|
+
&& (v.model === undefined || typeof v.model === 'string')
|
|
18
|
+
&& (v.who === undefined || typeof v.who === 'string');
|
|
19
|
+
export function createAccounts(ctx) {
|
|
20
|
+
const path = (member) => join(ctx.root, 'accounts', `${member}.json`);
|
|
21
|
+
const check = (member) => {
|
|
22
|
+
if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
|
|
23
|
+
throw new Error(`invalid member id: ${member}`);
|
|
24
|
+
};
|
|
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
|
+
const write = (member, file) => {
|
|
53
|
+
mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
|
|
54
|
+
const tmp = `${path(member)}.${randomUUID()}.tmp`;
|
|
55
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n');
|
|
56
|
+
chmodSync(tmp, 0o600);
|
|
57
|
+
try {
|
|
58
|
+
renameSync(tmp, path(member));
|
|
59
|
+
chmodSync(path(member), 0o600);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
rmSync(tmp, { force: true });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const metaOf = (choice) => {
|
|
66
|
+
const row = routes().find((r) => r.choice === choice);
|
|
67
|
+
if (row && (row.billing === 'subscription' || row.billing === 'api'))
|
|
68
|
+
return { label: row.label, billing: row.billing };
|
|
69
|
+
return { label: choice, billing: 'subscription' };
|
|
70
|
+
};
|
|
71
|
+
const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
|
|
72
|
+
?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
|
|
73
|
+
?? provider;
|
|
74
|
+
const rosterOf = () => ctx.request('agents.list')
|
|
75
|
+
.then((r) => new Set(r.agents.map((a) => a.id)), () => new Set());
|
|
76
|
+
const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
|
|
77
|
+
.then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
|
|
78
|
+
const stateOf = (route, status, until) => {
|
|
79
|
+
if (!routes().some((r) => r.choice === route))
|
|
80
|
+
return 'not_included';
|
|
81
|
+
if (until !== undefined && until > Date.now())
|
|
82
|
+
return 'resting';
|
|
83
|
+
if (!status)
|
|
84
|
+
return 'signed_out';
|
|
85
|
+
return usable(status) ? 'ready' : status === 'expired' ? 'needs_again' : 'signed_out';
|
|
86
|
+
};
|
|
87
|
+
// Lock-free row builder over one snapshot: mutates `file` (synthesized entries, `who` backfill, dropped
|
|
88
|
+
// dangling default) and reports whether it did. Callers own the locking.
|
|
89
|
+
const build = async (member, agentId, file, roster, memberProfiles) => {
|
|
90
|
+
let dirty = false;
|
|
91
|
+
const byProvider = new Map();
|
|
92
|
+
for (const p of memberProfiles) {
|
|
93
|
+
if (typeof p.provider !== 'string' || !p.provider)
|
|
94
|
+
continue;
|
|
95
|
+
byProvider.set(p.provider, [...(byProvider.get(p.provider) ?? []), p]);
|
|
96
|
+
}
|
|
97
|
+
const memberRows = [];
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
for (const [provider, profiles] of [...byProvider.entries()].sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
100
|
+
let entry = file.accounts[provider];
|
|
101
|
+
if (!entry || entry.agent !== agentId) {
|
|
102
|
+
const route = entry?.agent === agentId && typeof entry.route === 'string' ? entry.route : synthesizedRoute(provider);
|
|
103
|
+
const same = Object.values(file.accounts).filter((e) => e.agent === agentId).length;
|
|
104
|
+
entry = { agent: agentId, name: `${metaOf(route).label} ${same + 1}`, route, addedAt: 0 };
|
|
105
|
+
file.accounts[provider] = entry;
|
|
106
|
+
dirty = true;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.who && typeof profiles[0]?.who === 'string') {
|
|
109
|
+
entry.who = profiles[0].who;
|
|
110
|
+
dirty = true;
|
|
111
|
+
}
|
|
112
|
+
const meta = metaOf(entry.route);
|
|
113
|
+
memberRows.push({ id: provider, provider, route: entry.route, name: entry.name, label: meta.label,
|
|
114
|
+
billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
|
|
115
|
+
...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
|
|
116
|
+
seen.add(provider);
|
|
117
|
+
}
|
|
118
|
+
for (const [id, entry] of Object.entries(file.accounts)) {
|
|
119
|
+
if (entry.agent !== agentId || id.includes('.') || seen.has(id))
|
|
120
|
+
continue;
|
|
121
|
+
const stale = metaOf(entry.route);
|
|
122
|
+
memberRows.push({ id, provider: id, route: entry.route, name: entry.name, label: stale.label,
|
|
123
|
+
billing: stale.billing, state: stateOf(entry.route, undefined, entry.until),
|
|
124
|
+
...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
|
|
125
|
+
}
|
|
126
|
+
// Account agents: only what the index names, with exactly `<member>--<hex6>` shape. An unindexed
|
|
127
|
+
// agent of that shape is never listed, and an entry whose agent is gone is not listed either.
|
|
128
|
+
const agentRows = [];
|
|
129
|
+
for (const [id, entry] of Object.entries(file.accounts)) {
|
|
130
|
+
if (entry.agent === agentId || !ACCOUNT_AGENT.test(entry.agent) || !entry.agent.startsWith(`${member}--`))
|
|
131
|
+
continue;
|
|
132
|
+
if (!roster.has(entry.agent))
|
|
133
|
+
continue;
|
|
134
|
+
const provider = id.split('.')[0];
|
|
135
|
+
const meta = metaOf(entry.route);
|
|
136
|
+
const profiles = await profilesOf(entry.agent);
|
|
137
|
+
agentRows.push({ id, provider, route: entry.route, name: entry.name, label: meta.label,
|
|
138
|
+
billing: meta.billing, state: stateOf(entry.route, profiles[0]?.status, entry.until),
|
|
139
|
+
...(entry.until !== undefined ? { until: entry.until } : {}), addedAt: entry.addedAt });
|
|
140
|
+
}
|
|
141
|
+
memberRows.sort((a, b) => a.addedAt - b.addedAt || (a.id < b.id ? -1 : 1));
|
|
142
|
+
agentRows.sort((a, b) => a.addedAt - b.addedAt);
|
|
143
|
+
const rows = [...memberRows, ...agentRows];
|
|
144
|
+
if (file.defaults.account !== undefined && !rows.some((r) => r.id === file.defaults.account)) {
|
|
145
|
+
delete file.defaults.account;
|
|
146
|
+
dirty = true;
|
|
147
|
+
}
|
|
148
|
+
return { rows, dirty };
|
|
149
|
+
};
|
|
150
|
+
const gather = (agentId) => Promise.all([rosterOf(), profilesOf(agentId)]);
|
|
151
|
+
const buildRows = async (member, agentId) => {
|
|
152
|
+
const file = read(member);
|
|
153
|
+
const [roster, profiles] = await gather(agentId);
|
|
154
|
+
return { ...(await build(member, agentId, file, roster, profiles)), file };
|
|
155
|
+
};
|
|
156
|
+
async function list(member) {
|
|
157
|
+
check(member);
|
|
158
|
+
const { agentId } = await ctx.ensure(member);
|
|
159
|
+
const first = await ctx.locks.shared(agentId, () => buildRows(member, agentId));
|
|
160
|
+
if (!first.dirty)
|
|
161
|
+
return first.rows;
|
|
162
|
+
// A write is needed (synthesized entry, `who` backfill or dropped default): redo it under the
|
|
163
|
+
// exclusive lock so two first-lists cannot interleave their read-modify-write.
|
|
164
|
+
return ctx.locks.exclusive([agentId], async () => {
|
|
165
|
+
const { rows, dirty, file } = await buildRows(member, agentId);
|
|
166
|
+
if (dirty)
|
|
167
|
+
write(member, file);
|
|
168
|
+
return rows;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function rename(member, id, name) {
|
|
172
|
+
check(member);
|
|
173
|
+
if (typeof name !== 'string')
|
|
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);
|
|
179
|
+
if (!(await list(member)).some((r) => r.id === id))
|
|
180
|
+
throw new Error(`unknown account: ${id}`);
|
|
181
|
+
await ctx.locks.exclusive([agentId], async () => {
|
|
182
|
+
const file = read(member);
|
|
183
|
+
const entry = file.accounts[id];
|
|
184
|
+
if (!entry)
|
|
185
|
+
throw new Error(`unknown account: ${id}`);
|
|
186
|
+
entry.name = trimmed;
|
|
187
|
+
write(member, file);
|
|
188
|
+
});
|
|
189
|
+
const done = (await list(member)).find((r) => r.id === id);
|
|
190
|
+
if (!done)
|
|
191
|
+
throw new Error(`unknown account: ${id}`);
|
|
192
|
+
return done;
|
|
193
|
+
}
|
|
194
|
+
async function defaults(member) {
|
|
195
|
+
await list(member);
|
|
196
|
+
return read(member).defaults;
|
|
197
|
+
}
|
|
198
|
+
async function setDefaults(member, d) {
|
|
199
|
+
check(member);
|
|
200
|
+
if (!d || typeof d !== 'object')
|
|
201
|
+
throw new Error('setDefaults: defaults are invalid');
|
|
202
|
+
if (d.account !== undefined && typeof d.account !== 'string')
|
|
203
|
+
throw new Error('setDefaults: defaults are invalid');
|
|
204
|
+
if (d.model !== undefined && typeof d.model !== 'string')
|
|
205
|
+
throw new Error('setDefaults: defaults are invalid');
|
|
206
|
+
if (d.auto !== undefined && typeof d.auto !== 'boolean')
|
|
207
|
+
throw new Error('setDefaults: defaults are invalid');
|
|
208
|
+
const { agentId } = await ctx.ensure(member);
|
|
209
|
+
return ctx.locks.exclusive([agentId], async () => {
|
|
210
|
+
const file = read(member);
|
|
211
|
+
const next = {};
|
|
212
|
+
if (d.account !== undefined)
|
|
213
|
+
next.account = d.account;
|
|
214
|
+
if (d.model !== undefined)
|
|
215
|
+
next.model = d.model;
|
|
216
|
+
if (d.auto !== undefined)
|
|
217
|
+
next.auto = d.auto;
|
|
218
|
+
write(member, { ...file, defaults: next });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return { list, rename, defaults, setDefaults };
|
|
222
|
+
}
|
package/dist/auth-store.js
CHANGED
|
@@ -54,7 +54,27 @@ function put(path, bytes) {
|
|
|
54
54
|
// containing directory in `changed` instead of fsyncing it now. restore() fsyncs each distinct directory
|
|
55
55
|
// once after every file is written, so a restore still makes each rename durable before it removes the
|
|
56
56
|
// restoring marker, without one directory fsync per object. seal() and the stop path keep put().
|
|
57
|
+
// When the live file already holds the exact bytes at the restore mode, the write (and its file fsync,
|
|
58
|
+
// rename and directory fsync) is skipped: nothing on disk changes, so there is nothing to make durable.
|
|
59
|
+
// Size is compared before reading; any read error (or a missing, changed or different-mode file) writes.
|
|
60
|
+
function sameLiveFile(path, bytes) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = lstatSync(path);
|
|
63
|
+
if (!stat.isFile())
|
|
64
|
+
return false;
|
|
65
|
+
if ((stat.mode & 0o777) !== 0o600)
|
|
66
|
+
return false;
|
|
67
|
+
if (stat.size !== bytes.length)
|
|
68
|
+
return false;
|
|
69
|
+
return readFileSync(path).equals(Buffer.from(bytes));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
57
75
|
function putRestore(path, bytes, changed) {
|
|
76
|
+
if (sameLiveFile(path, bytes))
|
|
77
|
+
return;
|
|
58
78
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
59
79
|
const tmp = `${path}.sealing-${process.pid}`;
|
|
60
80
|
try {
|
package/dist/engine.js
CHANGED
|
@@ -256,7 +256,7 @@ export class Engine {
|
|
|
256
256
|
const pluginDir = join(this.root, 'plugin');
|
|
257
257
|
mkdirSync(pluginDir, { recursive: true, mode: 0o700 });
|
|
258
258
|
// The shipped plugin follows the kit on every prepare too: a state dir from an older kit must not keep its gate.
|
|
259
|
-
for (const f of ['package.json', 'index.js', 'keys.js', 'usage.js']) {
|
|
259
|
+
for (const f of ['package.json', 'index.js', 'keys.js', 'usage.js', 'accounts.js']) {
|
|
260
260
|
const source = join(kitDir, 'plugin', f);
|
|
261
261
|
putChanged(join(pluginDir, f), readFileSync(source, 'utf8'));
|
|
262
262
|
}
|
package/dist/kit.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { SealingAdapter } from '@byokit/secrets';
|
|
|
2
2
|
import { type AddKeyResult } from './keys.ts';
|
|
3
3
|
import { type RouteView } from './routes.ts';
|
|
4
4
|
import type { BrowserHost, BrowserOptions } from './browser.ts';
|
|
5
|
-
import type { Approval, CallOptions, Decision, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, KitEventName, KitEventPayload, LearningCapture, LearningMode, Member, RunEnd, RunEvent, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ToolHost, ToolSpec } from './types.ts';
|
|
5
|
+
import type { Account, AccountId, Approval, CallOptions, Decision, Defaults, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, KitEventName, KitEventPayload, LearningCapture, LearningMode, Member, RunEnd, RunEvent, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ToolHost, ToolSpec } from './types.ts';
|
|
6
6
|
export type KitOptions = {
|
|
7
7
|
stateDir: string;
|
|
8
8
|
authSeal?: SealingAdapter;
|
|
@@ -54,6 +54,8 @@ export declare class OpenClawKit {
|
|
|
54
54
|
private transport?;
|
|
55
55
|
private greeting?;
|
|
56
56
|
private members?;
|
|
57
|
+
private readonly locks;
|
|
58
|
+
private accountsCtl?;
|
|
57
59
|
private readonly bridge;
|
|
58
60
|
private readonly keys;
|
|
59
61
|
private readonly admission;
|
|
@@ -110,6 +112,10 @@ export declare class OpenClawKit {
|
|
|
110
112
|
done: Promise<SignInView>;
|
|
111
113
|
};
|
|
112
114
|
signOut(member: Member, provider: string): Promise<void>;
|
|
115
|
+
accounts(member: Member): Promise<Account[]>;
|
|
116
|
+
renameAccount(member: Member, id: AccountId, name: string): Promise<Account>;
|
|
117
|
+
defaults(member: Member): Promise<Defaults>;
|
|
118
|
+
setDefaults(member: Member, d: Defaults): Promise<void>;
|
|
113
119
|
/** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
|
|
114
120
|
addKey(agentId: Member, o: {
|
|
115
121
|
authChoice: string;
|
package/dist/kit.js
CHANGED
|
@@ -10,6 +10,8 @@ import { Engine, refusedOnly } from "./engine.js";
|
|
|
10
10
|
import { AuthStoreSealSizeError } from "./auth-store.js";
|
|
11
11
|
import { gatewayTransport } from "./transport.js";
|
|
12
12
|
import { createMembers } from "./members.js";
|
|
13
|
+
import { createAccounts } from "./accounts.js";
|
|
14
|
+
import { createLocks } from "./locks.js";
|
|
13
15
|
import { keyAgentId, KEY_PREFIX, MEMBER_ID } from "./members.js";
|
|
14
16
|
import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLogin } from "./migrate.js";
|
|
15
17
|
import { createRuns, signedInProviders } from "./runs.js";
|
|
@@ -429,6 +431,8 @@ export class OpenClawKit {
|
|
|
429
431
|
transport;
|
|
430
432
|
greeting;
|
|
431
433
|
members;
|
|
434
|
+
locks = createLocks();
|
|
435
|
+
accountsCtl;
|
|
432
436
|
// One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
|
|
433
437
|
// Only the socket itself is per-connection. Approvals always reaches the live transport through this.request().
|
|
434
438
|
bridge;
|
|
@@ -741,6 +745,8 @@ export class OpenClawKit {
|
|
|
741
745
|
if (!operatorMethods.has(name) && !nodeMethods.has(name))
|
|
742
746
|
this.o.log?.(`gateway has unknown method: ${name}`);
|
|
743
747
|
this.members = createMembers({ request: transport.request.bind(transport), root: this.engine.root });
|
|
748
|
+
this.accountsCtl = createAccounts({ request: transport.request.bind(transport), root: this.engine.root,
|
|
749
|
+
ensure: (member) => this.ensureMember(member), locks: this.locks });
|
|
744
750
|
// The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
|
|
745
751
|
await this.bridge.start();
|
|
746
752
|
this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
|
|
@@ -789,6 +795,7 @@ export class OpenClawKit {
|
|
|
789
795
|
this.transport = undefined;
|
|
790
796
|
this.greeting = undefined;
|
|
791
797
|
this.members = undefined;
|
|
798
|
+
this.accountsCtl = undefined;
|
|
792
799
|
this.browserPolicySessions.clear();
|
|
793
800
|
this.bridge.stop();
|
|
794
801
|
await transport?.stop();
|
|
@@ -919,6 +926,28 @@ export class OpenClawKit {
|
|
|
919
926
|
signOut(member, provider) {
|
|
920
927
|
return engineSignOut(this.signInCtx(), member, provider);
|
|
921
928
|
}
|
|
929
|
+
// accounts (5.15, O14 step S5): the per-member index with listing, defaults and rename. Adding,
|
|
930
|
+
// removing, models, room, pick and move arrive in later steps.
|
|
931
|
+
accounts(member) {
|
|
932
|
+
if (!this.accountsCtl)
|
|
933
|
+
return Promise.reject(new Error('gateway not ready'));
|
|
934
|
+
return this.accountsCtl.list(member);
|
|
935
|
+
}
|
|
936
|
+
renameAccount(member, id, name) {
|
|
937
|
+
if (!this.accountsCtl)
|
|
938
|
+
return Promise.reject(new Error('gateway not ready'));
|
|
939
|
+
return this.accountsCtl.rename(member, id, name);
|
|
940
|
+
}
|
|
941
|
+
defaults(member) {
|
|
942
|
+
if (!this.accountsCtl)
|
|
943
|
+
return Promise.reject(new Error('gateway not ready'));
|
|
944
|
+
return this.accountsCtl.defaults(member);
|
|
945
|
+
}
|
|
946
|
+
setDefaults(member, d) {
|
|
947
|
+
if (!this.accountsCtl)
|
|
948
|
+
return Promise.reject(new Error('gateway not ready'));
|
|
949
|
+
return this.accountsCtl.setDefaults(member, d);
|
|
950
|
+
}
|
|
922
951
|
/** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
|
|
923
952
|
addKey(agentId, o) {
|
|
924
953
|
return this.keys.add(agentId, o);
|
package/dist/locks.d.ts
ADDED
package/dist/locks.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export function createLocks() {
|
|
2
|
+
const slots = new Map();
|
|
3
|
+
const slot = (id) => {
|
|
4
|
+
let s = slots.get(id);
|
|
5
|
+
if (!s) {
|
|
6
|
+
s = { readers: 0, writer: false, queue: [] };
|
|
7
|
+
slots.set(id, s);
|
|
8
|
+
}
|
|
9
|
+
return s;
|
|
10
|
+
};
|
|
11
|
+
// FIFO: an exclusive waiter blocks later shared waiters until the readers ahead of it drain.
|
|
12
|
+
const pump = (s) => {
|
|
13
|
+
if (s.writer)
|
|
14
|
+
return;
|
|
15
|
+
const head = s.queue[0];
|
|
16
|
+
if (!head)
|
|
17
|
+
return;
|
|
18
|
+
if (head.mode === 'exclusive') {
|
|
19
|
+
if (s.readers === 0) {
|
|
20
|
+
s.queue.shift();
|
|
21
|
+
s.writer = true;
|
|
22
|
+
head.grant();
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
while (s.queue[0]?.mode === 'shared') {
|
|
27
|
+
const w = s.queue.shift();
|
|
28
|
+
s.readers++;
|
|
29
|
+
w.grant();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const acquire = (id, mode) => {
|
|
33
|
+
const s = slot(id);
|
|
34
|
+
if (mode === 'shared' && !s.writer && s.queue.every((w) => w.mode === 'shared')) {
|
|
35
|
+
s.readers++;
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
}
|
|
38
|
+
if (mode === 'exclusive' && !s.writer && s.readers === 0 && s.queue.length === 0) {
|
|
39
|
+
s.writer = true;
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
return new Promise((grant) => {
|
|
43
|
+
s.queue.push(mode === 'exclusive' ? { mode, grant: () => { s.writer = true; grant(); } } : { mode, grant });
|
|
44
|
+
pump(s);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const release = (id, mode) => {
|
|
48
|
+
const s = slot(id);
|
|
49
|
+
if (mode === 'shared')
|
|
50
|
+
s.readers = Math.max(0, s.readers - 1);
|
|
51
|
+
else
|
|
52
|
+
s.writer = false;
|
|
53
|
+
pump(s);
|
|
54
|
+
if (s.readers === 0 && !s.writer && s.queue.length === 0)
|
|
55
|
+
slots.delete(id);
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
async shared(agentId, work) {
|
|
59
|
+
await acquire(agentId, 'shared');
|
|
60
|
+
try {
|
|
61
|
+
return await work();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
release(agentId, 'shared');
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async exclusive(agentIds, work) {
|
|
68
|
+
// Sorted so two exclusive takes over overlapping sets cannot deadlock each other.
|
|
69
|
+
const ids = [...new Set(agentIds)].sort();
|
|
70
|
+
for (const id of ids)
|
|
71
|
+
await acquire(id, 'exclusive');
|
|
72
|
+
try {
|
|
73
|
+
return await work();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
for (const id of [...ids].reverse())
|
|
77
|
+
release(id, 'exclusive');
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
live(agentId) {
|
|
81
|
+
const s = slots.get(agentId);
|
|
82
|
+
return !!s && (s.readers > 0 || s.writer);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/testing/contract.js
CHANGED
|
@@ -18,7 +18,6 @@ const REQUIRED_METHODS = [
|
|
|
18
18
|
'plugin.approval.resolve', 'question.resolve', 'wizard.cancel', 'wizard.next',
|
|
19
19
|
];
|
|
20
20
|
// Driven by the kit but not advertised in hello (O11 probe, e.g. sessions.steer): routable, just not listed.
|
|
21
|
-
// The engine job's generated.test.ts audits the full generated table against hello instead of asserting it here.
|
|
22
21
|
const REQUIRED_EVENTS = [
|
|
23
22
|
'agent',
|
|
24
23
|
'exec.approval.requested', 'exec.approval.resolved',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `new OpenClawKit({ transport: fake.factory, spawnEngine: false, ... })` or drive `fake.transport.request`
|
|
3
3
|
// directly. No engine, no network, no account.
|
|
4
4
|
import { connect } from 'node:net';
|
|
5
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { ENGINE_VERSION, PROTOCOL_VERSION } from "../constants.js";
|
|
@@ -23,6 +23,12 @@ const EVENTS = [
|
|
|
23
23
|
/** Window between a run's last event and its completion, so an abort issued on the text event still lands. */
|
|
24
24
|
const ABORT_WINDOW_MS = 25;
|
|
25
25
|
const BRIDGE_TIMEOUT_MS = 30_000;
|
|
26
|
+
// The plugin's accounts.js status thresholds: auth selection's refresh margin and auth-health's oauth warn window.
|
|
27
|
+
const EXPIRING_MS = 300_000;
|
|
28
|
+
const OAUTH_WARN_MS = 86_400_000;
|
|
29
|
+
// The plugin's account agent shape (D9) and agent id shape, as plugin/accounts.js tests them.
|
|
30
|
+
const ACCOUNT_AGENT = /--[0-9a-f]{6}$/;
|
|
31
|
+
const AGENT_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
26
32
|
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
27
33
|
function mergeInto(target, patch) {
|
|
28
34
|
for (const [key, value] of Object.entries(patch)) {
|
|
@@ -100,6 +106,9 @@ export function fakeGateway(script, o) {
|
|
|
100
106
|
const handlers = new Map();
|
|
101
107
|
const agents = new Map();
|
|
102
108
|
const keys = new Map();
|
|
109
|
+
const profiles = new Map();
|
|
110
|
+
const orders = new Map();
|
|
111
|
+
const models = new Map();
|
|
103
112
|
const wizards = new Map();
|
|
104
113
|
const runs = new Map();
|
|
105
114
|
const configBox = { hash: 'hash-1', config: {} };
|
|
@@ -114,10 +123,92 @@ export function fakeGateway(script, o) {
|
|
|
114
123
|
agent.workspace = workspace;
|
|
115
124
|
return agent;
|
|
116
125
|
};
|
|
117
|
-
const
|
|
126
|
+
const storeOf = (id) => {
|
|
127
|
+
let store = profiles.get(id);
|
|
128
|
+
if (!store) {
|
|
129
|
+
store = {};
|
|
130
|
+
profiles.set(id, store);
|
|
131
|
+
}
|
|
132
|
+
return store;
|
|
133
|
+
};
|
|
134
|
+
const orderOf = (id) => {
|
|
135
|
+
let order = orders.get(id);
|
|
136
|
+
if (!order) {
|
|
137
|
+
order = {};
|
|
138
|
+
orders.set(id, order);
|
|
139
|
+
}
|
|
140
|
+
return order;
|
|
141
|
+
};
|
|
142
|
+
// As the plugin's accounts.js: `who` only exists for an OAuth profile that stores a provider account id.
|
|
143
|
+
const whoOf = (credential) => credential.type === 'oauth' && typeof credential.accountId === 'string' && credential.accountId.length > 0
|
|
144
|
+
? createHash('sha256').update(`${credential.provider}\n${credential.accountId}`).digest('hex').slice(0, 16) : undefined;
|
|
145
|
+
const hasSecret = (value, ref) => (typeof value === 'string' && value.length > 0) || (!!ref && typeof ref === 'object' && !Array.isArray(ref));
|
|
146
|
+
// The plugin's token expiry state (credential-state.ts): missing, invalid, expired, or valid.
|
|
147
|
+
const expiryState = (expires, now) => {
|
|
148
|
+
if (expires === undefined)
|
|
149
|
+
return 'missing';
|
|
150
|
+
if (typeof expires !== 'number' || !Number.isFinite(expires) || expires <= 0 || expires > 8.64e13)
|
|
151
|
+
return 'invalid';
|
|
152
|
+
return expires <= now ? 'expired' : 'valid';
|
|
153
|
+
};
|
|
154
|
+
// The plugin's profile status (auth-health.ts) from the credential alone: static, ok, expiring, expired or missing.
|
|
155
|
+
const statusOf = (credential, now) => {
|
|
156
|
+
if (credential.type === 'api_key')
|
|
157
|
+
return hasSecret(credential.key, credential.keyRef) ? 'static' : 'missing';
|
|
158
|
+
if (credential.type === 'token') {
|
|
159
|
+
if (!hasSecret(credential.token, credential.tokenRef))
|
|
160
|
+
return 'missing';
|
|
161
|
+
const state = expiryState(credential.expires, now);
|
|
162
|
+
if (state === 'invalid')
|
|
163
|
+
return 'missing';
|
|
164
|
+
if (state === 'expired')
|
|
165
|
+
return 'expired';
|
|
166
|
+
if (credential.expires === undefined)
|
|
167
|
+
return 'static';
|
|
168
|
+
return credential.expires - now <= EXPIRING_MS ? 'expiring' : 'ok';
|
|
169
|
+
}
|
|
170
|
+
if (!hasSecret(credential.access, undefined) && !hasSecret(credential.refresh, undefined))
|
|
171
|
+
return 'missing';
|
|
172
|
+
const expires = credential.expires;
|
|
173
|
+
const state = expiryState(expires, now);
|
|
174
|
+
if (state === 'invalid' || state === 'missing')
|
|
175
|
+
return 'missing';
|
|
176
|
+
if (state === 'expired')
|
|
177
|
+
return 'expired';
|
|
178
|
+
return expires - now <= OAUTH_WARN_MS ? 'expiring' : 'ok';
|
|
179
|
+
};
|
|
180
|
+
const entryPrimaryOf = (id) => {
|
|
181
|
+
const entries = configBox.config.agents?.entries;
|
|
182
|
+
const entry = entries?.[id];
|
|
183
|
+
const ref = entry?.model;
|
|
184
|
+
if (typeof ref === 'string')
|
|
185
|
+
return ref;
|
|
186
|
+
if (isPlainObject(ref) && typeof ref.primary === 'string')
|
|
187
|
+
return ref.primary;
|
|
188
|
+
return undefined;
|
|
189
|
+
};
|
|
190
|
+
const primaryOfId = (id) => entryPrimaryOf(id) ?? models.get(id);
|
|
191
|
+
const setPrimaryOfId = (id, primary) => {
|
|
192
|
+
models.set(id, primary);
|
|
193
|
+
const entries = configBox.config.agents?.entries;
|
|
194
|
+
const entry = entries?.[id];
|
|
195
|
+
if (entry && isPlainObject(entry)) {
|
|
196
|
+
const current = entry.model;
|
|
197
|
+
if (isPlainObject(current))
|
|
198
|
+
current.primary = primary;
|
|
199
|
+
else
|
|
200
|
+
entry.model = primary;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const signInAgent = (id, provider, extra) => {
|
|
118
204
|
const agent = ensureAgent(id);
|
|
119
205
|
if (!agent.providers.includes(provider))
|
|
120
206
|
agent.providers.push(provider);
|
|
207
|
+
// The pin names the first profile of a provider `<provider>:default` and points the agent's model at it.
|
|
208
|
+
storeOf(id)[`${provider}:default`] = { provider, type: 'oauth', status: 'ok', access: 'fake-access',
|
|
209
|
+
refresh: 'fake-refresh', expires: Date.now() + 30 * 86_400_000, accountId: `fake-${id}`, ...extra };
|
|
210
|
+
orderOf(id)[provider] = [`${provider}:default`];
|
|
211
|
+
setPrimaryOfId(id, `${provider}/default@${provider}:default`);
|
|
121
212
|
};
|
|
122
213
|
// Every transport the factory made; events fan out to all of them, drop() closes all of them.
|
|
123
214
|
const live = new Set();
|
|
@@ -223,10 +314,20 @@ export function fakeGateway(script, o) {
|
|
|
223
314
|
return { id };
|
|
224
315
|
},
|
|
225
316
|
// One profile per signed-in provider, `<provider>:default`, as the pin lists an agent's own profiles.
|
|
226
|
-
'models.authStatus': (p) =>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
317
|
+
'models.authStatus': (p) => {
|
|
318
|
+
const id = String(p?.agentId ?? 'main');
|
|
319
|
+
const store = storeOf(id);
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
return {
|
|
322
|
+
providers: (agents.get(id)?.providers ?? []).map((provider) => {
|
|
323
|
+
const profileId = `${provider}:default`;
|
|
324
|
+
const credential = store[profileId];
|
|
325
|
+
return { provider,
|
|
326
|
+
profiles: [{ profileId, type: credential?.type ?? 'oauth',
|
|
327
|
+
status: credential ? statusOf(credential, now) : 'ok' }] };
|
|
328
|
+
}),
|
|
329
|
+
};
|
|
330
|
+
},
|
|
230
331
|
'models.authLogout': (p) => {
|
|
231
332
|
const agent = agents.get(String(p?.agentId ?? 'main'));
|
|
232
333
|
// The pin refuses a profileIds list naming a profile the agent does not hold.
|
|
@@ -235,6 +336,17 @@ export function fakeGateway(script, o) {
|
|
|
235
336
|
throw new Error('profileIds contain unavailable auth profiles');
|
|
236
337
|
if (agent)
|
|
237
338
|
agent.providers = agent.providers.filter((provider) => provider !== p.provider);
|
|
339
|
+
const store = profiles.get(String(p.agentId ?? 'main'));
|
|
340
|
+
if (store)
|
|
341
|
+
for (const profileId of Object.keys(store))
|
|
342
|
+
if (store[profileId].provider === p.provider)
|
|
343
|
+
delete store[profileId];
|
|
344
|
+
const order = orders.get(String(p.agentId ?? 'main'));
|
|
345
|
+
if (order)
|
|
346
|
+
delete order[String(p.provider)];
|
|
347
|
+
const pin = models.get(String(p.agentId ?? 'main'));
|
|
348
|
+
if (typeof pin === 'string' && pin.includes(`@${String(p.provider)}:`))
|
|
349
|
+
models.delete(String(p.agentId ?? 'main'));
|
|
238
350
|
if (keys.get(String(p.agentId))?.provider === p.provider)
|
|
239
351
|
keys.delete(String(p.agentId));
|
|
240
352
|
return {};
|
|
@@ -255,6 +367,102 @@ export function fakeGateway(script, o) {
|
|
|
255
367
|
}
|
|
256
368
|
return { ok: key.sealed, ...(key.sealed ? { model: key.model } : {}) };
|
|
257
369
|
},
|
|
370
|
+
// Account sealing (5.15): the same five actions, branches and refusals the shipped plugin method serves.
|
|
371
|
+
'byokit.accounts': (p) => {
|
|
372
|
+
const action = String(p.action ?? '');
|
|
373
|
+
const entries = configBox.config.agents?.entries;
|
|
374
|
+
const known = (id) => Boolean(id) && AGENT_ID.test(id) && ((Boolean(entries) && id in entries) || agents.has(id));
|
|
375
|
+
if (action === 'adopt') {
|
|
376
|
+
const from = String(p.from), to = String(p.to), provider = String(p.provider);
|
|
377
|
+
if (from === to || !known(from) || !known(to))
|
|
378
|
+
return { ok: false };
|
|
379
|
+
if (typeof p.who !== 'string' || !p.who)
|
|
380
|
+
return { ok: false };
|
|
381
|
+
const fromOrder = orderOf(from), named = fromOrder[provider] ?? [];
|
|
382
|
+
const fromId = named.length === 1 ? named[0] : undefined;
|
|
383
|
+
const credential = fromId ? storeOf(from)[fromId] : undefined;
|
|
384
|
+
if (!fromId || !credential || credential.provider !== provider || whoOf(credential) !== p.who)
|
|
385
|
+
return { ok: false };
|
|
386
|
+
const target = primaryOfId(to);
|
|
387
|
+
if (typeof target !== 'string')
|
|
388
|
+
return { ok: false };
|
|
389
|
+
const toStore = storeOf(to), toOrder = orderOf(to);
|
|
390
|
+
for (const [otherId, other] of Object.entries(toStore))
|
|
391
|
+
if (ACCOUNT_AGENT.test(to) ? otherId !== fromId : other.provider === provider && otherId !== fromId)
|
|
392
|
+
delete toStore[otherId];
|
|
393
|
+
toStore[fromId] = credential;
|
|
394
|
+
toOrder[provider] = [fromId];
|
|
395
|
+
if (!ensureAgent(to).providers.includes(provider))
|
|
396
|
+
ensureAgent(to).providers.push(provider);
|
|
397
|
+
delete storeOf(from)[fromId];
|
|
398
|
+
if (fromOrder[provider]) {
|
|
399
|
+
fromOrder[provider] = fromOrder[provider].filter((x) => x !== fromId);
|
|
400
|
+
if (!fromOrder[provider].length)
|
|
401
|
+
delete fromOrder[provider];
|
|
402
|
+
}
|
|
403
|
+
if (!Object.values(storeOf(from)).some((c) => c.provider === provider))
|
|
404
|
+
ensureAgent(from).providers = ensureAgent(from).providers.filter((x) => x !== provider);
|
|
405
|
+
const cut = target.lastIndexOf('@');
|
|
406
|
+
setPrimaryOfId(to, `${cut > 0 ? target.slice(0, cut) : target}@${fromId}`);
|
|
407
|
+
return { ok: true };
|
|
408
|
+
}
|
|
409
|
+
const id = String(p.agentId ?? '');
|
|
410
|
+
if (!known(id))
|
|
411
|
+
return { ok: false };
|
|
412
|
+
const store = storeOf(id), order = orderOf(id);
|
|
413
|
+
if (action === 'list')
|
|
414
|
+
return { ok: true, profiles: Object.entries(store).map(([profileId, credential]) => ({ provider: credential.provider,
|
|
415
|
+
profileId, type: credential.type, status: statusOf(credential, Date.now()), ...(whoOf(credential) ? { who: whoOf(credential) } : {}) })) };
|
|
416
|
+
if (action === 'clear') {
|
|
417
|
+
profiles.set(id, {});
|
|
418
|
+
orders.set(id, {});
|
|
419
|
+
const cleared = agents.get(id);
|
|
420
|
+
if (cleared)
|
|
421
|
+
cleared.providers = [];
|
|
422
|
+
return { ok: true };
|
|
423
|
+
}
|
|
424
|
+
if (action === 'ready') {
|
|
425
|
+
const provider = String(p.provider ?? '');
|
|
426
|
+
const named = order[provider] ?? [];
|
|
427
|
+
const credential = named.length === 1 ? store[named[0]] : undefined;
|
|
428
|
+
if (!provider || !credential || credential.provider !== provider)
|
|
429
|
+
return { ok: false };
|
|
430
|
+
const status = statusOf(credential, Date.now());
|
|
431
|
+
if (status === 'missing' || status === 'expired')
|
|
432
|
+
return { ok: false };
|
|
433
|
+
return { ok: true, type: credential.type, ...(typeof credential.expires === 'number' ? { expiresAt: credential.expires } : {}),
|
|
434
|
+
...(whoOf(credential) ? { who: whoOf(credential) } : {}) };
|
|
435
|
+
}
|
|
436
|
+
if (action === 'seal') {
|
|
437
|
+
const primary = primaryOfId(id) ?? '';
|
|
438
|
+
const at = primary.lastIndexOf('@');
|
|
439
|
+
if (at < 1)
|
|
440
|
+
return { ok: false };
|
|
441
|
+
const profileId = primary.slice(at + 1);
|
|
442
|
+
const credential = store[profileId];
|
|
443
|
+
const account = ACCOUNT_AGENT.test(id);
|
|
444
|
+
if (!credential || credential.provider !== p.provider || (!account && credential.type === 'api_key'))
|
|
445
|
+
return { ok: false };
|
|
446
|
+
for (const [otherId, other] of Object.entries(store))
|
|
447
|
+
if (account ? otherId !== profileId : other.provider === p.provider && otherId !== profileId)
|
|
448
|
+
delete store[otherId];
|
|
449
|
+
// An account agent's whole order is replaced; a member's keeps its other providers, as plugin/accounts.js does.
|
|
450
|
+
const provider = String(p.provider);
|
|
451
|
+
if (account)
|
|
452
|
+
orders.set(id, { [provider]: [profileId] });
|
|
453
|
+
else
|
|
454
|
+
order[provider] = [profileId];
|
|
455
|
+
const sealedAgent = agents.get(id);
|
|
456
|
+
if (sealedAgent) {
|
|
457
|
+
if (account)
|
|
458
|
+
sealedAgent.providers = [provider];
|
|
459
|
+
else if (!sealedAgent.providers.includes(provider))
|
|
460
|
+
sealedAgent.providers.push(provider);
|
|
461
|
+
}
|
|
462
|
+
return { ok: true, ...(whoOf(credential) ? { who: whoOf(credential) } : {}) };
|
|
463
|
+
}
|
|
464
|
+
return { ok: false };
|
|
465
|
+
},
|
|
258
466
|
'openclaw.setup.activate': (p) => {
|
|
259
467
|
if (p.kind !== 'api-key')
|
|
260
468
|
throw new Error('only the API-key setup path is simulated');
|
|
@@ -397,8 +605,7 @@ export function fakeGateway(script, o) {
|
|
|
397
605
|
const hello = () => ({
|
|
398
606
|
protocol: PROTOCOL_VERSION,
|
|
399
607
|
server: { version: ENGINE_VERSION },
|
|
400
|
-
// The methods the fake serves. The generated tables are type-only until O2 ships runtime names
|
|
401
|
-
// generated.test.ts cross-checks the real engine's hello against the committed tables.
|
|
608
|
+
// The methods the fake serves. The generated tables are type-only until O2 ships runtime names.
|
|
402
609
|
methods: [...handlers.keys()].sort(),
|
|
403
610
|
events: [...EVENTS],
|
|
404
611
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -60,17 +60,17 @@
|
|
|
60
60
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@byokit/pair": "0.
|
|
64
|
-
"@byokit/relay": "0.6.
|
|
65
|
-
"@byokit/discover": "0.
|
|
63
|
+
"@byokit/pair": "0.10.0",
|
|
64
|
+
"@byokit/relay": "0.6.1",
|
|
65
|
+
"@byokit/discover": "0.8.0",
|
|
66
66
|
"@byokit/seal": "0.3.0",
|
|
67
|
-
"@byokit/secrets": "0.5.
|
|
67
|
+
"@byokit/secrets": "0.5.1",
|
|
68
68
|
"@openclaw/gateway-client": "2026.8.35",
|
|
69
69
|
"@openclaw/gateway-protocol": "2026.8.35",
|
|
70
70
|
"ws": "8.22.0"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
|
-
"@byokit/ui": "0.
|
|
73
|
+
"@byokit/ui": "0.7.0"
|
|
74
74
|
},
|
|
75
75
|
"publishConfig": {
|
|
76
76
|
"access": "public"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Account sealing (5.15, O14): the gateway method `byokit.accounts` over one agent's own auth-profile store.
|
|
2
|
+
// list/seal/ready/adopt/clear. Runs beside the other plugin methods; no credential value ever crosses an answer.
|
|
3
|
+
// `who` is a one-way identity: the first 16 hex digits of sha256(`<provider>\n<accountId>`), so the account id
|
|
4
|
+
// itself never leaves the engine.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
|
|
8
|
+
// Local-only store I/O (as keys.js): never read through inherited auth and never sync an external CLI.
|
|
9
|
+
const LOCAL = { filterExternalAuthProfiles: false, syncExternalCli: false };
|
|
10
|
+
// An account agent is `<member>--<6 hex>` (D9); a member id never contains `--`.
|
|
11
|
+
const ACCOUNT_AGENT = /--[0-9a-f]{6}$/;
|
|
12
|
+
const AGENT_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
13
|
+
const EXPIRING_MS = 300_000; // auth selection's refresh margin: at or under this, a token is "expiring"
|
|
14
|
+
const OAUTH_WARN_MS = 86_400_000; // auth-health's default oauth warn window: a day out is still "expiring"
|
|
15
|
+
|
|
16
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
17
|
+
const primaryOf = (entry) => {
|
|
18
|
+
const ref = entry?.model;
|
|
19
|
+
return typeof ref === 'string' ? ref : object(ref) && typeof ref.primary === 'string' ? ref.primary : undefined;
|
|
20
|
+
};
|
|
21
|
+
const hasSecret = (value, ref) => (typeof value === 'string' && value.length > 0) || object(ref);
|
|
22
|
+
/** The sign-in identity for an OAuth profile that stores the provider's account id; api_key/token never have one. */
|
|
23
|
+
const whoOf = (credential) => credential?.type === 'oauth' && typeof credential.accountId === 'string' && credential.accountId.length > 0
|
|
24
|
+
? createHash('sha256').update(`${credential.provider}\n${credential.accountId}`).digest('hex').slice(0, 16) : undefined;
|
|
25
|
+
/** The engine's token expiry state (credential-state.ts): missing, invalid, expired, or valid. */
|
|
26
|
+
const expiryState = (expires, now) => {
|
|
27
|
+
if (expires === undefined) return 'missing';
|
|
28
|
+
if (typeof expires !== 'number' || !Number.isFinite(expires) || expires <= 0 || expires > 8.64e13) return 'invalid';
|
|
29
|
+
return expires <= now ? 'expired' : 'valid';
|
|
30
|
+
};
|
|
31
|
+
/** The engine's profile status (auth-health.ts), from the credential alone: static, ok, expiring, expired or missing. */
|
|
32
|
+
const statusOf = (credential, now) => {
|
|
33
|
+
if (credential.type === 'api_key') return hasSecret(credential.key, credential.keyRef) ? 'static' : 'missing';
|
|
34
|
+
if (credential.type === 'token') {
|
|
35
|
+
if (!hasSecret(credential.token, credential.tokenRef)) return 'missing';
|
|
36
|
+
const state = expiryState(credential.expires, now);
|
|
37
|
+
if (state === 'invalid') return 'missing';
|
|
38
|
+
if (state === 'expired') return 'expired';
|
|
39
|
+
if (credential.expires === undefined) return 'static';
|
|
40
|
+
return credential.expires - now <= EXPIRING_MS ? 'expiring' : 'ok';
|
|
41
|
+
}
|
|
42
|
+
if (!hasSecret(credential.access, undefined) && !hasSecret(credential.refresh, undefined)) return 'missing';
|
|
43
|
+
const state = expiryState(credential.expires, now);
|
|
44
|
+
if (state === 'invalid' || state === 'missing') return 'missing';
|
|
45
|
+
if (state === 'expired') return 'expired';
|
|
46
|
+
return credential.expires - now <= OAUTH_WARN_MS ? 'expiring' : 'ok';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A read (`fn` returns nothing) or a write when `fn` sets `store.changed`. Resolves the store, or null on lock contention. */
|
|
50
|
+
function withStore(updateAuthProfileStoreWithLock, agentDir, fn) {
|
|
51
|
+
return updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(store) { return fn(store); } });
|
|
52
|
+
}
|
|
53
|
+
/** Keep only `profileId` of `provider`; on an account agent drop every other provider too. */
|
|
54
|
+
const sealProfiles = (profiles, provider, profileId, account) => account
|
|
55
|
+
? { [profileId]: { ...profiles[profileId] } }
|
|
56
|
+
: Object.fromEntries(Object.entries(profiles).filter(([id, p]) => p.provider !== provider || id === profileId));
|
|
57
|
+
|
|
58
|
+
export function registerAccounts(api) {
|
|
59
|
+
api.registerGatewayMethod('byokit.accounts', async ({ params, respond }) => {
|
|
60
|
+
// Every failure answers { ok: false }; no engine error or store contents ever leave this method.
|
|
61
|
+
try { respond(true, await handle(params)); }
|
|
62
|
+
catch { respond(true, { ok: false }); }
|
|
63
|
+
}, { scope: 'operator.admin' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function handle(params) {
|
|
67
|
+
const { readFile } = await import('node:fs/promises');
|
|
68
|
+
const config = JSON.parse(await readFile(process.env.OPENCLAW_CONFIG_PATH, 'utf8'));
|
|
69
|
+
const { resolveAgentDir } = await import('openclaw/plugin-sdk/agent-runtime');
|
|
70
|
+
const { updateAuthProfileStoreWithLock } = await import('openclaw/plugin-sdk/provider-auth');
|
|
71
|
+
// adopt names both agents itself: it is the one action that does not address a single agent.
|
|
72
|
+
if (params?.action === 'adopt') return adopt(params, config, resolveAgentDir, updateAuthProfileStoreWithLock);
|
|
73
|
+
|
|
74
|
+
const agentId = params?.agentId;
|
|
75
|
+
if (typeof agentId !== 'string' || !AGENT_ID.test(agentId)) return { ok: false };
|
|
76
|
+
const entry = config.agents?.entries?.[agentId];
|
|
77
|
+
if (!entry) return { ok: false };
|
|
78
|
+
const agentDir = resolveAgentDir(config, agentId);
|
|
79
|
+
const read = (fn) => withStore(updateAuthProfileStoreWithLock, agentDir, fn);
|
|
80
|
+
|
|
81
|
+
if (params.action === 'list') {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
let rows;
|
|
84
|
+
const store = await read((s) => {
|
|
85
|
+
rows = Object.entries(s.profiles ?? {}).map(([profileId, credential]) => {
|
|
86
|
+
const row = { provider: credential.provider, profileId, type: credential.type, status: statusOf(credential, now) };
|
|
87
|
+
const who = whoOf(credential);
|
|
88
|
+
if (who) row.who = who;
|
|
89
|
+
return row;
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return store && rows ? { ok: true, profiles: rows } : { ok: false };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (params.action === 'ready') {
|
|
96
|
+
const provider = params.provider;
|
|
97
|
+
if (typeof provider !== 'string' || !provider) return { ok: false };
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
let answer;
|
|
100
|
+
const store = await read((s) => {
|
|
101
|
+
const order = s.order?.[provider];
|
|
102
|
+
const profileId = Array.isArray(order) && order.length === 1 ? order[0] : undefined;
|
|
103
|
+
const credential = profileId ? s.profiles?.[profileId] : undefined;
|
|
104
|
+
if (!credential || credential.provider !== provider) return;
|
|
105
|
+
const status = statusOf(credential, now);
|
|
106
|
+
if (status === 'missing' || status === 'expired') return;
|
|
107
|
+
answer = { type: credential.type };
|
|
108
|
+
if (typeof credential.expires === 'number') answer.expiresAt = credential.expires;
|
|
109
|
+
const who = whoOf(credential);
|
|
110
|
+
if (who) answer.who = who;
|
|
111
|
+
});
|
|
112
|
+
return store && answer ? { ok: true, ...answer } : { ok: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (params.action === 'seal') {
|
|
116
|
+
const provider = params.provider;
|
|
117
|
+
if (typeof provider !== 'string' || !provider) return { ok: false };
|
|
118
|
+
const primary = primaryOf(entry);
|
|
119
|
+
const at = typeof primary === 'string' ? primary.lastIndexOf('@') : -1;
|
|
120
|
+
if (at < 1) return { ok: false };
|
|
121
|
+
const profileId = primary.slice(at + 1);
|
|
122
|
+
const account = ACCOUNT_AGENT.test(agentId);
|
|
123
|
+
let who;
|
|
124
|
+
let sealed = false;
|
|
125
|
+
const store = await updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(s) {
|
|
126
|
+
const credential = s.profiles?.[profileId];
|
|
127
|
+
// No such profile, or the named profile belongs to another provider; on a member agent an api_key is refused.
|
|
128
|
+
if (!credential || credential.provider !== provider || (!account && credential.type === 'api_key')) return false;
|
|
129
|
+
who = whoOf(credential);
|
|
130
|
+
s.profiles = sealProfiles(s.profiles, provider, profileId, account);
|
|
131
|
+
s.order = account ? { [provider]: [profileId] } : { ...(s.order ?? {}), [provider]: [profileId] };
|
|
132
|
+
if (object(s.lastGood) && provider in s.lastGood) delete s.lastGood[provider];
|
|
133
|
+
sealed = true;
|
|
134
|
+
return true;
|
|
135
|
+
} });
|
|
136
|
+
return store && sealed ? { ok: true, ...(who ? { who } : {}) } : { ok: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (params.action === 'clear') {
|
|
140
|
+
const cleared = await updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(s) {
|
|
141
|
+
s.profiles = {}; s.order = {}; delete s.lastGood; return true;
|
|
142
|
+
} });
|
|
143
|
+
return cleared ? { ok: true } : { ok: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Move `from`'s sealed profile of `provider` into `to` and point `to`'s model at it, or answer { ok: false } with both
|
|
150
|
+
* stores and the model as they were. The three writes are separate transactions, so any failure after the first is
|
|
151
|
+
* undone in reverse: a partial move (or a model pin to a profile no store holds) must never outlive the answer. */
|
|
152
|
+
async function adopt(params, config, resolveAgentDir, updateAuthProfileStoreWithLock) {
|
|
153
|
+
const { from, to, provider, who } = params;
|
|
154
|
+
if (typeof from !== 'string' || !AGENT_ID.test(from) || typeof to !== 'string' || !AGENT_ID.test(to)
|
|
155
|
+
|| typeof provider !== 'string' || !provider || typeof who !== 'string' || !who) return { ok: false };
|
|
156
|
+
if (from === to) return { ok: false };
|
|
157
|
+
if (!config.agents?.entries?.[from] || !config.agents?.entries?.[to]) return { ok: false };
|
|
158
|
+
const fromDir = resolveAgentDir(config, from);
|
|
159
|
+
const toDir = resolveAgentDir(config, to);
|
|
160
|
+
// from's sealed profile (order[provider] names exactly it) must carry exactly this who, or nothing changes.
|
|
161
|
+
let fromId;
|
|
162
|
+
let moved;
|
|
163
|
+
await withStore(updateAuthProfileStoreWithLock, fromDir, (s) => {
|
|
164
|
+
const order = s.order?.[provider];
|
|
165
|
+
fromId = Array.isArray(order) && order.length === 1 ? order[0] : undefined;
|
|
166
|
+
const credential = fromId ? s.profiles?.[fromId] : undefined;
|
|
167
|
+
if (credential && credential.provider === provider && whoOf(credential) === who) moved = credential;
|
|
168
|
+
});
|
|
169
|
+
if (!moved) return { ok: false };
|
|
170
|
+
// The model's current primary names the shape (string or object) and the base ref the new pin keeps.
|
|
171
|
+
const target = primaryOf(config.agents.entries[to]);
|
|
172
|
+
if (typeof target !== 'string') return { ok: false };
|
|
173
|
+
const cut = target.lastIndexOf('@');
|
|
174
|
+
const base = cut > 0 ? target.slice(0, cut) : target;
|
|
175
|
+
const { updateConfig } = await import('openclaw/plugin-sdk/config-runtime');
|
|
176
|
+
const { setAgentEffectiveModelPrimary } = await import('openclaw/plugin-sdk/agent-runtime');
|
|
177
|
+
const pin = (primary) => updateConfig((cfg) => { setAgentEffectiveModelPrimary(cfg, to, primary, { forceAgent: true }); return cfg; });
|
|
178
|
+
|
|
179
|
+
const account = ACCOUNT_AGENT.test(to);
|
|
180
|
+
let pre;
|
|
181
|
+
const restoreTo = () => updateAuthProfileStoreWithLock({ agentDir: toDir, saveOptions: LOCAL, updater(s) {
|
|
182
|
+
s.profiles = pre.profiles;
|
|
183
|
+
if (pre.order === undefined) delete s.order; else s.order = pre.order;
|
|
184
|
+
if (pre.lastGood === undefined) delete s.lastGood; else s.lastGood = pre.lastGood;
|
|
185
|
+
return true;
|
|
186
|
+
} });
|
|
187
|
+
try {
|
|
188
|
+
// Rewrite to's model primary @ suffix to from's sealed id before any store move: the pin would otherwise keep
|
|
189
|
+
// pinning the profile we are about to delete.
|
|
190
|
+
await pin(`${base}@${fromId}`);
|
|
191
|
+
// Move the record unchanged into to's store, sealed to it (dropping to's old profile of provider), then clear it
|
|
192
|
+
// from from. The grant stays shared; each agent holds only its own view (D11).
|
|
193
|
+
const sealed = await updateAuthProfileStoreWithLock({ agentDir: toDir, saveOptions: LOCAL, updater(s) {
|
|
194
|
+
pre = { profiles: s.profiles, order: s.order, lastGood: object(s.lastGood) ? { ...s.lastGood } : s.lastGood };
|
|
195
|
+
s.profiles = { ...sealProfiles(s.profiles, provider, fromId, account), [fromId]: moved };
|
|
196
|
+
s.order = { ...(s.order ?? {}), [provider]: [fromId] };
|
|
197
|
+
if (object(s.lastGood) && provider in s.lastGood) delete s.lastGood[provider];
|
|
198
|
+
return true;
|
|
199
|
+
} });
|
|
200
|
+
if (!sealed) { await pin(target); return { ok: false }; }
|
|
201
|
+
let clearedOk = false;
|
|
202
|
+
const cleared = await updateAuthProfileStoreWithLock({ agentDir: fromDir, saveOptions: LOCAL, updater(s) {
|
|
203
|
+
// A refresh between the read and this clear must not be deleted with the stale snapshot: refuse when the
|
|
204
|
+
// live record no longer equals the moved one (a who check alone would miss it; the account id never
|
|
205
|
+
// changes on refresh), and the caller rolls the move back.
|
|
206
|
+
if (!isDeepStrictEqual(s.profiles?.[fromId], moved)) return false;
|
|
207
|
+
if (s.profiles) delete s.profiles[fromId];
|
|
208
|
+
if (s.order?.[provider]) {
|
|
209
|
+
s.order[provider] = s.order[provider].filter((id) => id !== fromId);
|
|
210
|
+
if (!s.order[provider].length) delete s.order[provider];
|
|
211
|
+
}
|
|
212
|
+
clearedOk = true;
|
|
213
|
+
return true;
|
|
214
|
+
} });
|
|
215
|
+
if (cleared && clearedOk) return { ok: true };
|
|
216
|
+
await restoreTo();
|
|
217
|
+
await pin(target);
|
|
218
|
+
return { ok: false };
|
|
219
|
+
} catch {
|
|
220
|
+
await pin(target).catch(() => {});
|
|
221
|
+
if (pre) await restoreTo().catch(() => {});
|
|
222
|
+
return { ok: false };
|
|
223
|
+
}
|
|
224
|
+
}
|
package/plugin/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { connect } from 'node:net';
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { registerKeys } from './keys.js';
|
|
7
7
|
import { registerUsage } from './usage.js';
|
|
8
|
+
import { registerAccounts } from './accounts.js';
|
|
8
9
|
|
|
9
10
|
/** TypeBox's Kind is a global symbol, so these schemas are valid TypeBox without importing typebox. */
|
|
10
11
|
const Kind = Symbol.for('TypeBox.Kind');
|
|
@@ -146,7 +147,7 @@ export default {
|
|
|
146
147
|
jsonSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
147
148
|
},
|
|
148
149
|
register(api) {
|
|
149
|
-
if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); }
|
|
150
|
+
if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); registerAccounts(api); }
|
|
150
151
|
if (BROWSER) {
|
|
151
152
|
if (typeof api.registerAgentToolResultMiddleware !== 'function') throw new Error('browser result protection unavailable');
|
|
152
153
|
api.registerAgentToolResultMiddleware(async (event, ctx) => {
|