@byokit/openclaw 0.12.0 → 0.14.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 +24 -0
- package/README.md +25 -11
- package/SECURITY.md +6 -2
- package/dist/accounts.d.ts +34 -0
- package/dist/accounts.js +533 -66
- package/dist/auth-store.d.ts +8 -0
- package/dist/auth-store.js +202 -25
- package/dist/engine.d.ts +10 -0
- package/dist/engine.js +67 -1
- package/dist/index.d.ts +2 -2
- package/dist/keys.d.ts +13 -2
- package/dist/keys.js +110 -59
- package/dist/kit.d.ts +14 -2
- package/dist/kit.js +71 -14
- package/dist/link.js +12 -6
- package/dist/pick.js +4 -0
- package/dist/runs.js +23 -6
- package/dist/signin.d.ts +4 -0
- package/dist/signin.js +80 -22
- package/dist/testing/fake-gateway.d.ts +7 -0
- package/dist/testing/fake-gateway.js +35 -1
- package/dist/thinking.d.ts +16 -0
- package/dist/thinking.js +62 -0
- package/dist/types.d.ts +8 -0
- package/dist/words.json +5 -1
- package/package.json +2 -1
- package/plugin/keys.js +3 -2
package/dist/auth-store.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export declare class AuthStore {
|
|
|
53
53
|
* and in `home` exactly the sealed paths — regenerable caches and the directories still holding them are left in place. */
|
|
54
54
|
private remove;
|
|
55
55
|
private sealObjects;
|
|
56
|
+
/** Re-attach held-back sealed paths a damaged index kept out of the live inventory. */
|
|
57
|
+
private mergeHeldBack;
|
|
56
58
|
private persist;
|
|
57
59
|
/** `mkdir -p` for a restore target that records the parent of every directory it creates: a new directory
|
|
58
60
|
* changes its parent's entries, so the parent needs a directory fsync too. Stops at the store root (it exists)
|
|
@@ -61,6 +63,12 @@ export declare class AuthStore {
|
|
|
61
63
|
private restore;
|
|
62
64
|
archives(): Promise<void>;
|
|
63
65
|
prepare(): Promise<void>;
|
|
66
|
+
/** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
|
|
67
|
+
* instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
|
|
68
|
+
* read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
|
|
69
|
+
* stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
|
|
70
|
+
* deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
|
|
71
|
+
reseal(): Promise<void>;
|
|
64
72
|
start(): Promise<void>;
|
|
65
73
|
stop(): Promise<void>;
|
|
66
74
|
offline<T>(task: () => Promise<T>): Promise<T>;
|
package/dist/auth-store.js
CHANGED
|
@@ -8,6 +8,8 @@ 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 { damagedAccountMembers, heldBackAgent, removedAccountAgents, sweepOrphans } from "./accounts.js";
|
|
12
|
+
const agentDirOf = (path) => path.startsWith('state/agents/') ? path.split('/')[2] : undefined;
|
|
11
13
|
const encoder = new TextEncoder();
|
|
12
14
|
const archive = (name) => !name.endsWith('.sealed') && (/^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
|
|
13
15
|
|| name.endsWith('.moved-to-engine'));
|
|
@@ -140,6 +142,11 @@ function safePath(path) {
|
|
|
140
142
|
return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
|
|
141
143
|
&& !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
|
|
142
144
|
}
|
|
145
|
+
// A file the live engine deletes between the directory listing and the read is simply gone: there is nothing to
|
|
146
|
+
// seal, so callers skip it. Every other failure still throws.
|
|
147
|
+
function vanished(error) {
|
|
148
|
+
return error.code === 'ENOENT';
|
|
149
|
+
}
|
|
143
150
|
function snapshot(text) {
|
|
144
151
|
const s = JSON.parse(text);
|
|
145
152
|
if ((s?.v !== 1 && s?.v !== 2) || !Array.isArray(s.dirs) || !Array.isArray(s.files))
|
|
@@ -206,8 +213,9 @@ export class AuthStore {
|
|
|
206
213
|
cleanup;
|
|
207
214
|
restoring;
|
|
208
215
|
// 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
|
|
210
|
-
// seal. A process that never restored starts with an empty map and
|
|
216
|
+
// never becomes on-disk metadata. Filled by restore() from what is decrypted (except objects in an older wrap
|
|
217
|
+
// mode), refreshed after each successful seal. A process that never restored starts with an empty map and
|
|
218
|
+
// reseals every object, as before.
|
|
211
219
|
objectHashes = new Map();
|
|
212
220
|
constructor(privateOptions) {
|
|
213
221
|
this.o = privateOptions;
|
|
@@ -315,19 +323,42 @@ export class AuthStore {
|
|
|
315
323
|
catch {
|
|
316
324
|
throw new AuthStoreUnreadableError('invalid-snapshot');
|
|
317
325
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
326
|
+
// Removed agents leave the sealed store; damaged-index agents stay sealed (held back) until their index is repaired.
|
|
327
|
+
const gone = removedAccountAgents(this.o.root);
|
|
328
|
+
const keep = (path) => {
|
|
329
|
+
const agent = agentDirOf(path);
|
|
330
|
+
return agent === undefined || !gone.has(agent);
|
|
331
|
+
};
|
|
332
|
+
const kept = { v: 1, dirs: saved.dirs.filter(keep), files: saved.files.filter(([path]) => keep(path)) };
|
|
333
|
+
if (kept.dirs.length !== saved.dirs.length || kept.files.length !== saved.files.length) {
|
|
334
|
+
this.seal(this.file, JSON.stringify(kept));
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const upgraded = seal.upgrade?.(bytes);
|
|
338
|
+
if (upgraded) {
|
|
339
|
+
if (seal.decryptString(Buffer.from(upgraded)) !== text)
|
|
340
|
+
throw new Error('credential upgrade verification failed');
|
|
341
|
+
put(this.file, upgraded);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (gone.size && existsSync(this.objects)) {
|
|
345
|
+
for (const entry of readdirSync(this.objects)) {
|
|
346
|
+
const file = join(this.objects, entry);
|
|
347
|
+
if (keep(this.readObject(file)[0]))
|
|
348
|
+
continue;
|
|
349
|
+
rmSync(file);
|
|
350
|
+
this.objectHashes.delete(entry);
|
|
351
|
+
}
|
|
323
352
|
}
|
|
324
|
-
return
|
|
353
|
+
return kept;
|
|
325
354
|
}
|
|
326
355
|
readObject(file) {
|
|
356
|
+
let bytes = Buffer.alloc(0);
|
|
327
357
|
let text = '';
|
|
328
358
|
let entry = null;
|
|
329
359
|
try {
|
|
330
|
-
|
|
360
|
+
bytes = readFileSync(file);
|
|
361
|
+
text = this.open(bytes);
|
|
331
362
|
entry = JSON.parse(text);
|
|
332
363
|
}
|
|
333
364
|
catch (error) {
|
|
@@ -340,7 +371,7 @@ export class AuthStore {
|
|
|
340
371
|
if (!safePath(path) || !apart(path) || typeof data !== 'string' || Buffer.from(data, 'base64').toString('base64') !== data) {
|
|
341
372
|
throw new AuthStoreUnreadableError('invalid-snapshot');
|
|
342
373
|
}
|
|
343
|
-
return [path, data, text];
|
|
374
|
+
return [path, data, text, bytes];
|
|
344
375
|
}
|
|
345
376
|
inventory() {
|
|
346
377
|
const dirs = [];
|
|
@@ -348,15 +379,41 @@ export class AuthStore {
|
|
|
348
379
|
const objects = [];
|
|
349
380
|
let total = 0;
|
|
350
381
|
const root = realpathSync(this.o.root);
|
|
382
|
+
const gone = removedAccountAgents(this.o.root);
|
|
383
|
+
const damaged = damagedAccountMembers(this.o.root);
|
|
351
384
|
const walk = (path) => {
|
|
352
385
|
const name = relative(this.o.root, path).split('\\').join('/');
|
|
386
|
+
if (name.startsWith('state/agents/')) {
|
|
387
|
+
const agent = name.split('/')[2];
|
|
388
|
+
if (gone.has(agent))
|
|
389
|
+
return; // a late refresh never enters a seal
|
|
390
|
+
if (heldBackAgent(agent, damaged))
|
|
391
|
+
return; // damaged index: leave sealed copy; never reseal a late live write
|
|
392
|
+
}
|
|
353
393
|
if (name.startsWith('home/') && nonCredential(name))
|
|
354
394
|
return; // regenerable home caches stay on disk; skipping a directory skips its subtree
|
|
355
|
-
|
|
395
|
+
let stat;
|
|
396
|
+
try {
|
|
397
|
+
stat = lstatSync(path);
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
if (vanished(error))
|
|
401
|
+
return;
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
356
404
|
if (stat.isDirectory()) {
|
|
357
405
|
if (!apart(name))
|
|
358
406
|
dirs.push(name);
|
|
359
|
-
|
|
407
|
+
let children;
|
|
408
|
+
try {
|
|
409
|
+
children = readdirSync(path).sort();
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (vanished(error))
|
|
413
|
+
return;
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
for (const child of children)
|
|
360
417
|
walk(join(path, child));
|
|
361
418
|
return;
|
|
362
419
|
}
|
|
@@ -374,7 +431,14 @@ export class AuthStore {
|
|
|
374
431
|
const rel = relative(root, file);
|
|
375
432
|
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
376
433
|
return;
|
|
377
|
-
|
|
434
|
+
try {
|
|
435
|
+
target = lstatSync(file);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
if (vanished(error))
|
|
439
|
+
return;
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
378
442
|
}
|
|
379
443
|
if (!target.isFile())
|
|
380
444
|
return;
|
|
@@ -396,15 +460,44 @@ export class AuthStore {
|
|
|
396
460
|
return { dirs, files, objects };
|
|
397
461
|
}
|
|
398
462
|
collect() {
|
|
463
|
+
sweepOrphans(this.o.root, this.o.log);
|
|
399
464
|
const { dirs, files, objects } = this.inventory();
|
|
400
|
-
for (const dir of dirs)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
465
|
+
for (const dir of dirs) {
|
|
466
|
+
try {
|
|
467
|
+
chmodSync(join(this.o.root, dir), 0o700);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (!vanished(error))
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Read the checked target, but save the link's path: restore materializes a regular file there. A file that
|
|
475
|
+
// vanishes here is skipped by the same rule as in the listing above.
|
|
476
|
+
const read = ({ name, file }) => {
|
|
477
|
+
try {
|
|
478
|
+
chmodSync(file, 0o600);
|
|
479
|
+
return [name, readFileSync(file).toString('base64')];
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (vanished(error))
|
|
483
|
+
return undefined;
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const kept = objects.filter(({ file }) => {
|
|
488
|
+
try {
|
|
489
|
+
chmodSync(file, 0o600);
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (vanished(error))
|
|
494
|
+
return false;
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
405
498
|
return {
|
|
406
|
-
saved: { v: 1, dirs, files: files.map((
|
|
407
|
-
objects,
|
|
499
|
+
saved: { v: 1, dirs, files: files.map(read).filter((entry) => entry !== undefined) },
|
|
500
|
+
objects: kept,
|
|
408
501
|
};
|
|
409
502
|
}
|
|
410
503
|
/** Remove the live sealed trees: all of `state` (every file there is sealed, as a blob entry or an object),
|
|
@@ -431,10 +524,19 @@ export class AuthStore {
|
|
|
431
524
|
mkdirSync(this.objects, { recursive: true, mode: 0o700 });
|
|
432
525
|
const keep = new Set();
|
|
433
526
|
for (const { name, file } of objects) {
|
|
527
|
+
let data;
|
|
528
|
+
try {
|
|
529
|
+
data = readFileSync(file).toString('base64');
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (vanished(error))
|
|
533
|
+
continue;
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
434
536
|
const object = objectName(name);
|
|
435
537
|
keep.add(object);
|
|
436
538
|
const target = join(this.objects, object);
|
|
437
|
-
const payload = JSON.stringify({ path: name, data
|
|
539
|
+
const payload = JSON.stringify({ path: name, data });
|
|
438
540
|
// An object this process already sealed (or restored) unchanged is not resealed: its plaintext hash is
|
|
439
541
|
// the same and its sealed file is still on disk, so the encrypt + verify + fsyncs are skipped. A changed
|
|
440
542
|
// file, a new one, or one whose sealed file vanished falls through to the normal seal.
|
|
@@ -445,10 +547,48 @@ export class AuthStore {
|
|
|
445
547
|
this.seal(target, payload);
|
|
446
548
|
this.objectHashes.set(object, hash);
|
|
447
549
|
}
|
|
550
|
+
// Held-back agent objects stay sealed while their index is damaged (inventory skipped their live trees).
|
|
551
|
+
const damaged = damagedAccountMembers(this.o.root);
|
|
552
|
+
if (damaged.size) {
|
|
553
|
+
for (const entry of readdirSync(this.objects)) {
|
|
554
|
+
if (keep.has(entry))
|
|
555
|
+
continue;
|
|
556
|
+
try {
|
|
557
|
+
const agent = agentDirOf(this.readObject(join(this.objects, entry))[0]);
|
|
558
|
+
if (agent !== undefined && heldBackAgent(agent, damaged))
|
|
559
|
+
keep.add(entry);
|
|
560
|
+
}
|
|
561
|
+
catch { /* unreadable object: leave for the next start's auth-store check */ }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
448
564
|
for (const entry of readdirSync(this.objects))
|
|
449
565
|
if (!keep.has(entry))
|
|
450
566
|
rmSync(join(this.objects, entry), { force: true });
|
|
451
567
|
}
|
|
568
|
+
/** Re-attach held-back sealed paths a damaged index kept out of the live inventory. */
|
|
569
|
+
mergeHeldBack(previous, saved) {
|
|
570
|
+
if (!previous)
|
|
571
|
+
return;
|
|
572
|
+
const damaged = damagedAccountMembers(this.o.root);
|
|
573
|
+
if (!damaged.size)
|
|
574
|
+
return;
|
|
575
|
+
const held = (path) => {
|
|
576
|
+
const agent = agentDirOf(path);
|
|
577
|
+
return agent !== undefined && heldBackAgent(agent, damaged);
|
|
578
|
+
};
|
|
579
|
+
const dirs = new Set(saved.dirs);
|
|
580
|
+
for (const dir of previous.dirs)
|
|
581
|
+
if (held(dir) && !dirs.has(dir)) {
|
|
582
|
+
saved.dirs.push(dir);
|
|
583
|
+
dirs.add(dir);
|
|
584
|
+
}
|
|
585
|
+
const files = new Set(saved.files.map(([path]) => path));
|
|
586
|
+
for (const entry of previous.files)
|
|
587
|
+
if (held(entry[0]) && !files.has(entry[0])) {
|
|
588
|
+
saved.files.push(entry);
|
|
589
|
+
files.add(entry[0]);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
452
592
|
async persist() {
|
|
453
593
|
if (!this.o.seal)
|
|
454
594
|
return;
|
|
@@ -466,6 +606,7 @@ export class AuthStore {
|
|
|
466
606
|
// A running engine always leaves files behind (its SQLite stores), so a genuine sign-out still re-seals.
|
|
467
607
|
if (!saved.files.length && !objects.length && previous)
|
|
468
608
|
return;
|
|
609
|
+
this.mergeHeldBack(previous, saved);
|
|
469
610
|
// The payload is built exactly once; verification decrypts the sealed bytes and compares to it.
|
|
470
611
|
this.seal(this.file, JSON.stringify(saved));
|
|
471
612
|
if (previous?.dirs.some(nonCredential))
|
|
@@ -491,19 +632,35 @@ export class AuthStore {
|
|
|
491
632
|
if (!this.o.seal)
|
|
492
633
|
return;
|
|
493
634
|
const saved = await this.read();
|
|
635
|
+
const damaged = damagedAccountMembers(this.o.root);
|
|
636
|
+
const skipHeld = (path) => {
|
|
637
|
+
const agent = agentDirOf(path);
|
|
638
|
+
return agent !== undefined && heldBackAgent(agent, damaged);
|
|
639
|
+
};
|
|
494
640
|
// A crashed host may leave a newer live state; prepare seals that before restore.
|
|
495
641
|
put(this.restoring, encoder.encode('1'));
|
|
496
642
|
const changed = new Set();
|
|
497
643
|
if (saved) {
|
|
498
|
-
for (const dir of saved.dirs.sort((a, b) => a.split('/').length - b.split('/').length))
|
|
644
|
+
for (const dir of saved.dirs.sort((a, b) => a.split('/').length - b.split('/').length)) {
|
|
645
|
+
if (skipHeld(dir))
|
|
646
|
+
continue;
|
|
499
647
|
this.ensureRestoreDirs(join(this.o.root, dir), changed);
|
|
500
|
-
|
|
648
|
+
}
|
|
649
|
+
for (const [path, data] of saved.files) {
|
|
650
|
+
if (skipHeld(path))
|
|
651
|
+
continue;
|
|
501
652
|
putRestore(join(this.o.root, path), Buffer.from(data, 'base64'), changed);
|
|
653
|
+
}
|
|
502
654
|
}
|
|
503
655
|
if (existsSync(this.objects)) {
|
|
504
656
|
for (const entry of readdirSync(this.objects)) {
|
|
505
|
-
const [path, data, payload] = this.readObject(join(this.objects, entry));
|
|
506
|
-
|
|
657
|
+
const [path, data, payload, bytes] = this.readObject(join(this.objects, entry));
|
|
658
|
+
// An object sealed in an older wrap mode (the adapter would upgrade it, as read() does the main file) gets
|
|
659
|
+
// no hash, so the next seal reseals it in the current mode even when unchanged.
|
|
660
|
+
if (!this.o.seal.upgrade?.(bytes))
|
|
661
|
+
this.objectHashes.set(entry, objectHash(payload));
|
|
662
|
+
if (skipHeld(path))
|
|
663
|
+
continue;
|
|
507
664
|
// An object path can be several directories below an existing one, and those directories are not in
|
|
508
665
|
// `saved.dirs` (they sit in a non-credential subtree). Create them through ensureRestoreDirs so the
|
|
509
666
|
// parent of every directory this restore creates — not just each object's own directory — is fsynced
|
|
@@ -558,6 +715,26 @@ export class AuthStore {
|
|
|
558
715
|
}
|
|
559
716
|
});
|
|
560
717
|
}
|
|
718
|
+
/** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
|
|
719
|
+
* instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
|
|
720
|
+
* read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
|
|
721
|
+
* stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
|
|
722
|
+
* deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
|
|
723
|
+
async reseal() {
|
|
724
|
+
return this.serial(async () => {
|
|
725
|
+
if (!this.o.seal || !this.active)
|
|
726
|
+
return;
|
|
727
|
+
const previous = await this.read();
|
|
728
|
+
const { saved, objects } = this.collect();
|
|
729
|
+
// Same debris guard as persist(): a running engine always leaves files behind, so an empty live tree
|
|
730
|
+
// keeps the sealed credentials instead of replacing them with an empty snapshot.
|
|
731
|
+
if (!saved.files.length && !objects.length && previous)
|
|
732
|
+
return;
|
|
733
|
+
this.mergeHeldBack(previous, saved);
|
|
734
|
+
this.seal(this.file, JSON.stringify(saved));
|
|
735
|
+
// Database objects stay at their last stop-time seal; only the credential blob is refreshed here.
|
|
736
|
+
});
|
|
737
|
+
}
|
|
561
738
|
start() {
|
|
562
739
|
return this.serial(async () => {
|
|
563
740
|
if (this.active)
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remoteCliHost } from '@byokit/accounts/cli';
|
|
1
2
|
import { AuthStore, AuthStoreSealSizeError } from './auth-store.ts';
|
|
2
3
|
import type { KitOptions } from './kit.ts';
|
|
3
4
|
import type { KitState, ToolSpec } from './types.ts';
|
|
@@ -28,6 +29,10 @@ export declare class Engine {
|
|
|
28
29
|
private starting?;
|
|
29
30
|
readonly authStore: AuthStore;
|
|
30
31
|
private stopping;
|
|
32
|
+
private stopOwner;
|
|
33
|
+
private claudeLogin?;
|
|
34
|
+
private claudeAccounts?;
|
|
35
|
+
private claudeReset?;
|
|
31
36
|
private credentialsLocked;
|
|
32
37
|
private repaired;
|
|
33
38
|
private prepared?;
|
|
@@ -49,11 +54,16 @@ export declare class Engine {
|
|
|
49
54
|
entry: string;
|
|
50
55
|
env: Record<string, string>;
|
|
51
56
|
};
|
|
57
|
+
/** The engine's own shutdown signal; aborted by stop() and replaced by the next start(). */
|
|
58
|
+
get stopSignal(): AbortSignal;
|
|
59
|
+
nativeClaudeLogin(): ReturnType<typeof remoteCliHost>;
|
|
60
|
+
resetNativeClaudeLogin(): Promise<void>;
|
|
52
61
|
doctor(timeoutMs: number): {
|
|
53
62
|
status: number | null;
|
|
54
63
|
};
|
|
55
64
|
private verifiedOrphan;
|
|
56
65
|
private recoverOrphan;
|
|
66
|
+
private sweepAccountOrphans;
|
|
57
67
|
start(): Promise<{
|
|
58
68
|
port: number;
|
|
59
69
|
token: string;
|
package/dist/engine.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { randomBytes, generateKeyPairSync, randomUUID } from 'node:crypto';
|
|
3
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { accessSync, constants, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { basename, join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { cliAccounts, remoteCliHost } from '@byokit/accounts/cli';
|
|
8
9
|
import { AuthStore, AuthStoreUnreadableError, AuthStoreSealSizeError } from "./auth-store.js";
|
|
9
10
|
import { EngineAlreadyRunningError, pidAlive, StartedProcesses } from "./engine-status.js";
|
|
10
11
|
import { EnginePatchError, atomic, prepareEngineSet, processStartTime, readPatchSet, sha256, verifyEngineSet } from "./engine-patches.js";
|
|
@@ -12,6 +13,7 @@ import { ENGINE_VERSION } from "./constants.js";
|
|
|
12
13
|
import { appendUsageBoot } from "./usage-boots.js";
|
|
13
14
|
import { reconcileConfig, appRecoveryPrefixes } from "./config.js";
|
|
14
15
|
import { writePlugin, resolveBridge } from "./bridge.js";
|
|
16
|
+
import { sweepOrphans } from "./accounts.js";
|
|
15
17
|
const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
18
|
const putOnce = (path, data) => { if (!existsSync(path))
|
|
17
19
|
writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); };
|
|
@@ -75,6 +77,13 @@ export class Engine {
|
|
|
75
77
|
starting;
|
|
76
78
|
authStore;
|
|
77
79
|
stopping = false;
|
|
80
|
+
// Aborted by stop(): a sign-in drive that has not reached its commit point then declines at once, the key paste
|
|
81
|
+
// included (it has no native host signal of its own). start() replaces it, so an earlier shutdown cannot decline
|
|
82
|
+
// a later drive.
|
|
83
|
+
stopOwner = new AbortController();
|
|
84
|
+
claudeLogin;
|
|
85
|
+
claudeAccounts;
|
|
86
|
+
claudeReset;
|
|
78
87
|
credentialsLocked = false;
|
|
79
88
|
repaired = false;
|
|
80
89
|
prepared;
|
|
@@ -161,6 +170,7 @@ export class Engine {
|
|
|
161
170
|
await this.recoverOrphan();
|
|
162
171
|
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
172
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
173
|
+
this.sweepAccountOrphans();
|
|
164
174
|
try {
|
|
165
175
|
await this.authStore.prepare();
|
|
166
176
|
this.credentialsLocked = false;
|
|
@@ -283,6 +293,42 @@ export class Engine {
|
|
|
283
293
|
BYOKIT_APP_OWNED_SESSION_PREFIXES: JSON.stringify(this.appOwnedSessionPrefixes),
|
|
284
294
|
} };
|
|
285
295
|
}
|
|
296
|
+
/** The engine's own shutdown signal; aborted by stop() and replaced by the next start(). */
|
|
297
|
+
get stopSignal() { return this.stopOwner.signal; }
|
|
298
|
+
nativeClaudeLogin() {
|
|
299
|
+
if (this.claudeLogin)
|
|
300
|
+
return this.claudeLogin;
|
|
301
|
+
const { env } = this.doctorContext();
|
|
302
|
+
const bin = env.PATH.split(':').map(path => join(path, 'claude')).find(path => { try {
|
|
303
|
+
accessSync(path, constants.X_OK);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return false;
|
|
308
|
+
} });
|
|
309
|
+
const bins = bin ? { claude: bin } : {};
|
|
310
|
+
// The login runs in its own HOME: CLAUDE_CONFIG_DIR points it at the engine's folder, which accounts refuses as a HOME's default login.
|
|
311
|
+
for (const dir of ['home', 'login-home'])
|
|
312
|
+
mkdirSync(join(this.root, dir), { recursive: true, mode: 0o700 });
|
|
313
|
+
const cli = cliAccounts({ stateDir: this.root, bins, env: { PATH: env.PATH, HOME: join(this.root, 'login-home'), LANG: env.LANG, TMPDIR: env.TMPDIR, XDG_CONFIG_HOME: env.XDG_CONFIG_HOME, XDG_CACHE_HOME: env.XDG_CACHE_HOME, XDG_DATA_HOME: env.XDG_DATA_HOME, XDG_STATE_HOME: env.XDG_STATE_HOME, }, managedFolders: { claude: env.CLAUDE_CONFIG_DIR } });
|
|
314
|
+
this.claudeAccounts = cli;
|
|
315
|
+
this.claudeLogin = remoteCliHost({ cli, bins });
|
|
316
|
+
return this.claudeLogin;
|
|
317
|
+
}
|
|
318
|
+
resetNativeClaudeLogin() {
|
|
319
|
+
if (this.claudeReset)
|
|
320
|
+
return this.claudeReset;
|
|
321
|
+
const cli = this.claudeAccounts, host = this.claudeLogin;
|
|
322
|
+
if (!cli || !host || host.signal.aborted)
|
|
323
|
+
return Promise.resolve();
|
|
324
|
+
this.claudeReset = (async () => { for (const row of await cli.list()) {
|
|
325
|
+
if (host.signal.aborted)
|
|
326
|
+
return;
|
|
327
|
+
if (row.provider === 'claude' && row.state === 'ready' && cli.folderOf(row.id) === join(this.root, 'home', '.claude'))
|
|
328
|
+
await cli.remove(row.id);
|
|
329
|
+
} })().finally(() => { this.claudeReset = undefined; });
|
|
330
|
+
return this.claudeReset;
|
|
331
|
+
}
|
|
286
332
|
doctor(timeoutMs) {
|
|
287
333
|
const { entry, env } = this.doctorContext();
|
|
288
334
|
if (this.o.spawnEngine)
|
|
@@ -351,10 +397,24 @@ export class Engine {
|
|
|
351
397
|
throw occupied();
|
|
352
398
|
// The new store owner now seals crash leftovers under its lock before restoring them.
|
|
353
399
|
}
|
|
400
|
+
// The account orphan sweep (5.15): a late engine write after a removal cannot be ruled out by a bounded check, so
|
|
401
|
+
// prepare and the sealed restore remove each `<root>/state/agents/<member>--<hex6>` directory a removal recorded as
|
|
402
|
+
// gone and no index names, and drop member-store auth profiles a removal recorded as `dropped`. No roster here: the
|
|
403
|
+
// gateway is down, and no add is in flight while preparing (an entry is written before its agent is created, so an
|
|
404
|
+
// account being added is named).
|
|
405
|
+
sweepAccountOrphans() {
|
|
406
|
+
try {
|
|
407
|
+
sweepOrphans(this.root, this.o.log);
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
this.o.log?.(`prepare: orphans are not swept: ${String(error.message).slice(0, 200)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
354
413
|
start() {
|
|
355
414
|
if (this.starting)
|
|
356
415
|
return this.starting;
|
|
357
416
|
this.stopping = false;
|
|
417
|
+
this.stopOwner = new AbortController();
|
|
358
418
|
const pending = this.startOnce();
|
|
359
419
|
this.starting = pending;
|
|
360
420
|
void pending.finally(() => { if (this.starting === pending)
|
|
@@ -382,6 +442,7 @@ export class Engine {
|
|
|
382
442
|
this.state('failed', 'auth-store-unreadable');
|
|
383
443
|
throw error;
|
|
384
444
|
}
|
|
445
|
+
this.sweepAccountOrphans();
|
|
385
446
|
try {
|
|
386
447
|
return this.launch();
|
|
387
448
|
}
|
|
@@ -499,6 +560,11 @@ export class Engine {
|
|
|
499
560
|
withAuthStore(task) { return this.authStore.offline(task); }
|
|
500
561
|
async stop() {
|
|
501
562
|
this.stopping = true;
|
|
563
|
+
this.stopOwner.abort();
|
|
564
|
+
await this.claudeLogin?.close();
|
|
565
|
+
await this.claudeReset?.catch(() => { });
|
|
566
|
+
this.claudeLogin = undefined;
|
|
567
|
+
this.claudeAccounts = undefined;
|
|
502
568
|
await this.starting?.catch(() => { });
|
|
503
569
|
const child = this.child;
|
|
504
570
|
await this.started.terminate(delay);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.ts';
|
|
2
|
-
export type { Account, AccountId, AccountPick, AccountRef, Approval, CallOptions, Considered, Decision, Defaults, ModelInfo, MoveResult, PickWhy, Room, RoomSpan, RunSelection, SignInState, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, KitEventName, KitEventPayload, BrowserPing, LearningCapture, LearningMode, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, OutputSchema, SchemaOutput, RunUsage, SignInOptions, SignInView, ToolHost, ToolSpec, } from './types.ts';
|
|
2
|
+
export type { Account, AccountId, AccountPick, AccountRef, Approval, CallOptions, Considered, Decision, Defaults, ModelInfo, MoveResult, PickWhy, Room, RoomSpan, RunSelection, SignInState, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, KitEventName, KitEventPayload, BrowserPing, LearningCapture, LearningMode, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, OutputSchema, SchemaOutput, RunUsage, ThinkingAsked, ThinkingReport, ThinkingUsed, SignInOptions, SignInView, ToolHost, ToolSpec, } from './types.ts';
|
|
3
3
|
export type { GatewayMethods } from './generated/methods.ts';
|
|
4
4
|
export type { JsonValue } from './output.ts';
|
|
5
5
|
export type { RouteFacts, RouteView } from './routes.ts';
|
|
6
6
|
export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
|
|
7
|
-
export type { AddKeyResult, KeyFailureReason } from './keys.ts';
|
|
7
|
+
export type { AddKeyHandle, AddKeyResult, KeyFailureReason } from './keys.ts';
|
|
8
8
|
export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
|
|
9
9
|
export { chooseAccount, consider, resolveSelection, type RoomOf } from './pick.ts';
|
|
10
10
|
export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.ts';
|
package/dist/keys.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ActivationCtx } from './activation.ts';
|
|
2
|
+
import { type RouteView } from './routes.ts';
|
|
2
3
|
import type { Member, ModelInfo } from './types.ts';
|
|
3
4
|
export type KeyFailureReason = 'auth' | 'billing' | 'empty' | 'model' | 'network' | 'engine' | 'inference' | 'busy';
|
|
4
5
|
export type AddKeyResult = {
|
|
@@ -7,6 +8,10 @@ export type AddKeyResult = {
|
|
|
7
8
|
status: 'invalid' | 'not_included' | 'retry';
|
|
8
9
|
reason: KeyFailureReason;
|
|
9
10
|
};
|
|
11
|
+
/** The add resolves like a Promise; `cancel()` stops a pending add and clears any partial key (retry/busy). */
|
|
12
|
+
export type AddKeyHandle = Promise<AddKeyResult> & {
|
|
13
|
+
cancel(): void;
|
|
14
|
+
};
|
|
10
15
|
export declare function createKeys(ctx: ActivationCtx & {
|
|
11
16
|
root: string;
|
|
12
17
|
ensure(member: Member): Promise<{
|
|
@@ -14,6 +19,9 @@ export declare function createKeys(ctx: ActivationCtx & {
|
|
|
14
19
|
}>;
|
|
15
20
|
restarting?(): boolean;
|
|
16
21
|
prepareOwner(agentId: string): Promise<void>;
|
|
22
|
+
indexLane(member: Member, route: RouteView): Promise<void>;
|
|
23
|
+
dropLane(member: Member, provider?: string): Promise<void>;
|
|
24
|
+
backfillLane(member: Member, model: string | undefined): Promise<void>;
|
|
17
25
|
}): {
|
|
18
26
|
models: (member: Member, o: {
|
|
19
27
|
authChoice: string;
|
|
@@ -23,12 +31,15 @@ export declare function createKeys(ctx: ActivationCtx & {
|
|
|
23
31
|
authChoice: string;
|
|
24
32
|
apiKey: string;
|
|
25
33
|
model?: string;
|
|
26
|
-
}):
|
|
34
|
+
}): AddKeyHandle;
|
|
27
35
|
ready(member: Member, o?: {
|
|
28
36
|
once?: boolean;
|
|
29
37
|
}): Promise<{
|
|
30
38
|
agentId: string;
|
|
31
39
|
model: string;
|
|
32
40
|
} | undefined>;
|
|
33
|
-
|
|
41
|
+
remove(member: Member, o?: {
|
|
42
|
+
provider?: string;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
sweep(clear: boolean): Promise<boolean>;
|
|
34
45
|
};
|