@byokit/openclaw 0.9.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.
@@ -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,92 @@ 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
+ // 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
+ }
75
+ function putRestore(path, bytes, changed) {
76
+ if (sameLiveFile(path, bytes))
77
+ return;
78
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
79
+ const tmp = `${path}.sealing-${process.pid}`;
80
+ try {
81
+ writeFileSync(tmp, bytes, { mode: 0o600, flag: 'wx' });
82
+ const fd = openSync(tmp, 'r');
83
+ try {
84
+ fsyncSync(fd);
85
+ }
86
+ finally {
87
+ closeSync(fd);
88
+ }
89
+ renameSync(tmp, path);
90
+ changed.add(dirname(path));
91
+ }
92
+ finally {
93
+ rmSync(tmp, { force: true });
94
+ }
95
+ }
50
96
  // 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
97
+ // and logs never do. In `home`: the XDG cache and npm cache homes the kit's engine environment pins, plus
98
+ // the transcript/log/cache subtrees of the Codex and Claude Code CLIs it runs; those stay on disk unsealed.
99
+ // In the isolated engine `state` tree the pinned engine (2026.8.1, `resolveStateDatabasePath`/`resolveOpenClawAgentSqlitePath`/
100
+ // `resolveOAuthDir`) keeps credentials in the shared and per-agent SQLite databases and `credentials/`, and
101
+ // writes non-credential data beside them: the caches (`cache/` control-UI assets, shell snapshots and
102
+ // worker bundles, `completions/`), exported transcript artifacts (`transcripts/`), legacy session stores
103
+ // (`sessions/`, `agents/<agentId>/sessions/`), the media stores (`media/`, `delivery-queue-media/`),
104
+ // logs (`logs/`) and gateway temp/lock files (`tmp/`). Unknown paths stay
53
105
  // sealed — a credential location we do not know about must fail loudly, never drop silently.
54
106
  const HOME_CACHES = {
55
107
  home: ['.cache', '.npm'],
56
108
  'home/.codex': ['sessions', 'log', 'cache', '.tmp', 'history.jsonl'],
57
109
  'home/.claude': ['projects', 'todos', 'shell-snapshots', 'statsig', 'file-history', 'history.jsonl'],
58
110
  };
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));
111
+ // Positively non-credential subtrees directly under the isolated engine `state` tree. They are sealed as
112
+ // separate objects, never in the credential blob.
113
+ const STATE_NON_CREDENTIAL = new Set(['cache', 'completions', 'transcripts', 'sessions', 'media', 'delivery-queue-media', 'logs', 'tmp']);
114
+ // Legacy per-agent session migration sources and archives.
115
+ const AGENT_SESSIONS = /^state\/agents\/[^/]+\/sessions(\/|$)/;
116
+ // The engine's SQLite databases and their journal/WAL sidecars.
117
+ const DATABASE = /\.sqlite(-wal|-shm|-journal)?$/;
118
+ /** True for the non-credential paths: under `home` they are never sealed; under `state` they are sealed as objects. */
119
+ export function nonCredential(name) {
120
+ if (AGENT_SESSIONS.test(name))
121
+ return true;
122
+ const slash = name.lastIndexOf('/');
123
+ const parent = slash === -1 ? '' : name.slice(0, slash);
124
+ if (parent === 'state')
125
+ return STATE_NON_CREDENTIAL.has(name.slice(slash + 1));
126
+ return (HOME_CACHES[parent] ?? []).includes(name.slice(slash + 1));
127
+ }
128
+ // A file under `state` that is sealed as its own object: the SQLite databases and anything in a non-credential subtree.
129
+ function apart(name) {
130
+ if (!name.startsWith('state/'))
131
+ return false;
132
+ if (DATABASE.test(name))
133
+ return true;
134
+ const parts = name.split('/');
135
+ return parts.some((_, i) => nonCredential(parts.slice(0, i + 1).join('/')));
63
136
  }
137
+ const objectName = (name) => `${createHash('sha256').update(name).digest('hex')}.sealed`;
138
+ const objectHash = (payload) => createHash('sha256').update(payload).digest('hex');
64
139
  function safePath(path) {
65
140
  return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
66
141
  && !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
@@ -98,17 +173,46 @@ export class AuthStoreUnreadableError extends Error {
98
173
  this.reason = reason;
99
174
  }
100
175
  }
176
+ /** Total raw credential bytes one snapshot may seal. The sealed payload is one runtime string, so collect()
177
+ * refuses a larger snapshot before reading it and the process never aborts inside the sealer. Credential
178
+ * state is normally a few MiB; the cap leaves several times that headroom. The same bound applies to each
179
+ * non-credential engine store under `state` sealed as its own object; their total is not bounded. */
180
+ export const SEAL_CAP_BYTES = 128 * 1024 * 1024;
181
+ /** The saved sign-in data (or one object file, when `file` names it) is over `SEAL_CAP_BYTES`. On a refusal the
182
+ * last good saved store is kept as it was and no live file is deleted. `size` is a lower bound for the whole
183
+ * store (the running total when the cap was crossed) and the exact size when `file` is set. */
184
+ export class AuthStoreSealSizeError extends Error {
185
+ code = 'auth-store-seal-size';
186
+ size;
187
+ cap;
188
+ file;
189
+ constructor(size, cap, file) {
190
+ const MiB = 1024 * 1024;
191
+ 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). ` +
192
+ 'Your sign-ins were kept.');
193
+ this.name = 'AuthStoreSealSizeError';
194
+ this.size = size;
195
+ this.cap = cap;
196
+ this.file = file;
197
+ }
198
+ }
101
199
  export class AuthStore {
102
200
  active = false;
103
201
  owned = false;
104
202
  queue = Promise.resolve();
105
203
  file;
204
+ objects;
106
205
  lock;
107
206
  cleanup;
108
207
  restoring;
208
+ // 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.
211
+ objectHashes = new Map();
109
212
  constructor(privateOptions) {
110
213
  this.o = privateOptions;
111
214
  this.file = join(this.o.root, 'auth-store.sealed');
215
+ this.objects = join(this.o.root, 'auth-store.objects');
112
216
  this.lock = join(this.o.root, 'auth-store.lock');
113
217
  this.cleanup = join(this.o.root, 'auth-store.cleanup');
114
218
  this.restoring = join(this.o.root, 'auth-store.restoring');
@@ -178,21 +282,32 @@ export class AuthStore {
178
282
  rmSync(this.lock, { recursive: true });
179
283
  this.owned = false;
180
284
  }
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;
285
+ open(bytes) {
188
286
  try {
189
- text = seal.decryptString(bytes);
287
+ return this.o.seal.decryptString(bytes);
190
288
  }
191
289
  catch (error) {
192
290
  if (error?.code !== 'auth-failed')
193
291
  throw error;
194
292
  throw new AuthStoreUnreadableError('auth-failed');
195
293
  }
294
+ }
295
+ seal(path, payload) {
296
+ const seal = this.o.seal;
297
+ const sealed = seal.encryptString(payload);
298
+ if (seal.decryptString(Buffer.from(sealed)) !== payload)
299
+ throw new Error('credential seal verification failed');
300
+ put(path, sealed);
301
+ if (seal.decryptString(readFileSync(path)) !== payload)
302
+ throw new Error('credential seal verification failed');
303
+ }
304
+ async read() {
305
+ if (!existsSync(this.file))
306
+ return undefined;
307
+ regular(this.file);
308
+ const seal = this.o.seal;
309
+ const bytes = readFileSync(this.file);
310
+ const text = this.open(bytes);
196
311
  let saved;
197
312
  try {
198
313
  saved = snapshot(text);
@@ -208,22 +323,45 @@ export class AuthStore {
208
323
  }
209
324
  return saved;
210
325
  }
211
- collect() {
212
- const s = { v: 1, dirs: [], files: [] };
326
+ readObject(file) {
327
+ let text = '';
328
+ let entry = null;
329
+ try {
330
+ text = this.open(readFileSync(file));
331
+ entry = JSON.parse(text);
332
+ }
333
+ catch (error) {
334
+ if (error instanceof AuthStoreUnreadableError)
335
+ throw error;
336
+ throw new AuthStoreUnreadableError('invalid-snapshot');
337
+ }
338
+ const path = entry?.path;
339
+ const data = entry?.data;
340
+ if (!safePath(path) || !apart(path) || typeof data !== 'string' || Buffer.from(data, 'base64').toString('base64') !== data) {
341
+ throw new AuthStoreUnreadableError('invalid-snapshot');
342
+ }
343
+ return [path, data, text];
344
+ }
345
+ inventory() {
346
+ const dirs = [];
347
+ const files = [];
348
+ const objects = [];
349
+ let total = 0;
213
350
  const root = realpathSync(this.o.root);
214
351
  const walk = (path) => {
215
352
  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
353
+ if (name.startsWith('home/') && nonCredential(name))
354
+ return; // regenerable home caches stay on disk; skipping a directory skips its subtree
218
355
  const stat = lstatSync(path);
219
356
  if (stat.isDirectory()) {
220
- chmodSync(path, 0o700);
221
- s.dirs.push(name);
357
+ if (!apart(name))
358
+ dirs.push(name);
222
359
  for (const child of readdirSync(path).sort())
223
360
  walk(join(path, child));
224
361
  return;
225
362
  }
226
363
  let file = path;
364
+ let target = stat;
227
365
  if (stat.isSymbolicLink()) {
228
366
  try {
229
367
  file = realpathSync(path);
@@ -233,26 +371,44 @@ export class AuthStore {
233
371
  return;
234
372
  throw error;
235
373
  }
236
- const target = relative(root, file);
237
- if (isAbsolute(target) || target === '..' || target.startsWith(`..${sep}`))
238
- return;
239
- if (!lstatSync(file).isFile())
374
+ const rel = relative(root, file);
375
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
240
376
  return;
377
+ target = lstatSync(file);
241
378
  }
242
- else if (!stat.isFile())
379
+ if (!target.isFile())
243
380
  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')]);
381
+ if (apart(name)) {
382
+ if (target.size > SEAL_CAP_BYTES)
383
+ throw new AuthStoreSealSizeError(target.size, SEAL_CAP_BYTES, name);
384
+ objects.push({ name, file, size: target.size });
385
+ return;
386
+ }
387
+ // Refuse by size before any file is touched or read: an over-cap file must never reach memory or the sealer.
388
+ total += target.size;
389
+ if (total > SEAL_CAP_BYTES)
390
+ throw new AuthStoreSealSizeError(total, SEAL_CAP_BYTES);
391
+ files.push({ name, file, size: target.size });
247
392
  };
248
393
  for (const dir of ['state', 'home'])
249
394
  if (existsSync(join(this.o.root, dir)))
250
395
  walk(join(this.o.root, dir));
251
- return s;
396
+ return { dirs, files, objects };
252
397
  }
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. */
398
+ collect() {
399
+ 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);
405
+ return {
406
+ saved: { v: 1, dirs, files: files.map(({ name, file }) => [name, readFileSync(file).toString('base64')]) },
407
+ objects,
408
+ };
409
+ }
410
+ /** Remove the live sealed trees: all of `state` (every file there is sealed, as a blob entry or an object),
411
+ * and in `home` exactly the sealed paths — regenerable caches and the directories still holding them are left in place. */
256
412
  remove(s) {
257
413
  rmSync(join(this.o.root, 'state'), { recursive: true, force: true });
258
414
  for (const [path] of s.files)
@@ -271,6 +427,28 @@ export class AuthStore {
271
427
  }
272
428
  }
273
429
  }
430
+ sealObjects(objects) {
431
+ mkdirSync(this.objects, { recursive: true, mode: 0o700 });
432
+ const keep = new Set();
433
+ for (const { name, file } of objects) {
434
+ const object = objectName(name);
435
+ keep.add(object);
436
+ const target = join(this.objects, object);
437
+ const payload = JSON.stringify({ path: name, data: readFileSync(file).toString('base64') });
438
+ // An object this process already sealed (or restored) unchanged is not resealed: its plaintext hash is
439
+ // the same and its sealed file is still on disk, so the encrypt + verify + fsyncs are skipped. A changed
440
+ // file, a new one, or one whose sealed file vanished falls through to the normal seal.
441
+ const hash = objectHash(payload);
442
+ if (this.objectHashes.get(object) === hash && existsSync(target))
443
+ continue;
444
+ this.objectHashes.delete(object);
445
+ this.seal(target, payload);
446
+ this.objectHashes.set(object, hash);
447
+ }
448
+ for (const entry of readdirSync(this.objects))
449
+ if (!keep.has(entry))
450
+ rmSync(join(this.objects, entry), { force: true });
451
+ }
274
452
  async persist() {
275
453
  if (!this.o.seal)
276
454
  return;
@@ -282,40 +460,66 @@ export class AuthStore {
282
460
  removeMarker(this.restoring);
283
461
  this.o.log?.('interrupted credential transition recovered');
284
462
  }
285
- const saved = this.collect();
463
+ const { saved, objects } = this.collect();
286
464
  // Live trees with nothing sealable are debris (crash leftovers, or only the caches a previous stop
287
465
  // left in place) — keep the sealed credentials instead of replacing them with an empty snapshot.
288
466
  // A running engine always leaves files behind (its SQLite stores), so a genuine sign-out still re-seals.
289
- if (!saved.files.length && previous)
467
+ if (!saved.files.length && !objects.length && previous)
290
468
  return;
291
469
  // 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))
470
+ this.seal(this.file, JSON.stringify(saved));
471
+ if (previous?.dirs.some(nonCredential))
300
472
  this.o.log?.('sealed credential store re-sealed: tool caches no longer sealed');
473
+ this.sealObjects(objects);
301
474
  put(this.cleanup, encoder.encode('1'));
302
475
  this.remove(saved);
303
476
  removeMarker(this.cleanup);
304
477
  }
478
+ /** `mkdir -p` for a restore target that records the parent of every directory it creates: a new directory
479
+ * changes its parent's entries, so the parent needs a directory fsync too. Stops at the store root (it exists)
480
+ * and never fsyncs now; restore() fsyncs the collected directories once after all files are written. */
481
+ ensureRestoreDirs(dir, changed) {
482
+ const created = [];
483
+ for (let d = dir; d !== this.o.root && d !== dirname(d) && !existsSync(d); d = dirname(d))
484
+ created.push(d);
485
+ for (const d of created.reverse()) {
486
+ mkdirSync(d, { mode: 0o700 });
487
+ changed.add(dirname(d));
488
+ }
489
+ }
305
490
  async restore() {
306
491
  if (!this.o.seal)
307
492
  return;
308
493
  const saved = await this.read();
309
494
  // A crashed host may leave a newer live state; prepare seals that before restore.
310
495
  put(this.restoring, encoder.encode('1'));
496
+ const changed = new Set();
311
497
  if (saved) {
312
498
  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 });
499
+ this.ensureRestoreDirs(join(this.o.root, dir), changed);
314
500
  for (const [path, data] of saved.files)
315
- put(join(this.o.root, path), Buffer.from(data, 'base64'));
501
+ putRestore(join(this.o.root, path), Buffer.from(data, 'base64'), changed);
502
+ }
503
+ if (existsSync(this.objects)) {
504
+ 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));
507
+ // An object path can be several directories below an existing one, and those directories are not in
508
+ // `saved.dirs` (they sit in a non-credential subtree). Create them through ensureRestoreDirs so the
509
+ // parent of every directory this restore creates — not just each object's own directory — is fsynced
510
+ // before the marker goes; otherwise a crash after marker removal could lose a directory entry and the
511
+ // next prepare() would seal the now-incomplete live tree over the good sealed objects.
512
+ const target = join(this.o.root, path);
513
+ this.ensureRestoreDirs(dirname(target), changed);
514
+ putRestore(target, Buffer.from(data, 'base64'), changed);
515
+ }
316
516
  }
317
517
  for (const dir of ['state', 'home'])
318
- mkdirSync(join(this.o.root, dir), { recursive: true, mode: 0o700 });
518
+ this.ensureRestoreDirs(join(this.o.root, dir), changed);
519
+ // Each restored file's content is durable already (putRestore fsyncs it); every rename and every new
520
+ // directory entry is made durable here, once per distinct directory, before the marker is removed.
521
+ for (const dir of changed)
522
+ syncDir(dir);
319
523
  removeMarker(this.restoring);
320
524
  }
321
525
  async archives() {
@@ -373,8 +577,17 @@ export class AuthStore {
373
577
  return this.serial(async () => {
374
578
  if (!this.owned)
375
579
  return;
376
- await this.archives();
377
- await this.persist();
580
+ try {
581
+ await this.archives();
582
+ await this.persist();
583
+ }
584
+ catch (error) {
585
+ if (error instanceof AuthStoreSealSizeError) {
586
+ this.active = false;
587
+ this.release();
588
+ }
589
+ throw error;
590
+ }
378
591
  this.active = false;
379
592
  this.release();
380
593
  });
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>;