@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.
- package/CHANGELOG.md +31 -0
- package/README.md +55 -26
- package/SECURITY.md +6 -5
- package/dist/accounts.d.ts +16 -0
- package/dist/accounts.js +222 -0
- package/dist/auth-status.d.ts +1 -0
- package/dist/auth-store.d.ts +30 -5
- package/dist/auth-store.js +265 -52
- package/dist/classify.js +2 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +17 -2
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/device.d.ts +1 -1
- package/dist/engine.d.ts +5 -2
- package/dist/engine.js +112 -23
- package/dist/generated/methods.d.ts +7 -1
- package/dist/generated/params.d.ts +20 -0
- package/dist/generated/params.js +1 -1
- package/dist/generated/report.json +5 -5
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/kit.d.ts +8 -1
- package/dist/kit.js +39 -3
- package/dist/link.d.ts +1 -1
- package/dist/link.js +3 -3
- package/dist/locks.d.ts +5 -0
- package/dist/locks.js +85 -0
- package/dist/members.d.ts +1 -0
- package/dist/members.js +12 -2
- package/dist/pick.d.ts +11 -0
- package/dist/pick.js +145 -0
- package/dist/signin.d.ts +1 -0
- package/dist/signin.js +13 -1
- package/dist/testing/contract.js +0 -1
- package/dist/testing/fake-gateway.js +220 -7
- package/dist/types.d.ts +88 -1
- package/dist/words.js +3 -0
- package/dist/words.json +38 -1
- package/engine/package-lock.json +84 -84
- package/engine/package.json +1 -1
- package/engine/patches.json +17 -17
- package/package.json +8 -8
- package/plugin/accounts.js +224 -0
- package/plugin/index.js +2 -1
package/dist/auth-store.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// The pin persists OAuth JSON in both shared and agent SQLite. There is no public persistence hook.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
52
|
-
// transcript/log/cache subtrees of the Codex and Claude Code CLIs it runs
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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 (
|
|
217
|
-
return; //
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
237
|
-
if (isAbsolute(
|
|
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
|
-
|
|
379
|
+
if (!target.isFile())
|
|
243
380
|
return;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
396
|
+
return { dirs, files, objects };
|
|
252
397
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
499
|
+
this.ensureRestoreDirs(join(this.o.root, dir), changed);
|
|
314
500
|
for (const [path, data] of saved.files)
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
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) {
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export declare const ENGINE_VERSION = "2026.8.
|
|
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.
|
|
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/
|
|
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>;
|