@byokit/openclaw 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.0 (2026-10-14)
6
+
7
+ - FEAT: OpenClaw kit S8 `kit.removeAccount(member, id)`: a member-agent account goes through `models.authLogout` with its sealed profileIds, then an empty provider `list` (still listed stops with nothing dropped and says the account is still signing in), then its entry is dropped; an account agent goes through `byokit.accounts clear`, an empty `list` (a profile still listed after clear stops with nothing deleted and says the account is still signing in), `agents.delete` with `deleteFiles: false`, removal of its `<root>/state/agents/<agent>` dir, then the entry. Removal is refused `busy` while a run holds the agent and the member stays ready. On every `prepare` and after the sealed auth store restores, the kit removes each `<member>--<hex6>` state dir that a removal recorded as gone and no index names; a legacy member's own dir is never recorded and stays. `byokit.accounts clear` takes an optional `provider` that clears only that provider; a seal failure on a member-agent sign-in clears only that provider, and when that clear is refused the add reports failed with its credential still on the member agent.
8
+ - SECURITY: `kit.removeAccount` re-seals the live credential store at once while the engine runs, so `auth-store.sealed` and `auth-store.objects/` stop holding the removed profile immediately instead of keeping it until the next stop; a late engine write of the removed agent is swept on the next `prepare` or sealed restore. Durable removal tombstones also exclude late refreshes from stop-time sealing and prune removed-agent entries and objects from older sealed stores before restore.
9
+ - FIX: Changing the wrap mode (for example turning on dual wrapping) now reseals every engine store under the new mode on the next stop, including unchanged ones, so a later restore with the OS keyring locked opens them. Since 0.12.0 unchanged stores kept their old keyring-only wrap and that restore failed with `AuthStoreUnreadableError` (`invalid-snapshot`).
10
+
5
11
  ## 0.12.0 (2026-10-13)
6
12
 
7
13
  - FIX: an unsealed API key that a failed `addKey` could not clear (the gateway refused the clear after a restart ran late) is now dropped at the kit's first ready after start, so it no longer stays in the member's `byokit-key-<member>` store until the next add. A key whose ready marker exists is never cleared, and a sweep the gateway interrupts runs again on the next ready.
@@ -27,6 +27,8 @@ type StartSignIn = (agentId: string, o: SignInOptions, on: (v: SignInView) => vo
27
27
  done: Promise<SignInView>;
28
28
  };
29
29
  export declare function memberOfAccount(root: string, ref: string): Member | undefined;
30
+ export declare function removedAccountAgents(root: string): Set<string>;
31
+ export declare function sweepOrphans(root: string): void;
30
32
  export declare function createAccounts(ctx: {
31
33
  request: GatewayTransport['request'];
32
34
  root: string;
@@ -40,6 +42,7 @@ export declare function createAccounts(ctx: {
40
42
  session: string;
41
43
  }>;
42
44
  log?: (line: string) => void;
45
+ reseal?: () => Promise<void>;
43
46
  }): {
44
47
  list(member: Member): Promise<Account[]>;
45
48
  add(member: Member, o: AddOptions, on: (v: SignInView) => void): Promise<AccountSignIn>;
@@ -56,5 +59,6 @@ export declare function createAccounts(ctx: {
56
59
  }>;
57
60
  rest(member: Member, id: AccountId, until: number): Promise<void>;
58
61
  move(member: Member, sessionKey: string, to: AccountId): Promise<MoveResult>;
62
+ remove(member: Member, id: AccountId): Promise<void>;
59
63
  };
60
64
  export {};
package/dist/accounts.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // The per-member account index (5.15, O14 step S5): `<root>/accounts/<member>.json` (0600, whole-file temp
2
2
  // plus rename) is the only kit state: `{ v: 1, accounts: { [id]: { agent, name, route, addedAt, until?, model?,
3
- // who? } }, defaults }`. No credential, token, email or engine line. Listing, rename, defaults, models, room,
4
- // adding with its identity merge (O14 steps S6, S7), the start-of-run pick (S10), session binding (S11) and moving a
5
- // conversation (S12) live here; remove arrives in a later step.
6
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ // who? } }, defaults, gone? }`, where `gone` names the agent dirs a removal left for the orphan sweep. No credential,
4
+ // token, email or engine line. Listing, rename, defaults, models, room, adding with its identity merge (O14 steps
5
+ // S6, S7), the start-of-run pick (S10), session binding (S11), moving a conversation (S12) and removal with its
6
+ // orphan sweep (S8) live here.
7
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
8
  import { randomBytes, randomUUID } from 'node:crypto';
8
9
  import { join } from 'node:path';
9
10
  import { providerOf, resolveSelection } from "./pick.js";
@@ -86,7 +87,8 @@ const readIndex = (root, member) => {
86
87
  defaults.model = raw.model;
87
88
  if (typeof raw.auto === 'boolean')
88
89
  defaults.auto = raw.auto;
89
- return { v: 1, accounts, defaults };
90
+ const gone = Array.isArray(file.gone) ? file.gone.filter((a) => typeof a === 'string' && ACCOUNT_AGENT.test(a)) : [];
91
+ return { v: 1, accounts, defaults, ...(gone.length ? { gone } : {}) };
90
92
  };
91
93
  // Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
92
94
  // own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
@@ -108,6 +110,64 @@ export function memberOfAccount(root, ref) {
108
110
  return undefined;
109
111
  }
110
112
  }
113
+ // Every agent any member's index names. An index that cannot be read or parsed throws: its accounts are unknown,
114
+ // so no sweep may run.
115
+ function indexedAgents(root) {
116
+ const named = new Set();
117
+ const gone = new Set();
118
+ let files;
119
+ try {
120
+ files = readdirSync(join(root, 'accounts'));
121
+ }
122
+ catch (error) {
123
+ if (error.code === 'ENOENT')
124
+ return { named, gone };
125
+ throw error;
126
+ }
127
+ for (const file of files) {
128
+ if (!file.endsWith('.json'))
129
+ continue;
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
133
+ }
134
+ catch (error) {
135
+ throw new Error(`account index is unreadable: accounts/${file}`, { cause: error });
136
+ }
137
+ for (const entry of Object.values(parsed.accounts ?? {}))
138
+ if (typeof entry?.agent === 'string')
139
+ named.add(entry.agent);
140
+ for (const agent of Array.isArray(parsed.gone) ? parsed.gone : [])
141
+ if (typeof agent === 'string')
142
+ gone.add(agent);
143
+ }
144
+ return { named, gone };
145
+ }
146
+ // Durable removal tombstones, except ids an index now names again. Shared by the live and sealed-store sweeps.
147
+ export function removedAccountAgents(root) {
148
+ const { named, gone } = indexedAgents(root);
149
+ return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent)));
150
+ }
151
+ // Removes each `<root>/state/agents/<member>--<hex6>` directory that a removal recorded as gone and no index names.
152
+ // Only recorded directories go: a legacy member's own directory is never recorded, so it stays. The directories are
153
+ // listed before the index is read: each listed one was created after its entry was written.
154
+ export function sweepOrphans(root) {
155
+ let dirs;
156
+ try {
157
+ dirs = readdirSync(join(root, 'state', 'agents'));
158
+ }
159
+ catch (error) {
160
+ if (error.code === 'ENOENT')
161
+ return;
162
+ throw error;
163
+ }
164
+ const gone = removedAccountAgents(root);
165
+ for (const dir of dirs) {
166
+ if (!gone.has(dir))
167
+ continue;
168
+ rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
169
+ }
170
+ }
111
171
  export function createAccounts(ctx) {
112
172
  const path = (member) => indexPath(ctx.root, member);
113
173
  const read = (member) => readIndex(ctx.root, member);
@@ -119,6 +179,13 @@ export function createAccounts(ctx) {
119
179
  if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
120
180
  throw new Error(`invalid member id: ${member}`);
121
181
  };
182
+ const dropEntry = (file, id) => {
183
+ delete file.accounts[id];
184
+ if (file.defaults.account === id)
185
+ delete file.defaults.account;
186
+ };
187
+ const markGone = (file, agent) => { if (!file.gone?.includes(agent))
188
+ file.gone = [...(file.gone ?? []), agent]; };
122
189
  const write = (member, file) => {
123
190
  mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
124
191
  const tmp = `${path(member)}.${randomUUID()}.tmp`;
@@ -141,8 +208,8 @@ export function createAccounts(ctx) {
141
208
  const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
142
209
  ?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
143
210
  ?? provider;
144
- const rosterOf = () => ctx.request('agents.list')
145
- .then((r) => new Set(r.agents.map((a) => a.id)), () => new Set());
211
+ const agentRoster = () => ctx.request('agents.list').then((r) => new Set(r.agents.map((a) => a.id)));
212
+ const rosterOf = () => agentRoster().catch(() => new Set());
146
213
  const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
147
214
  .then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
148
215
  const stateOf = (route, status, until) => {
@@ -539,6 +606,63 @@ export function createAccounts(ctx) {
539
606
  // A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
540
607
  const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
541
608
  entry.until = until; });
609
+ const agentDir = (agent) => join(ctx.root, 'state', 'agents', agent);
610
+ // Removing (5.15): a member-agent account goes through `models.authLogout` with its sealed profileIds, then its
611
+ // entry is dropped. An account agent goes through `clear`, an empty `list`, `agents.delete` with
612
+ // `deleteFiles: false`, removal of its state dir, then the entry is dropped. Refused `busy` while a run holds
613
+ // the agent; the member agent stays ready (only the named provider is logged out or cleared).
614
+ const providerProfileIds = async (agentId, provider) => {
615
+ const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
616
+ if (listed.ok !== true || !Array.isArray(listed.profiles))
617
+ throw new Error('removeAccount: the sign-ins could not be read');
618
+ return listed.profiles.filter((p) => p.provider === provider && typeof p.profileId === 'string' && p.profileId).map((p) => p.profileId);
619
+ };
620
+ async function remove(member, id) {
621
+ check(member);
622
+ const { agentId: memberAgent } = await ctx.ensure(member);
623
+ const entry = read(member).accounts[id];
624
+ if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
625
+ throw new Error(`unknown account: ${id}`);
626
+ const agent = entry.agent;
627
+ const provider = id.split('.')[0];
628
+ if (ctx.locks.live(agent) || signing.has(`${member}\n${id}`))
629
+ throw new Error('removeAccount: busy');
630
+ if (agent === memberAgent) {
631
+ await ctx.locks.exclusive([agent, indexLock(member)], async () => {
632
+ const profileIds = await providerProfileIds(agent, provider);
633
+ if (profileIds.length)
634
+ await ctx.request('models.authLogout', { provider, agentId: agent, profileIds });
635
+ if ((await providerProfileIds(agent, provider)).length)
636
+ throw new Error('removeAccount: the account is still signing in');
637
+ const file = read(member);
638
+ dropEntry(file, id);
639
+ write(member, file);
640
+ // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
641
+ await ctx.reseal?.();
642
+ });
643
+ return;
644
+ }
645
+ await ctx.locks.exclusive([agent, indexLock(member)], async () => {
646
+ const roster = await agentRoster();
647
+ if (roster.has(agent)) {
648
+ await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
649
+ const listed = await ctx.request('byokit.accounts', { action: 'list', agentId: agent });
650
+ if (listed.ok !== true || !Array.isArray(listed.profiles))
651
+ throw new Error('removeAccount: the sign-ins could not be read');
652
+ if (listed.profiles.length)
653
+ throw new Error('removeAccount: the account is still signing in');
654
+ await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
655
+ }
656
+ rmSync(agentDir(agent), { recursive: true, force: true });
657
+ // Already holding the index lock: write directly instead of `edit`, which would wait on itself.
658
+ const file = read(member);
659
+ dropEntry(file, id);
660
+ markGone(file, agent);
661
+ write(member, file);
662
+ // The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
663
+ await ctx.reseal?.();
664
+ });
665
+ }
542
666
  // Index edits take the index lock, as listing's own write does.
543
667
  const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
544
668
  const identityOf = async (agentId) => {
@@ -581,7 +705,7 @@ export function createAccounts(ctx) {
581
705
  await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
582
706
  await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
583
707
  }
584
- rmSync(join(ctx.root, 'state', 'agents', agent), { recursive: true, force: true });
708
+ rmSync(agentDir(agent), { recursive: true, force: true });
585
709
  await edit(member, (file) => { delete file.accounts[id]; });
586
710
  })().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
587
711
  // Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
@@ -776,17 +900,28 @@ export function createAccounts(ctx) {
776
900
  return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
777
901
  }).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
778
902
  : (error instanceof Error ? error.message : String(error)).slice(0, 200));
779
- if (typeof sealed === 'string')
780
- view = { state: 'failed', via: view.via, why: 'failed', error: sealed };
781
- else {
782
- for (const dup of sealed.dups)
783
- await collapse(dup, sealed.keep, sealed.who);
784
- const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
785
- if (!into || !await adopt(sealed.who, into[1].agent))
786
- return { view, id };
787
- await removeEntry();
788
- return { view, id: into[0] };
903
+ if (typeof sealed === 'string') {
904
+ let error = sealed;
905
+ // A seal failure on the member agent (the non-fresh path) leaves its new credential with no index entry,
906
+ // so listing hides it but runs could use it. Clear only this provider: the bare clear wipes every profile.
907
+ if (agent === memberAgent) {
908
+ const cleared = await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
909
+ .then((answer) => answer?.ok === true, () => false);
910
+ if (!cleared) {
911
+ ctx.log?.(`addAccount: ${memberAgent} ${provider} is not cleared`);
912
+ error = `${sealed} Its credential is still on the member agent: remove this account.`;
913
+ }
914
+ }
915
+ await drop();
916
+ return { view: { state: 'failed', via: view.via, why: 'failed', error }, id };
789
917
  }
918
+ for (const dup of sealed.dups)
919
+ await collapse(dup, sealed.keep, sealed.who);
920
+ const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
921
+ if (!into || !await adopt(sealed.who, into[1].agent))
922
+ return { view, id };
923
+ await removeEntry();
924
+ return { view, id: into[0] };
790
925
  }
791
926
  // A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
792
927
  if (agent === memberAgent)
@@ -813,5 +948,5 @@ export function createAccounts(ctx) {
813
948
  throw error;
814
949
  }
815
950
  }
816
- return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move };
951
+ return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move, remove };
817
952
  }
@@ -61,6 +61,12 @@ export declare class AuthStore {
61
61
  private restore;
62
62
  archives(): Promise<void>;
63
63
  prepare(): Promise<void>;
64
+ /** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
65
+ * instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
66
+ * read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
67
+ * stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
68
+ * deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
69
+ reseal(): Promise<void>;
64
70
  start(): Promise<void>;
65
71
  stop(): Promise<void>;
66
72
  offline<T>(task: () => Promise<T>): Promise<T>;
@@ -8,6 +8,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
8
8
  import { createHash } from 'node:crypto';
9
9
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
10
  import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
11
+ import { removedAccountAgents, sweepOrphans } from "./accounts.js";
11
12
  const encoder = new TextEncoder();
12
13
  const archive = (name) => !name.endsWith('.sealed') && (/^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
13
14
  || name.endsWith('.moved-to-engine'));
@@ -140,6 +141,11 @@ function safePath(path) {
140
141
  return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
141
142
  && !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
142
143
  }
144
+ // A file the live engine deletes between the directory listing and the read is simply gone: there is nothing to
145
+ // seal, so callers skip it. Every other failure still throws.
146
+ function vanished(error) {
147
+ return error.code === 'ENOENT';
148
+ }
143
149
  function snapshot(text) {
144
150
  const s = JSON.parse(text);
145
151
  if ((s?.v !== 1 && s?.v !== 2) || !Array.isArray(s.dirs) || !Array.isArray(s.files))
@@ -206,8 +212,9 @@ export class AuthStore {
206
212
  cleanup;
207
213
  restoring;
208
214
  // Object file name -> sha256 of the plaintext payload currently sealed at that file. Kept only in memory: it
209
- // never becomes on-disk metadata. Filled by restore() from what is decrypted, refreshed after each successful
210
- // seal. A process that never restored starts with an empty map and reseals every object, as before.
215
+ // never becomes on-disk metadata. Filled by restore() from what is decrypted (except objects in an older wrap
216
+ // mode), refreshed after each successful seal. A process that never restored starts with an empty map and
217
+ // reseals every object, as before.
211
218
  objectHashes = new Map();
212
219
  constructor(privateOptions) {
213
220
  this.o = privateOptions;
@@ -315,19 +322,38 @@ export class AuthStore {
315
322
  catch {
316
323
  throw new AuthStoreUnreadableError('invalid-snapshot');
317
324
  }
318
- const upgraded = seal.upgrade?.(bytes);
319
- if (upgraded) {
320
- if (seal.decryptString(Buffer.from(upgraded)) !== text)
321
- throw new Error('credential upgrade verification failed');
322
- put(this.file, upgraded);
325
+ const gone = removedAccountAgents(this.o.root);
326
+ const keep = (path) => !path.startsWith('state/agents/') || !gone.has(path.split('/')[2]);
327
+ const kept = { v: 1, dirs: saved.dirs.filter(keep), files: saved.files.filter(([path]) => keep(path)) };
328
+ if (kept.dirs.length !== saved.dirs.length || kept.files.length !== saved.files.length) {
329
+ this.seal(this.file, JSON.stringify(kept));
330
+ }
331
+ else {
332
+ const upgraded = seal.upgrade?.(bytes);
333
+ if (upgraded) {
334
+ if (seal.decryptString(Buffer.from(upgraded)) !== text)
335
+ throw new Error('credential upgrade verification failed');
336
+ put(this.file, upgraded);
337
+ }
338
+ }
339
+ if (gone.size && existsSync(this.objects)) {
340
+ for (const entry of readdirSync(this.objects)) {
341
+ const file = join(this.objects, entry);
342
+ if (keep(this.readObject(file)[0]))
343
+ continue;
344
+ rmSync(file);
345
+ this.objectHashes.delete(entry);
346
+ }
323
347
  }
324
- return saved;
348
+ return kept;
325
349
  }
326
350
  readObject(file) {
351
+ let bytes = Buffer.alloc(0);
327
352
  let text = '';
328
353
  let entry = null;
329
354
  try {
330
- text = this.open(readFileSync(file));
355
+ bytes = readFileSync(file);
356
+ text = this.open(bytes);
331
357
  entry = JSON.parse(text);
332
358
  }
333
359
  catch (error) {
@@ -340,7 +366,7 @@ export class AuthStore {
340
366
  if (!safePath(path) || !apart(path) || typeof data !== 'string' || Buffer.from(data, 'base64').toString('base64') !== data) {
341
367
  throw new AuthStoreUnreadableError('invalid-snapshot');
342
368
  }
343
- return [path, data, text];
369
+ return [path, data, text, bytes];
344
370
  }
345
371
  inventory() {
346
372
  const dirs = [];
@@ -348,15 +374,35 @@ export class AuthStore {
348
374
  const objects = [];
349
375
  let total = 0;
350
376
  const root = realpathSync(this.o.root);
377
+ const gone = removedAccountAgents(this.o.root);
351
378
  const walk = (path) => {
352
379
  const name = relative(this.o.root, path).split('\\').join('/');
380
+ if (name.startsWith('state/agents/') && gone.has(name.split('/')[2]))
381
+ return; // a late refresh never enters a seal
353
382
  if (name.startsWith('home/') && nonCredential(name))
354
383
  return; // regenerable home caches stay on disk; skipping a directory skips its subtree
355
- const stat = lstatSync(path);
384
+ let stat;
385
+ try {
386
+ stat = lstatSync(path);
387
+ }
388
+ catch (error) {
389
+ if (vanished(error))
390
+ return;
391
+ throw error;
392
+ }
356
393
  if (stat.isDirectory()) {
357
394
  if (!apart(name))
358
395
  dirs.push(name);
359
- for (const child of readdirSync(path).sort())
396
+ let children;
397
+ try {
398
+ children = readdirSync(path).sort();
399
+ }
400
+ catch (error) {
401
+ if (vanished(error))
402
+ return;
403
+ throw error;
404
+ }
405
+ for (const child of children)
360
406
  walk(join(path, child));
361
407
  return;
362
408
  }
@@ -374,7 +420,14 @@ export class AuthStore {
374
420
  const rel = relative(root, file);
375
421
  if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
376
422
  return;
377
- target = lstatSync(file);
423
+ try {
424
+ target = lstatSync(file);
425
+ }
426
+ catch (error) {
427
+ if (vanished(error))
428
+ return;
429
+ throw error;
430
+ }
378
431
  }
379
432
  if (!target.isFile())
380
433
  return;
@@ -396,15 +449,44 @@ export class AuthStore {
396
449
  return { dirs, files, objects };
397
450
  }
398
451
  collect() {
452
+ sweepOrphans(this.o.root);
399
453
  const { dirs, files, objects } = this.inventory();
400
- for (const dir of dirs)
401
- chmodSync(join(this.o.root, dir), 0o700);
402
- // Read the checked target, but save the link's path: restore materializes a regular file there.
403
- for (const { file } of [...files, ...objects])
404
- chmodSync(file, 0o600);
454
+ for (const dir of dirs) {
455
+ try {
456
+ chmodSync(join(this.o.root, dir), 0o700);
457
+ }
458
+ catch (error) {
459
+ if (!vanished(error))
460
+ throw error;
461
+ }
462
+ }
463
+ // Read the checked target, but save the link's path: restore materializes a regular file there. A file that
464
+ // vanishes here is skipped by the same rule as in the listing above.
465
+ const read = ({ name, file }) => {
466
+ try {
467
+ chmodSync(file, 0o600);
468
+ return [name, readFileSync(file).toString('base64')];
469
+ }
470
+ catch (error) {
471
+ if (vanished(error))
472
+ return undefined;
473
+ throw error;
474
+ }
475
+ };
476
+ const kept = objects.filter(({ file }) => {
477
+ try {
478
+ chmodSync(file, 0o600);
479
+ return true;
480
+ }
481
+ catch (error) {
482
+ if (vanished(error))
483
+ return false;
484
+ throw error;
485
+ }
486
+ });
405
487
  return {
406
- saved: { v: 1, dirs, files: files.map(({ name, file }) => [name, readFileSync(file).toString('base64')]) },
407
- objects,
488
+ saved: { v: 1, dirs, files: files.map(read).filter((entry) => entry !== undefined) },
489
+ objects: kept,
408
490
  };
409
491
  }
410
492
  /** Remove the live sealed trees: all of `state` (every file there is sealed, as a blob entry or an object),
@@ -431,10 +513,19 @@ export class AuthStore {
431
513
  mkdirSync(this.objects, { recursive: true, mode: 0o700 });
432
514
  const keep = new Set();
433
515
  for (const { name, file } of objects) {
516
+ let data;
517
+ try {
518
+ data = readFileSync(file).toString('base64');
519
+ }
520
+ catch (error) {
521
+ if (vanished(error))
522
+ continue;
523
+ throw error;
524
+ }
434
525
  const object = objectName(name);
435
526
  keep.add(object);
436
527
  const target = join(this.objects, object);
437
- const payload = JSON.stringify({ path: name, data: readFileSync(file).toString('base64') });
528
+ const payload = JSON.stringify({ path: name, data });
438
529
  // An object this process already sealed (or restored) unchanged is not resealed: its plaintext hash is
439
530
  // the same and its sealed file is still on disk, so the encrypt + verify + fsyncs are skipped. A changed
440
531
  // file, a new one, or one whose sealed file vanished falls through to the normal seal.
@@ -502,8 +593,11 @@ export class AuthStore {
502
593
  }
503
594
  if (existsSync(this.objects)) {
504
595
  for (const entry of readdirSync(this.objects)) {
505
- const [path, data, payload] = this.readObject(join(this.objects, entry));
506
- this.objectHashes.set(entry, objectHash(payload));
596
+ const [path, data, payload, bytes] = this.readObject(join(this.objects, entry));
597
+ // An object sealed in an older wrap mode (the adapter would upgrade it, as read() does the main file) gets
598
+ // no hash, so the next seal reseals it in the current mode even when unchanged.
599
+ if (!this.o.seal.upgrade?.(bytes))
600
+ this.objectHashes.set(entry, objectHash(payload));
507
601
  // An object path can be several directories below an existing one, and those directories are not in
508
602
  // `saved.dirs` (they sit in a non-credential subtree). Create them through ensureRestoreDirs so the
509
603
  // parent of every directory this restore creates — not just each object's own directory — is fsynced
@@ -558,6 +652,24 @@ export class AuthStore {
558
652
  }
559
653
  });
560
654
  }
655
+ /** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
656
+ * instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
657
+ * read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
658
+ * stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
659
+ * deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
660
+ async reseal() {
661
+ return this.serial(async () => {
662
+ if (!this.o.seal || !this.active)
663
+ return;
664
+ const previous = await this.read();
665
+ const { saved, objects } = this.collect();
666
+ // Same debris guard as persist(): a running engine always leaves files behind, so an empty live tree
667
+ // keeps the sealed credentials instead of replacing them with an empty snapshot.
668
+ if (!saved.files.length && !objects.length && previous)
669
+ return;
670
+ this.seal(this.file, JSON.stringify(saved));
671
+ });
672
+ }
561
673
  start() {
562
674
  return this.serial(async () => {
563
675
  if (this.active)
package/dist/engine.d.ts CHANGED
@@ -54,6 +54,7 @@ export declare class Engine {
54
54
  };
55
55
  private verifiedOrphan;
56
56
  private recoverOrphan;
57
+ private sweepAccountOrphans;
57
58
  start(): Promise<{
58
59
  port: number;
59
60
  token: string;
package/dist/engine.js CHANGED
@@ -12,6 +12,7 @@ import { ENGINE_VERSION } from "./constants.js";
12
12
  import { appendUsageBoot } from "./usage-boots.js";
13
13
  import { reconcileConfig, appRecoveryPrefixes } from "./config.js";
14
14
  import { writePlugin, resolveBridge } from "./bridge.js";
15
+ import { sweepOrphans } from "./accounts.js";
15
16
  const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
16
17
  const putOnce = (path, data) => { if (!existsSync(path))
17
18
  writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); };
@@ -161,6 +162,7 @@ export class Engine {
161
162
  await this.recoverOrphan();
162
163
  for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'usage'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs')])
163
164
  mkdirSync(d, { recursive: true, mode: 0o700 });
165
+ this.sweepAccountOrphans();
164
166
  try {
165
167
  await this.authStore.prepare();
166
168
  this.credentialsLocked = false;
@@ -351,6 +353,18 @@ export class Engine {
351
353
  throw occupied();
352
354
  // The new store owner now seals crash leftovers under its lock before restoring them.
353
355
  }
356
+ // The account orphan sweep (5.15): a late engine write after a removal cannot be ruled out by a bounded check, so
357
+ // prepare and the sealed restore remove each `<root>/state/agents/<member>--<hex6>` directory a removal recorded as
358
+ // gone and no index names. No roster here: the gateway is down, and no add is in flight while preparing (an entry
359
+ // is written before its agent is created, so an account being added is named).
360
+ sweepAccountOrphans() {
361
+ try {
362
+ sweepOrphans(this.root);
363
+ }
364
+ catch (error) {
365
+ this.o.log?.(`prepare: orphans are not swept: ${String(error.message).slice(0, 200)}`);
366
+ }
367
+ }
354
368
  start() {
355
369
  if (this.starting)
356
370
  return this.starting;
@@ -382,6 +396,7 @@ export class Engine {
382
396
  this.state('failed', 'auth-store-unreadable');
383
397
  throw error;
384
398
  }
399
+ this.sweepAccountOrphans();
385
400
  try {
386
401
  return this.launch();
387
402
  }
package/dist/kit.d.ts CHANGED
@@ -148,6 +148,9 @@ export declare class OpenClawKit {
148
148
  }>;
149
149
  }>;
150
150
  renameAccount(member: Member, id: AccountId, name: string): Promise<Account>;
151
+ /** Remove one account: a member-agent account through `authLogout` with its profileIds, an account agent through
152
+ * `clear`, an empty `list`, `agents.delete` with `deleteFiles: false`, removal of its state dir, then the entry. */
153
+ removeAccount(member: Member, id: AccountId): Promise<void>;
151
154
  defaults(member: Member): Promise<Defaults>;
152
155
  setDefaults(member: Member, d: Defaults): Promise<void>;
153
156
  /** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
package/dist/kit.js CHANGED
@@ -763,7 +763,7 @@ export class OpenClawKit {
763
763
  this.accountsCtl = createAccounts({ request: (method, params, o) => this.request()(method, params, o), root: this.engine.root,
764
764
  ensure: (member) => this.ensureMember(member), locks: this.locks,
765
765
  signIn: (agentId, o, on, fresh) => startSignIn({ ...this.signInCtx(), ensure: async () => ({ agentId }), fresh }, agentId, o, on),
766
- routes: this.routeMap, log: this.o.log });
766
+ routes: this.routeMap, log: this.o.log, reseal: () => this.engine.authStore.reseal() });
767
767
  // The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
768
768
  await this.bridge.start();
769
769
  this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
@@ -981,9 +981,9 @@ export class OpenClawKit {
981
981
  memberOf(ref) {
982
982
  return memberOfAccount(this.engine.root, ref);
983
983
  }
984
- // accounts (5.15, O14 steps S5, S6 and S9-S12): the per-member index with listing, adding, defaults, rename, models
985
- // and room; `run({ account })` and `pick` choose among them, held to the account a session is bound to, and `move`
986
- // carries a conversation to another account. Removing arrives in a later step.
984
+ // accounts (5.15, O14 steps S5, S6 and S8-S12): the per-member index with listing, adding, defaults, rename, models
985
+ // and room; `run({ account })` and `pick` choose among them, held to the account a session is bound to, `move`
986
+ // carries a conversation to another account, and `removeAccount` deletes one.
987
987
  accounts(member) {
988
988
  if (!this.accountsCtl)
989
989
  return Promise.reject(new Error('gateway not ready'));
@@ -1028,6 +1028,13 @@ export class OpenClawKit {
1028
1028
  return Promise.reject(new Error('gateway not ready'));
1029
1029
  return this.accountsCtl.rename(member, id, name);
1030
1030
  }
1031
+ /** Remove one account: a member-agent account through `authLogout` with its profileIds, an account agent through
1032
+ * `clear`, an empty `list`, `agents.delete` with `deleteFiles: false`, removal of its state dir, then the entry. */
1033
+ removeAccount(member, id) {
1034
+ if (!this.accountsCtl)
1035
+ return Promise.reject(new Error('gateway not ready'));
1036
+ return this.accountsCtl.remove(member, id);
1037
+ }
1031
1038
  defaults(member) {
1032
1039
  if (!this.accountsCtl)
1033
1040
  return Promise.reject(new Error('gateway not ready'));
@@ -38,6 +38,13 @@ export type FakeGateway = {
38
38
  provider: string;
39
39
  type: string;
40
40
  } & Record<string, unknown>): void;
41
+ /** A late engine refresh racing a removal (O14 S8): writes one profile of `provider` onto `agentId` whole, as the
42
+ * pin would under its store lock. With `order: false` it lands with no order entry; `expires` (default: valid)
43
+ * may name an expired credential. */
44
+ refresh(agentId: string, provider: string, o?: {
45
+ order?: boolean;
46
+ expires?: number;
47
+ }): void;
41
48
  } & {
42
49
  transport: GatewayTransport;
43
50
  };
@@ -858,6 +858,19 @@ export function fakeGateway(script, o) {
858
858
  storeOf(agentId)[profileId] = profile;
859
859
  setPrimaryOfId(agentId, `${profile.provider}/default@${profileId}`);
860
860
  },
861
+ refresh: (agentId, provider, o) => {
862
+ const agent = ensureAgent(agentId);
863
+ if (!agent.providers.includes(provider))
864
+ agent.providers.push(provider);
865
+ const profileId = `${provider}:default`;
866
+ storeOf(agentId)[profileId] = { provider, type: 'oauth', status: 'ok', access: 'fake-access',
867
+ refresh: 'fake-refresh', expires: o?.expires ?? Date.now() + 30 * 86_400_000,
868
+ accountId: `fake-refresh-${agentId}` };
869
+ if (o?.order !== false)
870
+ orderOf(agentId)[provider] = [profileId];
871
+ else if (orderOf(agentId)[provider])
872
+ delete orderOf(agentId)[provider];
873
+ },
861
874
  transport: factory({ port: 0, token: 'test', identityPath: '', bridgeSock: '' }),
862
875
  };
863
876
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",