@byokit/openclaw 0.9.0 → 0.10.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.
@@ -1,13 +1,16 @@
1
1
  // The pin persists OAuth JSON in both shared and agent SQLite. There is no public persistence hook.
2
- // Seal only credential state — the engine's complete `state` tree plus every config/credential path in
3
- // `home` — never the regenerable tool caches, transcripts and logs a signed-in home accumulates.
4
- // Sealing those too once produced a snapshot past the runtime string limit and aborted boot.
2
+ // Credential state under `home` and `state` is sealed as one bounded blob (`auth-store.sealed`, capped at
3
+ // SEAL_CAP_BYTES, refusing with a typed error). Every other file under `state` — the SQLite databases, which mix
4
+ // credentials with transcript rows, and the regenerable caches, transcripts, media, logs and session stores beside
5
+ // them — is sealed as its own file under `auth-store.objects/`. Each such file is capped individually at
6
+ // SEAL_CAP_BYTES; the total across objects is not, so growth in aggregate never refuses.
5
7
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
8
+ import { createHash } from 'node:crypto';
6
9
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
10
  import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
8
11
  const encoder = new TextEncoder();
9
- const archive = (name) => /^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
10
- || name.endsWith('.moved-to-engine');
12
+ const archive = (name) => !name.endsWith('.sealed') && (/^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
13
+ || name.endsWith('.moved-to-engine'));
11
14
  function regular(path) {
12
15
  if (!lstatSync(path).isFile())
13
16
  throw new Error(`credential store requires regular files: ${path}`);
@@ -47,20 +50,72 @@ function put(path, bytes) {
47
50
  rmSync(tmp, { force: true });
48
51
  }
49
52
  }
53
+ // Restore-only variant of put(): the same temp write, file fsync and atomic rename, but it records the
54
+ // containing directory in `changed` instead of fsyncing it now. restore() fsyncs each distinct directory
55
+ // once after every file is written, so a restore still makes each rename durable before it removes the
56
+ // restoring marker, without one directory fsync per object. seal() and the stop path keep put().
57
+ function putRestore(path, bytes, changed) {
58
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
59
+ const tmp = `${path}.sealing-${process.pid}`;
60
+ try {
61
+ writeFileSync(tmp, bytes, { mode: 0o600, flag: 'wx' });
62
+ const fd = openSync(tmp, 'r');
63
+ try {
64
+ fsyncSync(fd);
65
+ }
66
+ finally {
67
+ closeSync(fd);
68
+ }
69
+ renameSync(tmp, path);
70
+ changed.add(dirname(path));
71
+ }
72
+ finally {
73
+ rmSync(tmp, { force: true });
74
+ }
75
+ }
50
76
  // Credential state is what restores a working signed-in session. Regenerable tool caches, transcripts
51
- // and logs never do: the XDG cache and npm cache homes the kit's engine environment pins, plus the
52
- // transcript/log/cache subtrees of the Codex and Claude Code CLIs it runs in `home`. Unknown paths stay
77
+ // and logs never do. In `home`: the XDG cache and npm cache homes the kit's engine environment pins, plus
78
+ // the transcript/log/cache subtrees of the Codex and Claude Code CLIs it runs; those stay on disk unsealed.
79
+ // In the isolated engine `state` tree the pinned engine (2026.8.1, `resolveStateDatabasePath`/`resolveOpenClawAgentSqlitePath`/
80
+ // `resolveOAuthDir`) keeps credentials in the shared and per-agent SQLite databases and `credentials/`, and
81
+ // writes non-credential data beside them: the caches (`cache/` control-UI assets, shell snapshots and
82
+ // worker bundles, `completions/`), exported transcript artifacts (`transcripts/`), legacy session stores
83
+ // (`sessions/`, `agents/<agentId>/sessions/`), the media stores (`media/`, `delivery-queue-media/`),
84
+ // logs (`logs/`) and gateway temp/lock files (`tmp/`). Unknown paths stay
53
85
  // sealed — a credential location we do not know about must fail loudly, never drop silently.
54
86
  const HOME_CACHES = {
55
87
  home: ['.cache', '.npm'],
56
88
  'home/.codex': ['sessions', 'log', 'cache', '.tmp', 'history.jsonl'],
57
89
  'home/.claude': ['projects', 'todos', 'shell-snapshots', 'statsig', 'file-history', 'history.jsonl'],
58
90
  };
59
- /** True for the regenerable cache paths the collector never seals; tests assert these are all that stay at rest. */
60
- export function cached(name) {
61
- const parent = name.includes('/') ? name.slice(0, name.lastIndexOf('/')) : '';
62
- return (HOME_CACHES[parent] ?? []).includes(name.slice(name.lastIndexOf('/') + 1));
91
+ // Positively non-credential subtrees directly under the isolated engine `state` tree. They are sealed as
92
+ // separate objects, never in the credential blob.
93
+ const STATE_NON_CREDENTIAL = new Set(['cache', 'completions', 'transcripts', 'sessions', 'media', 'delivery-queue-media', 'logs', 'tmp']);
94
+ // Legacy per-agent session migration sources and archives.
95
+ const AGENT_SESSIONS = /^state\/agents\/[^/]+\/sessions(\/|$)/;
96
+ // The engine's SQLite databases and their journal/WAL sidecars.
97
+ const DATABASE = /\.sqlite(-wal|-shm|-journal)?$/;
98
+ /** True for the non-credential paths: under `home` they are never sealed; under `state` they are sealed as objects. */
99
+ export function nonCredential(name) {
100
+ if (AGENT_SESSIONS.test(name))
101
+ return true;
102
+ const slash = name.lastIndexOf('/');
103
+ const parent = slash === -1 ? '' : name.slice(0, slash);
104
+ if (parent === 'state')
105
+ return STATE_NON_CREDENTIAL.has(name.slice(slash + 1));
106
+ return (HOME_CACHES[parent] ?? []).includes(name.slice(slash + 1));
63
107
  }
108
+ // A file under `state` that is sealed as its own object: the SQLite databases and anything in a non-credential subtree.
109
+ function apart(name) {
110
+ if (!name.startsWith('state/'))
111
+ return false;
112
+ if (DATABASE.test(name))
113
+ return true;
114
+ const parts = name.split('/');
115
+ return parts.some((_, i) => nonCredential(parts.slice(0, i + 1).join('/')));
116
+ }
117
+ const objectName = (name) => `${createHash('sha256').update(name).digest('hex')}.sealed`;
118
+ const objectHash = (payload) => createHash('sha256').update(payload).digest('hex');
64
119
  function safePath(path) {
65
120
  return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
66
121
  && !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
@@ -98,17 +153,46 @@ export class AuthStoreUnreadableError extends Error {
98
153
  this.reason = reason;
99
154
  }
100
155
  }
156
+ /** Total raw credential bytes one snapshot may seal. The sealed payload is one runtime string, so collect()
157
+ * refuses a larger snapshot before reading it and the process never aborts inside the sealer. Credential
158
+ * state is normally a few MiB; the cap leaves several times that headroom. The same bound applies to each
159
+ * non-credential engine store under `state` sealed as its own object; their total is not bounded. */
160
+ export const SEAL_CAP_BYTES = 128 * 1024 * 1024;
161
+ /** The saved sign-in data (or one object file, when `file` names it) is over `SEAL_CAP_BYTES`. On a refusal the
162
+ * last good saved store is kept as it was and no live file is deleted. `size` is a lower bound for the whole
163
+ * store (the running total when the cap was crossed) and the exact size when `file` is set. */
164
+ export class AuthStoreSealSizeError extends Error {
165
+ code = 'auth-store-seal-size';
166
+ size;
167
+ cap;
168
+ file;
169
+ constructor(size, cap, file) {
170
+ const MiB = 1024 * 1024;
171
+ super(`${file ? `Engine data ${file}` : 'Your saved sign-in data'} is too large to keep safely (more than ${Math.floor(size / MiB)} MB; the limit is ${cap / MiB} MB). ` +
172
+ 'Your sign-ins were kept.');
173
+ this.name = 'AuthStoreSealSizeError';
174
+ this.size = size;
175
+ this.cap = cap;
176
+ this.file = file;
177
+ }
178
+ }
101
179
  export class AuthStore {
102
180
  active = false;
103
181
  owned = false;
104
182
  queue = Promise.resolve();
105
183
  file;
184
+ objects;
106
185
  lock;
107
186
  cleanup;
108
187
  restoring;
188
+ // Object file name -> sha256 of the plaintext payload currently sealed at that file. Kept only in memory: it
189
+ // never becomes on-disk metadata. Filled by restore() from what is decrypted, refreshed after each successful
190
+ // seal. A process that never restored starts with an empty map and reseals every object, as before.
191
+ objectHashes = new Map();
109
192
  constructor(privateOptions) {
110
193
  this.o = privateOptions;
111
194
  this.file = join(this.o.root, 'auth-store.sealed');
195
+ this.objects = join(this.o.root, 'auth-store.objects');
112
196
  this.lock = join(this.o.root, 'auth-store.lock');
113
197
  this.cleanup = join(this.o.root, 'auth-store.cleanup');
114
198
  this.restoring = join(this.o.root, 'auth-store.restoring');
@@ -178,21 +262,32 @@ export class AuthStore {
178
262
  rmSync(this.lock, { recursive: true });
179
263
  this.owned = false;
180
264
  }
181
- async read() {
182
- if (!existsSync(this.file))
183
- return undefined;
184
- regular(this.file);
185
- const seal = this.o.seal;
186
- const bytes = readFileSync(this.file);
187
- let text;
265
+ open(bytes) {
188
266
  try {
189
- text = seal.decryptString(bytes);
267
+ return this.o.seal.decryptString(bytes);
190
268
  }
191
269
  catch (error) {
192
270
  if (error?.code !== 'auth-failed')
193
271
  throw error;
194
272
  throw new AuthStoreUnreadableError('auth-failed');
195
273
  }
274
+ }
275
+ seal(path, payload) {
276
+ const seal = this.o.seal;
277
+ const sealed = seal.encryptString(payload);
278
+ if (seal.decryptString(Buffer.from(sealed)) !== payload)
279
+ throw new Error('credential seal verification failed');
280
+ put(path, sealed);
281
+ if (seal.decryptString(readFileSync(path)) !== payload)
282
+ throw new Error('credential seal verification failed');
283
+ }
284
+ async read() {
285
+ if (!existsSync(this.file))
286
+ return undefined;
287
+ regular(this.file);
288
+ const seal = this.o.seal;
289
+ const bytes = readFileSync(this.file);
290
+ const text = this.open(bytes);
196
291
  let saved;
197
292
  try {
198
293
  saved = snapshot(text);
@@ -208,22 +303,45 @@ export class AuthStore {
208
303
  }
209
304
  return saved;
210
305
  }
211
- collect() {
212
- const s = { v: 1, dirs: [], files: [] };
306
+ readObject(file) {
307
+ let text = '';
308
+ let entry = null;
309
+ try {
310
+ text = this.open(readFileSync(file));
311
+ entry = JSON.parse(text);
312
+ }
313
+ catch (error) {
314
+ if (error instanceof AuthStoreUnreadableError)
315
+ throw error;
316
+ throw new AuthStoreUnreadableError('invalid-snapshot');
317
+ }
318
+ const path = entry?.path;
319
+ const data = entry?.data;
320
+ if (!safePath(path) || !apart(path) || typeof data !== 'string' || Buffer.from(data, 'base64').toString('base64') !== data) {
321
+ throw new AuthStoreUnreadableError('invalid-snapshot');
322
+ }
323
+ return [path, data, text];
324
+ }
325
+ inventory() {
326
+ const dirs = [];
327
+ const files = [];
328
+ const objects = [];
329
+ let total = 0;
213
330
  const root = realpathSync(this.o.root);
214
331
  const walk = (path) => {
215
332
  const name = relative(this.o.root, path).split('\\').join('/');
216
- if (cached(name))
217
- return; // tool caches are never credential state; skipping a directory skips its subtree
333
+ if (name.startsWith('home/') && nonCredential(name))
334
+ return; // regenerable home caches stay on disk; skipping a directory skips its subtree
218
335
  const stat = lstatSync(path);
219
336
  if (stat.isDirectory()) {
220
- chmodSync(path, 0o700);
221
- s.dirs.push(name);
337
+ if (!apart(name))
338
+ dirs.push(name);
222
339
  for (const child of readdirSync(path).sort())
223
340
  walk(join(path, child));
224
341
  return;
225
342
  }
226
343
  let file = path;
344
+ let target = stat;
227
345
  if (stat.isSymbolicLink()) {
228
346
  try {
229
347
  file = realpathSync(path);
@@ -233,26 +351,44 @@ export class AuthStore {
233
351
  return;
234
352
  throw error;
235
353
  }
236
- const target = relative(root, file);
237
- if (isAbsolute(target) || target === '..' || target.startsWith(`..${sep}`))
238
- return;
239
- if (!lstatSync(file).isFile())
354
+ const rel = relative(root, file);
355
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
240
356
  return;
357
+ target = lstatSync(file);
241
358
  }
242
- else if (!stat.isFile())
359
+ if (!target.isFile())
243
360
  return;
244
- // Read the checked target, but save the link's path: restore materializes a regular file there.
245
- chmodSync(file, 0o600);
246
- s.files.push([name, readFileSync(file).toString('base64')]);
361
+ if (apart(name)) {
362
+ if (target.size > SEAL_CAP_BYTES)
363
+ throw new AuthStoreSealSizeError(target.size, SEAL_CAP_BYTES, name);
364
+ objects.push({ name, file, size: target.size });
365
+ return;
366
+ }
367
+ // Refuse by size before any file is touched or read: an over-cap file must never reach memory or the sealer.
368
+ total += target.size;
369
+ if (total > SEAL_CAP_BYTES)
370
+ throw new AuthStoreSealSizeError(total, SEAL_CAP_BYTES);
371
+ files.push({ name, file, size: target.size });
247
372
  };
248
373
  for (const dir of ['state', 'home'])
249
374
  if (existsSync(join(this.o.root, dir)))
250
375
  walk(join(this.o.root, dir));
251
- return s;
376
+ return { dirs, files, objects };
377
+ }
378
+ collect() {
379
+ const { dirs, files, objects } = this.inventory();
380
+ for (const dir of dirs)
381
+ chmodSync(join(this.o.root, dir), 0o700);
382
+ // Read the checked target, but save the link's path: restore materializes a regular file there.
383
+ for (const { file } of [...files, ...objects])
384
+ chmodSync(file, 0o600);
385
+ return {
386
+ saved: { v: 1, dirs, files: files.map(({ name, file }) => [name, readFileSync(file).toString('base64')]) },
387
+ objects,
388
+ };
252
389
  }
253
- /** Remove the live credential trees: `state` entirely (it is all credential state, including the
254
- * runtime entries the collector skips), and in `home` exactly the sealed paths — regenerable caches
255
- * and the directories still holding them are left in place. */
390
+ /** Remove the live sealed trees: all of `state` (every file there is sealed, as a blob entry or an object),
391
+ * and in `home` exactly the sealed paths — regenerable caches and the directories still holding them are left in place. */
256
392
  remove(s) {
257
393
  rmSync(join(this.o.root, 'state'), { recursive: true, force: true });
258
394
  for (const [path] of s.files)
@@ -271,6 +407,28 @@ export class AuthStore {
271
407
  }
272
408
  }
273
409
  }
410
+ sealObjects(objects) {
411
+ mkdirSync(this.objects, { recursive: true, mode: 0o700 });
412
+ const keep = new Set();
413
+ for (const { name, file } of objects) {
414
+ const object = objectName(name);
415
+ keep.add(object);
416
+ const target = join(this.objects, object);
417
+ const payload = JSON.stringify({ path: name, data: readFileSync(file).toString('base64') });
418
+ // An object this process already sealed (or restored) unchanged is not resealed: its plaintext hash is
419
+ // the same and its sealed file is still on disk, so the encrypt + verify + fsyncs are skipped. A changed
420
+ // file, a new one, or one whose sealed file vanished falls through to the normal seal.
421
+ const hash = objectHash(payload);
422
+ if (this.objectHashes.get(object) === hash && existsSync(target))
423
+ continue;
424
+ this.objectHashes.delete(object);
425
+ this.seal(target, payload);
426
+ this.objectHashes.set(object, hash);
427
+ }
428
+ for (const entry of readdirSync(this.objects))
429
+ if (!keep.has(entry))
430
+ rmSync(join(this.objects, entry), { force: true });
431
+ }
274
432
  async persist() {
275
433
  if (!this.o.seal)
276
434
  return;
@@ -282,40 +440,66 @@ export class AuthStore {
282
440
  removeMarker(this.restoring);
283
441
  this.o.log?.('interrupted credential transition recovered');
284
442
  }
285
- const saved = this.collect();
443
+ const { saved, objects } = this.collect();
286
444
  // Live trees with nothing sealable are debris (crash leftovers, or only the caches a previous stop
287
445
  // left in place) — keep the sealed credentials instead of replacing them with an empty snapshot.
288
446
  // A running engine always leaves files behind (its SQLite stores), so a genuine sign-out still re-seals.
289
- if (!saved.files.length && previous)
447
+ if (!saved.files.length && !objects.length && previous)
290
448
  return;
291
449
  // The payload is built exactly once; verification decrypts the sealed bytes and compares to it.
292
- const payload = JSON.stringify(saved);
293
- const sealed = this.o.seal.encryptString(payload);
294
- if (this.o.seal.decryptString(Buffer.from(sealed)) !== payload)
295
- throw new Error('credential seal verification failed');
296
- put(this.file, sealed);
297
- if (this.o.seal.decryptString(readFileSync(this.file)) !== payload)
298
- throw new Error('credential seal verification failed');
299
- if (previous?.dirs.some(cached))
450
+ this.seal(this.file, JSON.stringify(saved));
451
+ if (previous?.dirs.some(nonCredential))
300
452
  this.o.log?.('sealed credential store re-sealed: tool caches no longer sealed');
453
+ this.sealObjects(objects);
301
454
  put(this.cleanup, encoder.encode('1'));
302
455
  this.remove(saved);
303
456
  removeMarker(this.cleanup);
304
457
  }
458
+ /** `mkdir -p` for a restore target that records the parent of every directory it creates: a new directory
459
+ * changes its parent's entries, so the parent needs a directory fsync too. Stops at the store root (it exists)
460
+ * and never fsyncs now; restore() fsyncs the collected directories once after all files are written. */
461
+ ensureRestoreDirs(dir, changed) {
462
+ const created = [];
463
+ for (let d = dir; d !== this.o.root && d !== dirname(d) && !existsSync(d); d = dirname(d))
464
+ created.push(d);
465
+ for (const d of created.reverse()) {
466
+ mkdirSync(d, { mode: 0o700 });
467
+ changed.add(dirname(d));
468
+ }
469
+ }
305
470
  async restore() {
306
471
  if (!this.o.seal)
307
472
  return;
308
473
  const saved = await this.read();
309
474
  // A crashed host may leave a newer live state; prepare seals that before restore.
310
475
  put(this.restoring, encoder.encode('1'));
476
+ const changed = new Set();
311
477
  if (saved) {
312
478
  for (const dir of saved.dirs.sort((a, b) => a.split('/').length - b.split('/').length))
313
- mkdirSync(join(this.o.root, dir), { recursive: true, mode: 0o700 });
479
+ this.ensureRestoreDirs(join(this.o.root, dir), changed);
314
480
  for (const [path, data] of saved.files)
315
- put(join(this.o.root, path), Buffer.from(data, 'base64'));
481
+ putRestore(join(this.o.root, path), Buffer.from(data, 'base64'), changed);
482
+ }
483
+ if (existsSync(this.objects)) {
484
+ for (const entry of readdirSync(this.objects)) {
485
+ const [path, data, payload] = this.readObject(join(this.objects, entry));
486
+ this.objectHashes.set(entry, objectHash(payload));
487
+ // An object path can be several directories below an existing one, and those directories are not in
488
+ // `saved.dirs` (they sit in a non-credential subtree). Create them through ensureRestoreDirs so the
489
+ // parent of every directory this restore creates — not just each object's own directory — is fsynced
490
+ // before the marker goes; otherwise a crash after marker removal could lose a directory entry and the
491
+ // next prepare() would seal the now-incomplete live tree over the good sealed objects.
492
+ const target = join(this.o.root, path);
493
+ this.ensureRestoreDirs(dirname(target), changed);
494
+ putRestore(target, Buffer.from(data, 'base64'), changed);
495
+ }
316
496
  }
317
497
  for (const dir of ['state', 'home'])
318
- mkdirSync(join(this.o.root, dir), { recursive: true, mode: 0o700 });
498
+ this.ensureRestoreDirs(join(this.o.root, dir), changed);
499
+ // Each restored file's content is durable already (putRestore fsyncs it); every rename and every new
500
+ // directory entry is made durable here, once per distinct directory, before the marker is removed.
501
+ for (const dir of changed)
502
+ syncDir(dir);
319
503
  removeMarker(this.restoring);
320
504
  }
321
505
  async archives() {
@@ -373,8 +557,17 @@ export class AuthStore {
373
557
  return this.serial(async () => {
374
558
  if (!this.owned)
375
559
  return;
376
- await this.archives();
377
- await this.persist();
560
+ try {
561
+ await this.archives();
562
+ await this.persist();
563
+ }
564
+ catch (error) {
565
+ if (error instanceof AuthStoreSealSizeError) {
566
+ this.active = false;
567
+ this.release();
568
+ }
569
+ throw error;
570
+ }
378
571
  this.active = false;
379
572
  this.release();
380
573
  });
package/dist/classify.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // The regexes are Crewhouse `classifyText`; the kinds are the kit's: rate_limit|overloaded → resting,
3
3
  // signed_out → signed-out, not_included → plan, network → network, anything unrecognised → other.
4
4
  export function classify(message) {
5
- const retry = /try again in ~?(\d+)\s*(min|h)/i.exec(message);
5
+ // Kept in step with @byokit/accounts' classifyFailure: both read the retry delay from either wording.
6
+ const retry = /(?:try again in|next reset in)\s*~?(\d+)\s*(min|h)/i.exec(message);
6
7
  const until = retry ? Date.now() + Number(retry[1]) * (retry[2].toLowerCase() === 'h' ? 3_600_000 : 60_000) : undefined;
7
8
  const kind = /your plan doesn't include/i.test(message) ? 'plan'
8
9
  : /usage limit|rate.?limit|quota|too many requests|\b429\b/i.test(message) ? 'resting'
package/dist/config.d.ts CHANGED
@@ -11,6 +11,7 @@ export declare function reconcileConfig(saved: object | undefined, o: {
11
11
  policyPath: string;
12
12
  app?: object;
13
13
  installPolicy?: KitOptions['installPolicy'];
14
+ offered?: readonly string[];
14
15
  browser?: {
15
16
  profiles: Record<string, {
16
17
  cdpUrl: string;
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, join } from 'node:path';
2
2
  import { routes } from "./routes.js";
3
- import { MEMBER_ID, KEY_PREFIX, keyAgentId } from "./members.js";
3
+ import { MEMBER_ID, KEY_PREFIX, BASE_AGENT, keyAgentId } from "./members.js";
4
4
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
5
  const merge = (a, b) => {
6
6
  for (const [k, v] of Object.entries(b))
@@ -26,6 +26,16 @@ const safeMemory = (memory) => {
26
26
  s.provider = 'none';
27
27
  s.fallback = 'none';
28
28
  };
29
+ /** The plugin of every route an app offers by name (5.6). A consumer names its offered accounts; each is the engine
30
+ * provider, the auth choice or an alias a route carries. Routes nobody names stay out. */
31
+ function offeredPlugins(offered) {
32
+ if (!Array.isArray(offered) || !offered.length)
33
+ return [];
34
+ const named = new Set(offered);
35
+ return routes().filter(route => route.plugin && !route.needs?.plugin
36
+ && (named.has(route.provider) || named.has(route.choice) || (route.aliases ?? []).some(alias => named.has(alias))))
37
+ .map(route => route.plugin);
38
+ }
29
39
  export function reconcileConfig(saved, o) {
30
40
  const c = merge(merge({}, object(saved) ? saved : {}), object(o.app) ? o.app : {});
31
41
  merge(c, {
@@ -37,6 +47,9 @@ export function reconcileConfig(saved, o) {
37
47
  c.channels ??= {};
38
48
  c.agents ??= {};
39
49
  c.agents.defaults ??= {};
50
+ // Auth isolation (5.15): left unset, the pin makes the first agent the inherited owner once the roster grows, and
51
+ // every later agent (other members too) reads its sign-ins. The base is an id the kit never creates.
52
+ c.agents.defaults.authInheritance = { agentId: BASE_AGENT };
40
53
  c.agents.defaults.models ??= {};
41
54
  c.agents.defaults.models['openai/*'] = { ...c.agents.defaults.models['openai/*'], agentRuntime: { id: 'openclaw' } };
42
55
  const allow = c.agents.defaults.modelPolicy?.allow;
@@ -59,7 +72,9 @@ export function reconcileConfig(saved, o) {
59
72
  // Every default-eligible bundled route, not only the ready ones: anthropic-cli waits on its binary and
60
73
  // shares its plugin with the explicit apiKey route, so neither may depend on another route being offered.
61
74
  ...routes().filter(route => route.offerPolicy === 'default' && !route.needs?.plugin && route.plugin)
62
- .map(route => route.plugin)])];
75
+ .map(route => route.plugin),
76
+ // Plus every explicitly offered route the app names, so an opted-in sign-in (OpenRouter) is not blocked.
77
+ ...offeredPlugins(o.offered)])];
63
78
  c.plugins.entries ??= {};
64
79
  c.plugins.entries[o.pluginId] = merge(c.plugins.entries[o.pluginId] ?? {}, { hooks: { timeouts: { before_tool_call: 200_000 } } });
65
80
  if (o.browser) {
@@ -1,3 +1,3 @@
1
- export declare const ENGINE_VERSION = "2026.8.1";
1
+ export declare const ENGINE_VERSION = "2026.8.35";
2
2
  export declare const PROTOCOL_VERSION = 4;
3
3
  export declare const OPERATOR_SCOPES: readonly ["operator.read", "operator.write", "operator.admin", "operator.approvals", "operator.questions", "operator.pairing", "operator.talk"];
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The pins this kit is built against (D4, D6). Data, not behavior: an installed engine on another version is
2
2
  // `needs-update`, never silently run.
3
- export const ENGINE_VERSION = '2026.8.1';
3
+ export const ENGINE_VERSION = '2026.8.35';
4
4
  export const PROTOCOL_VERSION = 4;
5
5
  export const OPERATOR_SCOPES = [
6
6
  'operator.read',
package/dist/device.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BrowserDevice, SignInRefusedWhy } from './browser.ts';
2
- import type { DeviceLink } from '@byokit/link';
2
+ import type { DeviceLink } from '@byokit/pair';
3
3
  import type { RouteView } from './routes.ts';
4
4
  import { type AccountView } from './words.ts';
5
5
  import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, RunEnd, OutputSchema, SchemaOutput, RunEvent, SignInView } from './types.ts';
package/dist/engine.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { AuthStore } from './auth-store.ts';
1
+ import { AuthStore, AuthStoreSealSizeError } from './auth-store.ts';
2
2
  import type { KitOptions } from './kit.ts';
3
3
  import type { KitState, ToolSpec } from './types.ts';
4
- export type EngineOptions = Pick<KitOptions, 'stateDir' | 'engineDir' | 'npmPath' | 'enginePath' | 'config' | 'appOwnedSessions' | 'installPolicy' | 'log' | 'bridge' | 'authSeal'> & {
4
+ export type EngineOptions = Pick<KitOptions, 'stateDir' | 'engineDir' | 'npmPath' | 'enginePath' | 'config' | 'offered' | 'appOwnedSessions' | 'installPolicy' | 'log' | 'bridge' | 'authSeal'> & {
5
5
  pluginId: string;
6
6
  tools: ToolSpec[];
7
7
  gateBuiltins?: boolean;
@@ -16,6 +16,7 @@ export type EngineOptions = Pick<KitOptions, 'stateDir' | 'engineDir' | 'npmPath
16
16
  onState(s: KitState): void;
17
17
  onExit(code: number | null): void;
18
18
  };
19
+ export declare const refusedOnly: (error: unknown) => AuthStoreSealSizeError;
19
20
  export declare class Engine {
20
21
  readonly root: string;
21
22
  readonly bridgeSock: string;
@@ -38,6 +39,8 @@ export declare class Engine {
38
39
  private readonly appOwnedSessionPrefixes;
39
40
  constructor(o: EngineOptions);
40
41
  private state;
42
+ private retainInstallDiagnostics;
43
+ private exitedState;
41
44
  private get entry();
42
45
  private freePort;
43
46
  prepare(): Promise<void>;