@byokit/openclaw 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +38 -6
- package/SECURITY.md +3 -2
- package/dist/accounts.d.ts +19 -2
- package/dist/accounts.js +618 -165
- package/dist/activation.d.ts +17 -0
- package/dist/activation.js +9 -0
- package/dist/approvals.d.ts +2 -0
- package/dist/approvals.js +9 -9
- package/dist/attribution.d.ts +3 -0
- package/dist/attribution.js +25 -0
- package/dist/auth-store.d.ts +6 -0
- package/dist/auth-store.js +135 -23
- package/dist/bridge.d.ts +3 -1
- package/dist/bridge.js +4 -6
- package/dist/config.js +10 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -0
- package/dist/device.d.ts +4 -4
- package/dist/device.js +11 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +16 -1
- package/dist/index.d.ts +1 -1
- package/dist/keys.d.ts +19 -5
- package/dist/keys.js +135 -23
- package/dist/kit.d.ts +24 -2
- package/dist/kit.js +139 -29
- package/dist/link.js +21 -11
- package/dist/routes.d.ts +1 -0
- package/dist/routes.js +5 -3
- package/dist/routes.json +31 -31
- package/dist/runs.d.ts +1 -1
- package/dist/runs.js +32 -10
- package/dist/signin.d.ts +2 -0
- package/dist/signin.js +39 -15
- package/dist/testing/fake-gateway.d.ts +19 -0
- package/dist/testing/fake-gateway.js +137 -12
- package/dist/testing/model-stub.d.ts +1 -0
- package/dist/testing/model-stub.js +2 -1
- package/dist/words.json +8 -1
- package/package.json +1 -1
- package/plugin/accounts.js +12 -3
- package/plugin/catalog.js +48 -0
- package/plugin/index.js +2 -1
- package/plugin/keys.js +22 -4
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { GatewayParams, GatewayTransport } from './types.ts';
|
|
2
|
+
export type ActivationCtx = {
|
|
3
|
+
request: GatewayTransport['request'];
|
|
4
|
+
authConfig(): string;
|
|
5
|
+
restarted(authBefore: string | undefined, signal?: AbortSignal): Promise<boolean>;
|
|
6
|
+
};
|
|
7
|
+
export declare const LATE_RESTART = "The engine took too long to restart after this sign-in. Nothing was kept; try again.";
|
|
8
|
+
export type Activation = {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
late?: boolean;
|
|
11
|
+
status?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function activate(ctx: ActivationCtx, params: GatewayParams<'openclaw.setup.activate'>, o: {
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}): Promise<Activation>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const LATE_RESTART = 'The engine took too long to restart after this sign-in. Nothing was kept; try again.';
|
|
2
|
+
export async function activate(ctx, params, o) {
|
|
3
|
+
const authBefore = ctx.authConfig();
|
|
4
|
+
// The request is never aborted by a cancel: its answer decides whether the engine kept the key, so cleanup waits for it.
|
|
5
|
+
const result = await ctx.request('openclaw.setup.activate', params, { timeoutMs: o.timeoutMs });
|
|
6
|
+
if (!result.ok)
|
|
7
|
+
return { ok: false, status: result.status, error: result.error };
|
|
8
|
+
return await ctx.restarted(authBefore, o.signal) ? { ok: true } : { ok: false, late: true };
|
|
9
|
+
}
|
package/dist/approvals.d.ts
CHANGED
|
@@ -3,11 +3,13 @@ import type { Approval, Decision, GatewayTransport, Member } from './types.ts';
|
|
|
3
3
|
export declare class Approvals {
|
|
4
4
|
private readonly request;
|
|
5
5
|
private readonly bridge;
|
|
6
|
+
private readonly memberOf;
|
|
6
7
|
private readonly approvals;
|
|
7
8
|
private readonly listeners;
|
|
8
9
|
constructor(o: {
|
|
9
10
|
request: GatewayTransport['request'];
|
|
10
11
|
bridge: Pick<Bridge, 'resolveAsk'>;
|
|
12
|
+
memberOf: (ref: string) => Member | undefined;
|
|
11
13
|
});
|
|
12
14
|
handleEvent(e: {
|
|
13
15
|
event: string;
|
package/dist/approvals.js
CHANGED
|
@@ -14,21 +14,19 @@ const REQUESTED = {
|
|
|
14
14
|
const RESOLVED = new Set(['exec.approval.resolved', 'plugin.approval.resolved', 'question.resolved']);
|
|
15
15
|
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
16
16
|
const asString = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
|
|
17
|
-
const memberOfKey = (key) => {
|
|
18
|
-
const m = /^agent:([a-z][a-z0-9-]{0,31}):/.exec(key);
|
|
19
|
-
return m?.[1];
|
|
20
|
-
};
|
|
21
17
|
/**
|
|
22
|
-
* Member attribution: the request detail's agent first, else the `agent:<member>:` session key (5.9).
|
|
18
|
+
* Member attribution: the request detail's agent first, else the `agent:<member>:` session key (5.9). An
|
|
19
|
+
* indexed `<member>--<hex6>` account agent attributes to `<member>` (5.15). The real
|
|
23
20
|
* engine nests exec/plugin details under `request` (question events stay flat); unattributed approvals carry ''
|
|
24
21
|
* so they can never match a member id (B2).
|
|
25
22
|
*/
|
|
26
|
-
function attribute(payload) {
|
|
23
|
+
function attribute(payload, memberOf) {
|
|
27
24
|
const detail = isRecord(payload.request) ? payload.request : payload;
|
|
28
25
|
const agent = asString(detail.agentId);
|
|
29
26
|
const key = asString(detail.sessionKey);
|
|
30
|
-
const
|
|
31
|
-
const
|
|
27
|
+
const fromAgent = agent ? memberOf(agent) : undefined;
|
|
28
|
+
const fromKey = key ? memberOf(key) : undefined;
|
|
29
|
+
const member = fromAgent ?? fromKey ?? asString(detail.member) ?? '';
|
|
32
30
|
return { member, ...(key ? { sessionKey: key } : {}) };
|
|
33
31
|
}
|
|
34
32
|
/** Exec policy modes (`off|on-miss|always`) ride in `request.ask`; they are not something a person said (B8). */
|
|
@@ -58,11 +56,13 @@ function expiresOf(payload, fallbackMs) {
|
|
|
58
56
|
export class Approvals {
|
|
59
57
|
request;
|
|
60
58
|
bridge;
|
|
59
|
+
memberOf;
|
|
61
60
|
approvals = new Map();
|
|
62
61
|
listeners = new Set();
|
|
63
62
|
constructor(o) {
|
|
64
63
|
this.request = o.request;
|
|
65
64
|
this.bridge = o.bridge;
|
|
65
|
+
this.memberOf = o.memberOf;
|
|
66
66
|
}
|
|
67
67
|
handleEvent(e) {
|
|
68
68
|
const payload = isRecord(e.payload) ? e.payload : {};
|
|
@@ -71,7 +71,7 @@ export class Approvals {
|
|
|
71
71
|
const id = asString(payload.id) ?? randomBytes(12).toString('base64url');
|
|
72
72
|
if (this.approvals.has(id))
|
|
73
73
|
return;
|
|
74
|
-
const { member, sessionKey } = attribute(payload);
|
|
74
|
+
const { member, sessionKey } = attribute(payload, this.memberOf);
|
|
75
75
|
const detail = isRecord(payload.request) ? payload.request : payload;
|
|
76
76
|
const now = Date.now();
|
|
77
77
|
const approval = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MEMBER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
2
|
+
const HEX6 = /^[0-9a-f]{6}$/;
|
|
3
|
+
const KEY_PREFIX = 'byokit-key-';
|
|
4
|
+
export function agentOf(ref) {
|
|
5
|
+
if (!ref.startsWith('agent:'))
|
|
6
|
+
return ref;
|
|
7
|
+
return /^agent:([^:]+):/.exec(ref)?.[1];
|
|
8
|
+
}
|
|
9
|
+
export function memberOf(ref) {
|
|
10
|
+
const agent = agentOf(ref);
|
|
11
|
+
if (agent === undefined)
|
|
12
|
+
return undefined;
|
|
13
|
+
if (agent.startsWith(KEY_PREFIX)) {
|
|
14
|
+
const inner = agent.slice(KEY_PREFIX.length);
|
|
15
|
+
return MEMBER.test(inner) ? inner : undefined;
|
|
16
|
+
}
|
|
17
|
+
const dash = agent.lastIndexOf('--');
|
|
18
|
+
if (dash >= 0) {
|
|
19
|
+
const head = agent.slice(0, dash);
|
|
20
|
+
const tail = agent.slice(dash + 2);
|
|
21
|
+
if (MEMBER.test(head) && HEX6.test(tail))
|
|
22
|
+
return head;
|
|
23
|
+
}
|
|
24
|
+
return MEMBER.test(agent) ? agent : undefined;
|
|
25
|
+
}
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ export declare class AuthStore {
|
|
|
61
61
|
private restore;
|
|
62
62
|
archives(): Promise<void>;
|
|
63
63
|
prepare(): Promise<void>;
|
|
64
|
+
/** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
|
|
65
|
+
* instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
|
|
66
|
+
* read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
|
|
67
|
+
* stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
|
|
68
|
+
* deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
|
|
69
|
+
reseal(): Promise<void>;
|
|
64
70
|
start(): Promise<void>;
|
|
65
71
|
stop(): Promise<void>;
|
|
66
72
|
offline<T>(task: () => Promise<T>): Promise<T>;
|
package/dist/auth-store.js
CHANGED
|
@@ -8,6 +8,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
10
|
import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
|
|
11
|
+
import { removedAccountAgents, sweepOrphans } from "./accounts.js";
|
|
11
12
|
const encoder = new TextEncoder();
|
|
12
13
|
const archive = (name) => !name.endsWith('.sealed') && (/^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
|
|
13
14
|
|| name.endsWith('.moved-to-engine'));
|
|
@@ -140,6 +141,11 @@ function safePath(path) {
|
|
|
140
141
|
return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
|
|
141
142
|
&& !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
|
|
142
143
|
}
|
|
144
|
+
// A file the live engine deletes between the directory listing and the read is simply gone: there is nothing to
|
|
145
|
+
// seal, so callers skip it. Every other failure still throws.
|
|
146
|
+
function vanished(error) {
|
|
147
|
+
return error.code === 'ENOENT';
|
|
148
|
+
}
|
|
143
149
|
function snapshot(text) {
|
|
144
150
|
const s = JSON.parse(text);
|
|
145
151
|
if ((s?.v !== 1 && s?.v !== 2) || !Array.isArray(s.dirs) || !Array.isArray(s.files))
|
|
@@ -206,8 +212,9 @@ export class AuthStore {
|
|
|
206
212
|
cleanup;
|
|
207
213
|
restoring;
|
|
208
214
|
// Object file name -> sha256 of the plaintext payload currently sealed at that file. Kept only in memory: it
|
|
209
|
-
// never becomes on-disk metadata. Filled by restore() from what is decrypted
|
|
210
|
-
// seal. A process that never restored starts with an empty map and
|
|
215
|
+
// never becomes on-disk metadata. Filled by restore() from what is decrypted (except objects in an older wrap
|
|
216
|
+
// mode), refreshed after each successful seal. A process that never restored starts with an empty map and
|
|
217
|
+
// reseals every object, as before.
|
|
211
218
|
objectHashes = new Map();
|
|
212
219
|
constructor(privateOptions) {
|
|
213
220
|
this.o = privateOptions;
|
|
@@ -315,19 +322,38 @@ export class AuthStore {
|
|
|
315
322
|
catch {
|
|
316
323
|
throw new AuthStoreUnreadableError('invalid-snapshot');
|
|
317
324
|
}
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
325
|
+
const gone = removedAccountAgents(this.o.root);
|
|
326
|
+
const keep = (path) => !path.startsWith('state/agents/') || !gone.has(path.split('/')[2]);
|
|
327
|
+
const kept = { v: 1, dirs: saved.dirs.filter(keep), files: saved.files.filter(([path]) => keep(path)) };
|
|
328
|
+
if (kept.dirs.length !== saved.dirs.length || kept.files.length !== saved.files.length) {
|
|
329
|
+
this.seal(this.file, JSON.stringify(kept));
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const upgraded = seal.upgrade?.(bytes);
|
|
333
|
+
if (upgraded) {
|
|
334
|
+
if (seal.decryptString(Buffer.from(upgraded)) !== text)
|
|
335
|
+
throw new Error('credential upgrade verification failed');
|
|
336
|
+
put(this.file, upgraded);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (gone.size && existsSync(this.objects)) {
|
|
340
|
+
for (const entry of readdirSync(this.objects)) {
|
|
341
|
+
const file = join(this.objects, entry);
|
|
342
|
+
if (keep(this.readObject(file)[0]))
|
|
343
|
+
continue;
|
|
344
|
+
rmSync(file);
|
|
345
|
+
this.objectHashes.delete(entry);
|
|
346
|
+
}
|
|
323
347
|
}
|
|
324
|
-
return
|
|
348
|
+
return kept;
|
|
325
349
|
}
|
|
326
350
|
readObject(file) {
|
|
351
|
+
let bytes = Buffer.alloc(0);
|
|
327
352
|
let text = '';
|
|
328
353
|
let entry = null;
|
|
329
354
|
try {
|
|
330
|
-
|
|
355
|
+
bytes = readFileSync(file);
|
|
356
|
+
text = this.open(bytes);
|
|
331
357
|
entry = JSON.parse(text);
|
|
332
358
|
}
|
|
333
359
|
catch (error) {
|
|
@@ -340,7 +366,7 @@ export class AuthStore {
|
|
|
340
366
|
if (!safePath(path) || !apart(path) || typeof data !== 'string' || Buffer.from(data, 'base64').toString('base64') !== data) {
|
|
341
367
|
throw new AuthStoreUnreadableError('invalid-snapshot');
|
|
342
368
|
}
|
|
343
|
-
return [path, data, text];
|
|
369
|
+
return [path, data, text, bytes];
|
|
344
370
|
}
|
|
345
371
|
inventory() {
|
|
346
372
|
const dirs = [];
|
|
@@ -348,15 +374,35 @@ export class AuthStore {
|
|
|
348
374
|
const objects = [];
|
|
349
375
|
let total = 0;
|
|
350
376
|
const root = realpathSync(this.o.root);
|
|
377
|
+
const gone = removedAccountAgents(this.o.root);
|
|
351
378
|
const walk = (path) => {
|
|
352
379
|
const name = relative(this.o.root, path).split('\\').join('/');
|
|
380
|
+
if (name.startsWith('state/agents/') && gone.has(name.split('/')[2]))
|
|
381
|
+
return; // a late refresh never enters a seal
|
|
353
382
|
if (name.startsWith('home/') && nonCredential(name))
|
|
354
383
|
return; // regenerable home caches stay on disk; skipping a directory skips its subtree
|
|
355
|
-
|
|
384
|
+
let stat;
|
|
385
|
+
try {
|
|
386
|
+
stat = lstatSync(path);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (vanished(error))
|
|
390
|
+
return;
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
356
393
|
if (stat.isDirectory()) {
|
|
357
394
|
if (!apart(name))
|
|
358
395
|
dirs.push(name);
|
|
359
|
-
|
|
396
|
+
let children;
|
|
397
|
+
try {
|
|
398
|
+
children = readdirSync(path).sort();
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (vanished(error))
|
|
402
|
+
return;
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
for (const child of children)
|
|
360
406
|
walk(join(path, child));
|
|
361
407
|
return;
|
|
362
408
|
}
|
|
@@ -374,7 +420,14 @@ export class AuthStore {
|
|
|
374
420
|
const rel = relative(root, file);
|
|
375
421
|
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
376
422
|
return;
|
|
377
|
-
|
|
423
|
+
try {
|
|
424
|
+
target = lstatSync(file);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (vanished(error))
|
|
428
|
+
return;
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
378
431
|
}
|
|
379
432
|
if (!target.isFile())
|
|
380
433
|
return;
|
|
@@ -396,15 +449,44 @@ export class AuthStore {
|
|
|
396
449
|
return { dirs, files, objects };
|
|
397
450
|
}
|
|
398
451
|
collect() {
|
|
452
|
+
sweepOrphans(this.o.root);
|
|
399
453
|
const { dirs, files, objects } = this.inventory();
|
|
400
|
-
for (const dir of dirs)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
454
|
+
for (const dir of dirs) {
|
|
455
|
+
try {
|
|
456
|
+
chmodSync(join(this.o.root, dir), 0o700);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (!vanished(error))
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Read the checked target, but save the link's path: restore materializes a regular file there. A file that
|
|
464
|
+
// vanishes here is skipped by the same rule as in the listing above.
|
|
465
|
+
const read = ({ name, file }) => {
|
|
466
|
+
try {
|
|
467
|
+
chmodSync(file, 0o600);
|
|
468
|
+
return [name, readFileSync(file).toString('base64')];
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
if (vanished(error))
|
|
472
|
+
return undefined;
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const kept = objects.filter(({ file }) => {
|
|
477
|
+
try {
|
|
478
|
+
chmodSync(file, 0o600);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (vanished(error))
|
|
483
|
+
return false;
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
});
|
|
405
487
|
return {
|
|
406
|
-
saved: { v: 1, dirs, files: files.map((
|
|
407
|
-
objects,
|
|
488
|
+
saved: { v: 1, dirs, files: files.map(read).filter((entry) => entry !== undefined) },
|
|
489
|
+
objects: kept,
|
|
408
490
|
};
|
|
409
491
|
}
|
|
410
492
|
/** Remove the live sealed trees: all of `state` (every file there is sealed, as a blob entry or an object),
|
|
@@ -431,10 +513,19 @@ export class AuthStore {
|
|
|
431
513
|
mkdirSync(this.objects, { recursive: true, mode: 0o700 });
|
|
432
514
|
const keep = new Set();
|
|
433
515
|
for (const { name, file } of objects) {
|
|
516
|
+
let data;
|
|
517
|
+
try {
|
|
518
|
+
data = readFileSync(file).toString('base64');
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (vanished(error))
|
|
522
|
+
continue;
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
434
525
|
const object = objectName(name);
|
|
435
526
|
keep.add(object);
|
|
436
527
|
const target = join(this.objects, object);
|
|
437
|
-
const payload = JSON.stringify({ path: name, data
|
|
528
|
+
const payload = JSON.stringify({ path: name, data });
|
|
438
529
|
// An object this process already sealed (or restored) unchanged is not resealed: its plaintext hash is
|
|
439
530
|
// the same and its sealed file is still on disk, so the encrypt + verify + fsyncs are skipped. A changed
|
|
440
531
|
// file, a new one, or one whose sealed file vanished falls through to the normal seal.
|
|
@@ -502,8 +593,11 @@ export class AuthStore {
|
|
|
502
593
|
}
|
|
503
594
|
if (existsSync(this.objects)) {
|
|
504
595
|
for (const entry of readdirSync(this.objects)) {
|
|
505
|
-
const [path, data, payload] = this.readObject(join(this.objects, entry));
|
|
506
|
-
|
|
596
|
+
const [path, data, payload, bytes] = this.readObject(join(this.objects, entry));
|
|
597
|
+
// An object sealed in an older wrap mode (the adapter would upgrade it, as read() does the main file) gets
|
|
598
|
+
// no hash, so the next seal reseals it in the current mode even when unchanged.
|
|
599
|
+
if (!this.o.seal.upgrade?.(bytes))
|
|
600
|
+
this.objectHashes.set(entry, objectHash(payload));
|
|
507
601
|
// An object path can be several directories below an existing one, and those directories are not in
|
|
508
602
|
// `saved.dirs` (they sit in a non-credential subtree). Create them through ensureRestoreDirs so the
|
|
509
603
|
// parent of every directory this restore creates — not just each object's own directory — is fsynced
|
|
@@ -558,6 +652,24 @@ export class AuthStore {
|
|
|
558
652
|
}
|
|
559
653
|
});
|
|
560
654
|
}
|
|
655
|
+
/** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
|
|
656
|
+
* instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
|
|
657
|
+
* read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
|
|
658
|
+
* stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
|
|
659
|
+
* deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
|
|
660
|
+
async reseal() {
|
|
661
|
+
return this.serial(async () => {
|
|
662
|
+
if (!this.o.seal || !this.active)
|
|
663
|
+
return;
|
|
664
|
+
const previous = await this.read();
|
|
665
|
+
const { saved, objects } = this.collect();
|
|
666
|
+
// Same debris guard as persist(): a running engine always leaves files behind, so an empty live tree
|
|
667
|
+
// keeps the sealed credentials instead of replacing them with an empty snapshot.
|
|
668
|
+
if (!saved.files.length && !objects.length && previous)
|
|
669
|
+
return;
|
|
670
|
+
this.seal(this.file, JSON.stringify(saved));
|
|
671
|
+
});
|
|
672
|
+
}
|
|
561
673
|
start() {
|
|
562
674
|
return this.serial(async () => {
|
|
563
675
|
if (this.active)
|
package/dist/bridge.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Approval, Decision, RunRef, ToolHost, ToolSpec } from './types.ts';
|
|
1
|
+
import type { Approval, Decision, Member, RunRef, ToolHost, ToolSpec } from './types.ts';
|
|
2
2
|
/**
|
|
3
3
|
* Asks must expire before the plugin's 195 s gate timeout, or the run gets a generic block while the approval
|
|
4
4
|
* lingers (N3). The kit passes its `approvalTimeoutMs` through; anything above is clamped to this.
|
|
@@ -32,6 +32,7 @@ export declare class Bridge {
|
|
|
32
32
|
private readonly tools;
|
|
33
33
|
private readonly permitted;
|
|
34
34
|
private readonly approvalTimeoutMs;
|
|
35
|
+
private readonly memberOf;
|
|
35
36
|
private readonly onAsk;
|
|
36
37
|
private readonly onAskGone;
|
|
37
38
|
private readonly beforeAgentRun?;
|
|
@@ -52,6 +53,7 @@ export declare class Bridge {
|
|
|
52
53
|
tools: ReadonlySet<string>;
|
|
53
54
|
permitted: (tool: string) => boolean;
|
|
54
55
|
approvalTimeoutMs: number;
|
|
56
|
+
memberOf(ref: string): Member | undefined;
|
|
55
57
|
onAsk(a: Approval): void;
|
|
56
58
|
onAskGone(id: string): void;
|
|
57
59
|
beforeAgentRun?: (key: string, runId?: string) => Promise<boolean>;
|
package/dist/bridge.js
CHANGED
|
@@ -12,10 +12,6 @@ const FRAME_CAP = 1_000_000;
|
|
|
12
12
|
*/
|
|
13
13
|
export const MAX_APPROVAL_TIMEOUT_MS = 190_000;
|
|
14
14
|
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
15
|
-
const memberOfKey = (key) => {
|
|
16
|
-
const m = /^agent:([a-z][a-z0-9-]{0,31}):/.exec(key);
|
|
17
|
-
return m?.[1];
|
|
18
|
-
};
|
|
19
15
|
export const DEFAULT_SOCKET_NAME = 'bridge.sock';
|
|
20
16
|
export const DEFAULT_PARAM_PREFIX = '__byokit';
|
|
21
17
|
/** Resolve the bridge naming options, validating explicit values; defaults keep prior behavior. */
|
|
@@ -66,6 +62,7 @@ export class Bridge {
|
|
|
66
62
|
tools;
|
|
67
63
|
permitted;
|
|
68
64
|
approvalTimeoutMs;
|
|
65
|
+
memberOf;
|
|
69
66
|
onAsk;
|
|
70
67
|
onAskGone;
|
|
71
68
|
beforeAgentRun;
|
|
@@ -88,6 +85,7 @@ export class Bridge {
|
|
|
88
85
|
this.tools = o.tools;
|
|
89
86
|
this.permitted = o.permitted;
|
|
90
87
|
this.approvalTimeoutMs = Math.min(o.approvalTimeoutMs, MAX_APPROVAL_TIMEOUT_MS);
|
|
88
|
+
this.memberOf = o.memberOf;
|
|
91
89
|
this.onAsk = o.onAsk;
|
|
92
90
|
this.onAskGone = o.onAskGone;
|
|
93
91
|
this.beforeAgentRun = o.beforeAgentRun;
|
|
@@ -293,7 +291,7 @@ export class Bridge {
|
|
|
293
291
|
(!armed.input || armed.input(input))) {
|
|
294
292
|
this.armed = undefined;
|
|
295
293
|
// '' when unattributable: never a member id (B2).
|
|
296
|
-
const member =
|
|
294
|
+
const member = this.memberOf(key) ?? '';
|
|
297
295
|
const runRef = { sessionKey: key, member };
|
|
298
296
|
return void this.gateRun(socket, runRef, tool, input);
|
|
299
297
|
}
|
|
@@ -389,7 +387,7 @@ export class Bridge {
|
|
|
389
387
|
}
|
|
390
388
|
if (!this.host)
|
|
391
389
|
return this.reply(socket, { ok: false, reason: "can't run this action right now" });
|
|
392
|
-
const run = this.runs.get(key) ?? { sessionKey: key, member:
|
|
390
|
+
const run = this.runs.get(key) ?? { sessionKey: key, member: this.memberOf(key) ?? '' };
|
|
393
391
|
// The plugin destroys its socket when the run aborts (run abort, hook timeout); without this the host's
|
|
394
392
|
// sandboxed command keeps running after the tool is gone.
|
|
395
393
|
const controller = new AbortController();
|
package/dist/config.js
CHANGED
|
@@ -52,6 +52,16 @@ export function reconcileConfig(saved, o) {
|
|
|
52
52
|
c.agents.defaults.authInheritance = { agentId: BASE_AGENT };
|
|
53
53
|
c.agents.defaults.models ??= {};
|
|
54
54
|
c.agents.defaults.models['openai/*'] = { ...c.agents.defaults.models['openai/*'], agentRuntime: { id: 'openclaw' } };
|
|
55
|
+
// With no policy record the pin reads the `models` keys above as a legacy allowlist and materializes
|
|
56
|
+
// `modelPolicy.allow: ['openai/*']` on boot, so a member's OpenCode Go (any non-OpenAI) model fell back to
|
|
57
|
+
// openai. An empty record restricts nothing; the exact list that migration wrote is dropped unless the app sets one.
|
|
58
|
+
if (!o.app?.agents?.defaults?.modelPolicy) {
|
|
59
|
+
const policy = c.agents.defaults.modelPolicy;
|
|
60
|
+
if (!object(policy))
|
|
61
|
+
c.agents.defaults.modelPolicy = {};
|
|
62
|
+
else if (Array.isArray(policy.allow) && policy.allow.length === 1 && policy.allow[0] === 'openai/*')
|
|
63
|
+
delete policy.allow;
|
|
64
|
+
}
|
|
55
65
|
const allow = c.agents.defaults.modelPolicy?.allow;
|
|
56
66
|
if (Array.isArray(allow) && allow.length && !allow.includes('openai/*'))
|
|
57
67
|
allow.push('openai/*');
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare const ENGINE_VERSION = "2026.8.35";
|
|
2
2
|
export declare const PROTOCOL_VERSION = 4;
|
|
3
|
+
export declare const LIVE_CATALOGS: string[];
|
|
3
4
|
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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// `needs-update`, never silently run.
|
|
3
3
|
export const ENGINE_VERSION = '2026.8.35';
|
|
4
4
|
export const PROTOCOL_VERSION = 4;
|
|
5
|
+
// Providers whose live catalog the kit registers (5.6); key choice uses the same catalog hook.
|
|
6
|
+
export const LIVE_CATALOGS = ['opencode-go'];
|
|
5
7
|
export const OPERATOR_SCOPES = [
|
|
6
8
|
'operator.read',
|
|
7
9
|
'operator.write',
|
package/dist/device.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { BrowserDevice, SignInRefusedWhy } from './browser.ts';
|
|
|
2
2
|
import type { DeviceLink } from '@byokit/pair';
|
|
3
3
|
import type { RouteView } from './routes.ts';
|
|
4
4
|
import { type AccountView } from './words.ts';
|
|
5
|
-
import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, RunEnd, OutputSchema, SchemaOutput, RunEvent, SignInView } from './types.ts';
|
|
5
|
+
import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, RunEnd, OutputSchema, SchemaOutput, RunEvent, SignInView, AccountRef } from './types.ts';
|
|
6
6
|
export type { GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult } from './types.ts';
|
|
7
7
|
export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunUsage, SignInView, OutputSchema, SchemaOutput } from './types.ts';
|
|
8
8
|
/** One `oc.events` frame: a member's Gateway event, typed by name, or an approval add/resolve (7.2). */
|
|
@@ -36,6 +36,8 @@ export type DeviceEndFrame<T = unknown> = {
|
|
|
36
36
|
export type DeviceRunOptions<S extends OutputSchema | undefined = OutputSchema | undefined> = {
|
|
37
37
|
schema?: S;
|
|
38
38
|
sessionKey?: string;
|
|
39
|
+
account?: AccountRef | 'default' | 'auto';
|
|
40
|
+
needs?: string[];
|
|
39
41
|
model?: string;
|
|
40
42
|
auth?: 'apiKey';
|
|
41
43
|
system?: string;
|
|
@@ -79,9 +81,7 @@ export type SignInNotice = {
|
|
|
79
81
|
export declare function openSignInNotice(data: Record<string, unknown>, seed: Uint8Array): SignInNotice | null;
|
|
80
82
|
/** Transient streams bypass link's durable request/answer cache: no lease nonce or frame is persisted. */
|
|
81
83
|
export declare function browserDevice(link: DeviceLink): BrowserDevice;
|
|
82
|
-
type DeviceRunEvent =
|
|
83
|
-
type: 'account';
|
|
84
|
-
}>;
|
|
84
|
+
type DeviceRunEvent = RunEvent;
|
|
85
85
|
/** `openclawDevice`'s device surface: every `oc.*` op the computer answers, typed. */
|
|
86
86
|
export type OpenClawDevice = {
|
|
87
87
|
browser: BrowserDevice;
|
package/dist/device.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { openBoxFromSeed, boxKeyPairFromSeed } from '@byokit/seal';
|
|
2
|
+
import { memberOf } from "./attribution.js";
|
|
2
3
|
import { toAccountView } from "./words.js";
|
|
3
4
|
import { b64urlDecode, b64urlEncode, openNotice } from "./notices.js";
|
|
4
5
|
/**
|
|
@@ -19,6 +20,13 @@ export class BrowserRefused extends LinkRefused {
|
|
|
19
20
|
constructor(why) { super(`Sign-in refused: ${why}`); this.why = why; }
|
|
20
21
|
}
|
|
21
22
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
// A session key this device sends must be shaped like one: `agent:<member>:` or an
|
|
24
|
+
// `<member>--<hex6>` account agent's key (5.15). Anything else is refused before it reaches the computer.
|
|
25
|
+
const checkKey = (sessionKey) => {
|
|
26
|
+
if (typeof sessionKey !== 'string' || !sessionKey.startsWith('agent:') || memberOf(sessionKey) === undefined)
|
|
27
|
+
throw new LinkRefused('Invalid session key');
|
|
28
|
+
return sessionKey;
|
|
29
|
+
};
|
|
22
30
|
/**
|
|
23
31
|
* Newline-delimited JSON frames off a link stream, in order. A host end with an error becomes a
|
|
24
32
|
* LinkRefused with the host's words (a refusal arrives after the stream opens, so it cannot ride
|
|
@@ -354,7 +362,9 @@ export function openclawDevice(link) {
|
|
|
354
362
|
},
|
|
355
363
|
sessions: () => link.request('oc.sessions'),
|
|
356
364
|
run: (message, o) => {
|
|
357
|
-
const inner = liveStream(() => link.stream('oc.run', { message, ...(o?.sessionKey ? { sessionKey: o.sessionKey } : {}),
|
|
365
|
+
const inner = liveStream(() => link.stream('oc.run', { message, ...(o?.sessionKey ? { sessionKey: checkKey(o.sessionKey) } : {}),
|
|
366
|
+
...(o?.account !== undefined ? { account: o.account } : {}),
|
|
367
|
+
...(o?.needs !== undefined ? { needs: o.needs } : {}),
|
|
358
368
|
...(o?.model !== undefined ? { model: o.model } : {}), ...(o?.system !== undefined ? { system: o.system } : {}),
|
|
359
369
|
...(o?.auth !== undefined ? { auth: o.auth } : {}),
|
|
360
370
|
...(o?.images !== undefined ? { images: o.images } : {}), ...(o?.thinking !== undefined ? { thinking: o.thinking } : {}),
|
package/dist/engine.d.ts
CHANGED
package/dist/engine.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ENGINE_VERSION } from "./constants.js";
|
|
|
12
12
|
import { appendUsageBoot } from "./usage-boots.js";
|
|
13
13
|
import { reconcileConfig, appRecoveryPrefixes } from "./config.js";
|
|
14
14
|
import { writePlugin, resolveBridge } from "./bridge.js";
|
|
15
|
+
import { sweepOrphans } from "./accounts.js";
|
|
15
16
|
const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
17
|
const putOnce = (path, data) => { if (!existsSync(path))
|
|
17
18
|
writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); };
|
|
@@ -161,6 +162,7 @@ export class Engine {
|
|
|
161
162
|
await this.recoverOrphan();
|
|
162
163
|
for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'usage'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs')])
|
|
163
164
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
165
|
+
this.sweepAccountOrphans();
|
|
164
166
|
try {
|
|
165
167
|
await this.authStore.prepare();
|
|
166
168
|
this.credentialsLocked = false;
|
|
@@ -256,7 +258,7 @@ export class Engine {
|
|
|
256
258
|
const pluginDir = join(this.root, 'plugin');
|
|
257
259
|
mkdirSync(pluginDir, { recursive: true, mode: 0o700 });
|
|
258
260
|
// The shipped plugin follows the kit on every prepare too: a state dir from an older kit must not keep its gate.
|
|
259
|
-
for (const f of ['package.json', 'index.js', 'keys.js', 'usage.js', 'accounts.js']) {
|
|
261
|
+
for (const f of ['package.json', 'index.js', 'keys.js', 'usage.js', 'accounts.js', 'catalog.js']) {
|
|
260
262
|
const source = join(kitDir, 'plugin', f);
|
|
261
263
|
putChanged(join(pluginDir, f), readFileSync(source, 'utf8'));
|
|
262
264
|
}
|
|
@@ -351,6 +353,18 @@ export class Engine {
|
|
|
351
353
|
throw occupied();
|
|
352
354
|
// The new store owner now seals crash leftovers under its lock before restoring them.
|
|
353
355
|
}
|
|
356
|
+
// The account orphan sweep (5.15): a late engine write after a removal cannot be ruled out by a bounded check, so
|
|
357
|
+
// prepare and the sealed restore remove each `<root>/state/agents/<member>--<hex6>` directory a removal recorded as
|
|
358
|
+
// gone and no index names. No roster here: the gateway is down, and no add is in flight while preparing (an entry
|
|
359
|
+
// is written before its agent is created, so an account being added is named).
|
|
360
|
+
sweepAccountOrphans() {
|
|
361
|
+
try {
|
|
362
|
+
sweepOrphans(this.root);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
this.o.log?.(`prepare: orphans are not swept: ${String(error.message).slice(0, 200)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
354
368
|
start() {
|
|
355
369
|
if (this.starting)
|
|
356
370
|
return this.starting;
|
|
@@ -382,6 +396,7 @@ export class Engine {
|
|
|
382
396
|
this.state('failed', 'auth-store-unreadable');
|
|
383
397
|
throw error;
|
|
384
398
|
}
|
|
399
|
+
this.sweepAccountOrphans();
|
|
385
400
|
try {
|
|
386
401
|
return this.launch();
|
|
387
402
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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 } from './keys.ts';
|
|
7
|
+
export type { 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';
|