@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 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
+ };
@@ -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
+ }
@@ -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);
@@ -0,0 +1,5 @@
1
+ export declare function createLocks(): {
2
+ shared<T>(agentId: string, work: () => Promise<T>): Promise<T>;
3
+ exclusive<T>(agentIds: string[], work: () => Promise<T>): Promise<T>;
4
+ live(agentId: string): boolean;
5
+ };
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
+ }
@@ -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 signInAgent = (id, provider) => {
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
- providers: (agents.get(String(p?.agentId ?? 'main'))?.providers ?? []).map((provider) => ({ provider,
228
- profiles: [{ profileId: `${provider}:default`, type: 'oauth', status: 'ok' }] })),
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; O11's
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.10.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.9.0",
64
- "@byokit/relay": "0.6.0",
65
- "@byokit/discover": "0.7.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.0",
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.6.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) => {