@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.
@@ -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
+ }
@@ -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). The real
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 fromKey = key ? memberOfKey(key) : undefined;
31
- const member = agent ?? fromKey ?? asString(detail.member) ?? '';
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,3 @@
1
+ import type { Member } from './types.ts';
2
+ export declare function agentOf(ref: string): string | undefined;
3
+ export declare function memberOf(ref: string): Member | undefined;
@@ -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
+ }
@@ -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>;
@@ -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, refreshed after each successful
210
- // seal. A process that never restored starts with an empty map and reseals every object, as before.
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 upgraded = seal.upgrade?.(bytes);
319
- if (upgraded) {
320
- if (seal.decryptString(Buffer.from(upgraded)) !== text)
321
- throw new Error('credential upgrade verification failed');
322
- put(this.file, upgraded);
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 saved;
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
- text = this.open(readFileSync(file));
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
- const stat = lstatSync(path);
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
- for (const child of readdirSync(path).sort())
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
- target = lstatSync(file);
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
- 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);
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(({ name, file }) => [name, readFileSync(file).toString('base64')]) },
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: readFileSync(file).toString('base64') });
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
- this.objectHashes.set(entry, objectHash(payload));
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 = memberOfKey(key) ?? '';
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: memberOfKey(key) ?? '' };
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/*');
@@ -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 = Exclude<RunEvent, {
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
@@ -54,6 +54,7 @@ export declare class Engine {
54
54
  };
55
55
  private verifiedOrphan;
56
56
  private recoverOrphan;
57
+ private sweepAccountOrphans;
57
58
  start(): Promise<{
58
59
  port: number;
59
60
  token: string;
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';