@byokit/openclaw 0.11.0 → 0.12.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +45 -7
  3. package/SECURITY.md +3 -2
  4. package/dist/accounts.d.ts +45 -1
  5. package/dist/accounts.js +641 -46
  6. package/dist/activation.d.ts +17 -0
  7. package/dist/activation.js +9 -0
  8. package/dist/approvals.d.ts +2 -0
  9. package/dist/approvals.js +9 -9
  10. package/dist/attribution.d.ts +3 -0
  11. package/dist/attribution.js +25 -0
  12. package/dist/bridge.d.ts +3 -1
  13. package/dist/bridge.js +4 -6
  14. package/dist/config.js +10 -0
  15. package/dist/constants.d.ts +1 -0
  16. package/dist/constants.js +2 -0
  17. package/dist/device.d.ts +5 -2
  18. package/dist/device.js +11 -1
  19. package/dist/engine.js +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/keys.d.ts +19 -5
  22. package/dist/keys.js +135 -23
  23. package/dist/kit.d.ts +38 -1
  24. package/dist/kit.js +153 -28
  25. package/dist/link.js +21 -11
  26. package/dist/members.d.ts +3 -0
  27. package/dist/members.js +13 -9
  28. package/dist/pick.d.ts +7 -3
  29. package/dist/pick.js +34 -5
  30. package/dist/routes.d.ts +1 -0
  31. package/dist/routes.js +5 -3
  32. package/dist/routes.json +31 -31
  33. package/dist/runs.d.ts +7 -0
  34. package/dist/runs.js +111 -11
  35. package/dist/signin.d.ts +3 -0
  36. package/dist/signin.js +51 -17
  37. package/dist/testing/fake-gateway.d.ts +18 -0
  38. package/dist/testing/fake-gateway.js +210 -16
  39. package/dist/testing/model-stub.d.ts +1 -0
  40. package/dist/testing/model-stub.js +2 -1
  41. package/dist/types.d.ts +19 -0
  42. package/dist/words.json +8 -1
  43. package/package.json +2 -2
  44. package/plugin/accounts.js +12 -3
  45. package/plugin/catalog.js +48 -0
  46. package/plugin/index.js +2 -1
  47. package/plugin/keys.js +22 -4
@@ -20,6 +20,24 @@ export type FakeGateway = {
20
20
  failNext(method: string, message: string): void;
21
21
  drop(why: string): void;
22
22
  handle(method: string, fn: FakeHandler): void;
23
+ /** Usage windows a later `models.authStatus` read reports for the agent's provider (O14: the Auto input). */
24
+ setUsage(agentId: string, provider: string, windows: {
25
+ label: string;
26
+ usedPercent?: number;
27
+ resetAt?: number;
28
+ }[]): void;
29
+ /** Profile fields every later wizard sign-in stores (O14 identity): `accountId: undefined` signs in with no account
30
+ * id, so no `who`. With no call, or `signInAs()`, the account id is `fake-<agentId>`. */
31
+ signInAs(fields?: {
32
+ accountId?: string;
33
+ email?: string;
34
+ expires?: number;
35
+ }): void;
36
+ /** A sealed profile already in an agent's store, as a sign-in of another provider leaves it; the agent's model points at it. */
37
+ seedProfile(agentId: string, profileId: string, profile: {
38
+ provider: string;
39
+ type: string;
40
+ } & Record<string, unknown>): void;
23
41
  } & {
24
42
  transport: GatewayTransport;
25
43
  };
@@ -14,6 +14,13 @@ const DEVICE_STEP = {
14
14
  deviceCode: { code: 'CREW-2026', expiresInMinutes: 15 }, externalUrl: 'https://auth.openai.com/codex/device',
15
15
  };
16
16
  const PROGRESS_STEP = { id: 'step-wait', type: 'progress', message: 'Waiting for authorization', executor: 'gateway' };
17
+ // ChatGPT's browser route: the pin hands its unsigned authorize URL to the next step as `externalUrl`.
18
+ const BROWSER_STEP = {
19
+ id: 'step-browser', type: 'note', executor: 'client', message: 'Open this URL in your LOCAL browser.',
20
+ externalUrl: 'https://auth.openai.com/oauth/authorize?response_type=code&client_id=fake-client&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=fake-state',
21
+ };
22
+ // The pin's agents.create into an existing workspace rewrites IDENTITY.md's Name line (mergeIdentityMarkdownContent).
23
+ const IDENTITY_NAME = /^- \*\*Name:\*\*.*$/m;
17
24
  const EVENTS = [
18
25
  'agent',
19
26
  'exec.approval.requested', 'exec.approval.resolved',
@@ -110,7 +117,47 @@ export function fakeGateway(script, o) {
110
117
  const orders = new Map();
111
118
  const models = new Map();
112
119
  const wizards = new Map();
120
+ let signInFields;
121
+ // Each workspace's files by name, as `agents.files.get/set` and the create-time IDENTITY.md rewrite see them.
122
+ const workspaces = new Map();
123
+ const filesOf = (agentId) => {
124
+ const agent = agents.get(agentId);
125
+ if (!agent)
126
+ throw new Error(`unknown agent id "${agentId}"`);
127
+ const workspace = agent.workspace ?? `workspace-${agentId}`;
128
+ let files = workspaces.get(workspace);
129
+ if (!files) {
130
+ files = new Map();
131
+ workspaces.set(workspace, files);
132
+ }
133
+ return { workspace, files };
134
+ };
135
+ const fileAnswer = (agentId, name) => {
136
+ const { workspace, files } = filesOf(agentId);
137
+ const content = files.get(name);
138
+ return { agentId, workspace, file: { name, path: join(workspace, name), missing: content === undefined,
139
+ ...(content === undefined ? {} : { size: Buffer.byteLength(content), content }) } };
140
+ };
113
141
  const runs = new Map();
142
+ const sessions = new Map();
143
+ const sessionOf = (agentId, key) => {
144
+ let session = sessions.get(`${agentId}\n${key}`);
145
+ if (!session) {
146
+ session = { sessionId: randomUUID(), updatedAt: 0, rows: [] };
147
+ sessions.set(`${agentId}\n${key}`, session);
148
+ }
149
+ return session;
150
+ };
151
+ const append = (session, row) => {
152
+ session.rows.push(row);
153
+ session.updatedAt = Math.max(Date.now(), session.updatedAt + 1);
154
+ };
155
+ const activeOn = (agentId, key) => [...runs.values()].some((run) => !run.result && run.agentId === agentId && run.sessionKey === key);
156
+ // The engine's own cached read of each provider's usage endpoint (O14): cold until a `models.authStatus`
157
+ // answer has carried the provider once (no `usage` on that first answer), then whatever `setUsage` stored.
158
+ // A pre-sign-in read names no provider, so it warms nothing: the journey's first room read stays cold.
159
+ const usage = new Map();
160
+ const warmed = new Set();
114
161
  const configBox = { hash: 'hash-1', config: {} };
115
162
  let hashSeq = 1;
116
163
  const ensureAgent = (id, workspace) => {
@@ -238,10 +285,14 @@ export function fakeGateway(script, o) {
238
285
  if (params.agentId != null && !agents.has(String(params.agentId)))
239
286
  throw new Error(`unknown agent: ${params.agentId}`);
240
287
  // Like the engine: the provider/model the run is called on, its own override or the member's (here openai).
241
- const run = { runId: randomUUID(), sessionKey: String(params.sessionKey ?? ''), seq: 0,
288
+ const run = { runId: randomUUID(), agentId: String(params.agentId ?? 'main'), sessionKey: String(params.sessionKey ?? ''), seq: 0,
242
289
  provider: String(params.provider ?? 'openai'), model: String(params.model ?? 'gpt-5.1'), waiters: [] };
243
290
  runs.set(run.runId, run);
291
+ const session = sessionOf(run.agentId, run.sessionKey);
292
+ session.provider = run.provider;
293
+ session.model = run.model;
244
294
  const message = String(params.message ?? '');
295
+ append(session, { role: 'user', content: [{ type: 'text', text: message }] });
245
296
  const text = `fake: ${message}`;
246
297
  setTimeout(() => {
247
298
  void (async () => {
@@ -272,8 +323,11 @@ export function fakeGateway(script, o) {
272
323
  // Completion rides `phase: 'result'` like the real engine (O11), not `'end'`, with its result.
273
324
  emitRun(run, 'tool', { phase: 'result', name: call.name, toolCallId, isError,
274
325
  result: { content: [{ type: 'text', text: output }] } });
326
+ append(session, { role: 'assistant', content: [{ type: 'toolCall', id: toolCallId, name: call.name, arguments: call.input }] });
327
+ append(session, { role: 'toolResult', toolCallId, toolName: call.name, isError, content: [{ type: 'text', text: output }] });
275
328
  }
276
329
  emitRun(run, 'assistant', { text });
330
+ append(session, { role: 'assistant', content: [{ type: 'text', text }] });
277
331
  // The engine sums the run's usage (zero buckets omitted); the fake counts characters.
278
332
  run.usage = { input: message.length, output: text.length, total: message.length + text.length };
279
333
  setTimeout(() => finish(run, { status: 'ok', terminalReply: { text } }), ABORT_WINDOW_MS);
@@ -304,16 +358,41 @@ export function fakeGateway(script, o) {
304
358
  }),
305
359
  'agents.create': (p) => {
306
360
  const id = String(p.name);
361
+ if (agents.has(id))
362
+ throw new Error(`agent "${id}" already exists`);
307
363
  const workspace = p.workspace ? String(p.workspace) : undefined;
364
+ if (workspace) {
365
+ const files = workspaces.get(workspace) ?? new Map();
366
+ workspaces.set(workspace, files);
367
+ const identity = files.get('IDENTITY.md') ?? '# IDENTITY.md - Agent Identity\n\n';
368
+ files.set('IDENTITY.md', IDENTITY_NAME.test(identity) ? identity.replace(IDENTITY_NAME, `- **Name:** ${id}`) : `${identity}- **Name:** ${id}\n`);
369
+ }
308
370
  // With rosterApplyMs the fake mirrors the pinned engine: create answers before the roster hot-reload
309
371
  // makes the agent visible to agents.list (Crewhouse saw ~113 ms).
310
372
  if (o?.rosterApplyMs)
311
373
  setTimeout(() => ensureAgent(id, workspace), o.rosterApplyMs);
312
374
  else
313
375
  ensureAgent(id, workspace);
314
- return { id };
376
+ return { ok: true, agentId: id, name: id, workspace: workspace ?? `workspace-${id}` };
377
+ },
378
+ // deleteFiles: false keeps the workspace, as the kit always asks; the agent's own store and model go.
379
+ 'agents.delete': (p) => {
380
+ const id = String(p.agentId);
381
+ if (!agents.delete(id))
382
+ throw new Error(`agent "${id}" not found`);
383
+ profiles.delete(id);
384
+ orders.delete(id);
385
+ models.delete(id);
386
+ return { ok: true, agentId: id, removedBindings: 0 };
387
+ },
388
+ 'agents.files.get': (p) => fileAnswer(String(p.agentId), String(p.name)),
389
+ 'agents.files.set': (p) => {
390
+ filesOf(String(p.agentId)).files.set(String(p.name), String(p.content));
391
+ return { ok: true, ...fileAnswer(String(p.agentId), String(p.name)) };
315
392
  },
316
393
  // One profile per signed-in provider, `<provider>:default`, as the pin lists an agent's own profiles.
394
+ // Plus its cached usage once warm: cold on the first answer carrying the provider (no `usage`), then the
395
+ // stored windows.
317
396
  'models.authStatus': (p) => {
318
397
  const id = String(p?.agentId ?? 'main');
319
398
  const store = storeOf(id);
@@ -322,12 +401,26 @@ export function fakeGateway(script, o) {
322
401
  providers: (agents.get(id)?.providers ?? []).map((provider) => {
323
402
  const profileId = `${provider}:default`;
324
403
  const credential = store[profileId];
404
+ const key = `${id}\n${provider}`;
405
+ const cold = !warmed.has(key);
406
+ warmed.add(key);
407
+ const windows = cold ? undefined : usage.get(id)?.get(provider);
325
408
  return { provider,
326
409
  profiles: [{ profileId, type: credential?.type ?? 'oauth',
327
- status: credential ? statusOf(credential, now) : 'ok' }] };
410
+ status: credential ? statusOf(credential, now) : 'ok' }],
411
+ ...(windows !== undefined ? { usage: { windows } } : {}) };
328
412
  }),
329
413
  };
330
414
  },
415
+ // The runtime-allowed catalog for the agent's signed-in providers (O14): one available row each. A test
416
+ // scripting exact availability scripts this method with `handle`.
417
+ 'models.list': (p) => {
418
+ const id = String(p?.agentId ?? 'main');
419
+ return {
420
+ models: (agents.get(id)?.providers ?? []).map((provider) => ({ id: `${provider}/default`,
421
+ name: 'Default', provider, available: true })),
422
+ };
423
+ },
331
424
  'models.authLogout': (p) => {
332
425
  const agent = agents.get(String(p?.agentId ?? 'main'));
333
426
  // The pin refuses a profileIds list naming a profile the agent does not hold.
@@ -353,20 +446,27 @@ export function fakeGateway(script, o) {
353
446
  },
354
447
  'byokit.keys': (p) => {
355
448
  const id = `byokit-key-${p.member}`;
356
- if (p.action === 'prepare') {
449
+ if (p.action === 'prepare' || p.action === 'clear') {
357
450
  keys.delete(id);
358
451
  ensureAgent(id).providers = [];
359
452
  return { ok: true };
360
453
  }
361
454
  const key = keys.get(id);
362
455
  if (!key)
363
- return { ok: false };
456
+ return { ok: false, absent: true };
364
457
  if (p.action === 'seal') {
365
458
  key.sealed = true;
459
+ if (typeof p.model === 'string')
460
+ key.model = p.model;
366
461
  return { ok: true };
367
462
  }
368
- return { ok: key.sealed, ...(key.sealed ? { model: key.model } : {}) };
463
+ return key.sealed ? { ok: true, model: key.model } : { ok: false, absent: true };
369
464
  },
465
+ // The provider's live catalog hook (5.6): every live model with its own api, once an agent holds the provider's key.
466
+ 'byokit.catalog': (p) => ([...keys.values()].some((key) => key.provider === p.provider) ? { ok: true, models: [
467
+ { id: 'fake-key-model', name: 'Fake Key Model', api: 'openai-completions' },
468
+ { id: 'fake-live-model', name: 'Fake Live Model', api: 'openai-responses' }
469
+ ] } : { ok: false, reason: 'no-key' }),
370
470
  // Account sealing (5.15): the same five actions, branches and refusals the shipped plugin method serves.
371
471
  'byokit.accounts': (p) => {
372
472
  const action = String(p.action ?? '');
@@ -414,11 +514,21 @@ export function fakeGateway(script, o) {
414
514
  return { ok: true, profiles: Object.entries(store).map(([profileId, credential]) => ({ provider: credential.provider,
415
515
  profileId, type: credential.type, status: statusOf(credential, Date.now()), ...(whoOf(credential) ? { who: whoOf(credential) } : {}) })) };
416
516
  if (action === 'clear') {
417
- profiles.set(id, {});
418
- orders.set(id, {});
517
+ // Mirrors the plugin: a provider scope drops only that provider's profiles, order and lastGood.
518
+ const scope = typeof p.provider === 'string' && p.provider ? String(p.provider) : undefined;
519
+ if (!scope) {
520
+ profiles.set(id, {});
521
+ orders.set(id, {});
522
+ }
523
+ else {
524
+ for (const [profileId, credential] of Object.entries(store))
525
+ if (credential.provider === scope)
526
+ delete store[profileId];
527
+ delete order[scope];
528
+ }
419
529
  const cleared = agents.get(id);
420
530
  if (cleared)
421
- cleared.providers = [];
531
+ cleared.providers = scope ? cleared.providers.filter((x) => x !== scope) : [];
422
532
  return { ok: true };
423
533
  }
424
534
  if (action === 'ready') {
@@ -441,7 +551,8 @@ export function fakeGateway(script, o) {
441
551
  const profileId = primary.slice(at + 1);
442
552
  const credential = store[profileId];
443
553
  const account = ACCOUNT_AGENT.test(id);
444
- if (!credential || credential.provider !== p.provider || (!account && credential.type === 'api_key'))
554
+ if (!credential || credential.provider !== p.provider
555
+ || (!account && credential.type === 'api_key' && p.planKey !== true))
445
556
  return { ok: false };
446
557
  for (const [otherId, other] of Object.entries(store))
447
558
  if (account ? otherId !== profileId : other.provider === p.provider && otherId !== profileId)
@@ -466,17 +577,26 @@ export function fakeGateway(script, o) {
466
577
  'openclaw.setup.activate': (p) => {
467
578
  if (p.kind !== 'api-key')
468
579
  throw new Error('only the API-key setup path is simulated');
580
+ // The pin takes a pasted key only for a choice whose manifest allows one (`appGuidedSecret`).
469
581
  const route = routes().find((r) => r.choice === String(p.authChoice) && r.keyEntry);
470
- if (!route)
471
- return { ok: false, status: 'unavailable' };
582
+ if (!route || route.upstream?.flow === 'absent')
583
+ return { ok: false, status: 'unavailable', error: 'That key-based provider is not available on this Gateway.' };
472
584
  const model = `${route.provider}/fake-key-model`;
473
585
  keys.set(String(p.agentId), { provider: route.provider, key: String(p.apiKey), model, sealed: false });
474
- signInAgent(String(p.agentId), route.provider);
586
+ signInAgent(String(p.agentId), route.provider, { type: 'api_key', key: String(p.apiKey) });
587
+ setPrimaryOfId(String(p.agentId), `${model}@${route.provider}:default`);
475
588
  return { ok: true, status: 'ok', modelRef: model };
476
589
  },
477
590
  'openclaw.setup.auth.start': (p) => {
591
+ // The pin starts only an app-guided login; a key-entry choice is set up from its pasted key instead.
592
+ const route = routes().find((r) => r.choice === String(p.authChoice ?? ''));
593
+ if (!route || route.keyEntry || route.via === 'cli' || route.upstream?.flow === 'absent')
594
+ throw new Error('That provider setup is not available on this Gateway.');
595
+ // The pin's single setup admission: a second start while a session runs is refused.
596
+ if (wizards.size)
597
+ throw new Error('setup is already in progress');
478
598
  const sessionId = String(p.sessionId ?? `fake-${randomUUID()}`);
479
- wizards.set(sessionId, { agentId: String(p.agentId ?? 'main'), answered: false, pulls: 0 });
599
+ wizards.set(sessionId, { agentId: String(p.agentId ?? 'main'), authChoice: String(p.authChoice ?? ''), answered: false, pulls: 0 });
480
600
  return { sessionId, done: false, status: 'running' };
481
601
  },
482
602
  'wizard.next': (p) => {
@@ -488,11 +608,11 @@ export function fakeGateway(script, o) {
488
608
  return { done: false, step: PROGRESS_STEP };
489
609
  }
490
610
  if (!session.answered)
491
- return { done: false, step: DEVICE_STEP };
611
+ return { done: false, step: session.authChoice === 'openai' ? BROWSER_STEP : DEVICE_STEP };
492
612
  if (session.pulls++ === 0)
493
613
  return { done: false, step: PROGRESS_STEP };
494
614
  wizards.delete(String(p.sessionId));
495
- signInAgent(session.agentId, 'openai');
615
+ signInAgent(session.agentId, 'openai', signInFields);
496
616
  return { done: true, status: 'done', modelActivation: { modelRef: 'openai/gpt-5.1' } };
497
617
  },
498
618
  'wizard.cancel': (p) => {
@@ -510,6 +630,66 @@ export function fakeGateway(script, o) {
510
630
  run.waiters.push(resolve);
511
631
  }),
512
632
  'sessions.steer': () => ({}),
633
+ // The pin: a key the agent holds answers its canonical key; a missing one errors unless `allowMissing`, which answers `{ ok: false }`.
634
+ 'sessions.resolve': (p) => {
635
+ const key = String(p.key ?? ''), agentId = String(p.agentId ?? 'main');
636
+ if (sessions.has(`${agentId}\n${key}`))
637
+ return { ok: true, key, agentId };
638
+ if (p.allowMissing === true)
639
+ return { ok: false };
640
+ throw new Error(`No session found: ${key}`);
641
+ },
642
+ // The pin's rows for one agent (`search` matches a key substring), `hasActiveRun` while a run on it is live.
643
+ 'sessions.list': (p) => {
644
+ const agentId = String(p.agentId ?? 'main'), search = String(p.search ?? '').toLowerCase();
645
+ const rows = [...sessions].filter(([id]) => id.startsWith(`${agentId}\n`)).map(([id, s]) => ({ key: id.slice(agentId.length + 1), s }))
646
+ .filter(({ key }) => key.toLowerCase().includes(search))
647
+ .map(({ key, s }) => ({ key, sessionId: s.sessionId, updatedAt: s.updatedAt, hasActiveRun: activeOn(agentId, key),
648
+ ...(s.provider ? { modelProvider: s.provider } : {}), ...(s.model ? { model: s.model } : {}) }));
649
+ return { count: rows.length, sessions: rows };
650
+ },
651
+ // `fork` copies the parent's whole transcript, from any agent, into a new session id; refused while the parent runs.
652
+ 'sessions.create': (p) => {
653
+ const agentId = String(p.agentId ?? 'main'), key = String(p.key ?? '');
654
+ if (!agents.has(agentId))
655
+ throw new Error(`unknown agent id "${agentId}"`);
656
+ const rows = [];
657
+ let model = {};
658
+ if (p.fork === true) {
659
+ const parentKey = String(p.parentSessionKey ?? '');
660
+ const parentAgent = /^agent:([^:]+):/.exec(parentKey)?.[1] ?? 'main';
661
+ const parent = sessions.get(`${parentAgent}\n${parentKey}`);
662
+ if (!parent)
663
+ throw new Error(`No session found: ${parentKey}`);
664
+ if (activeOn(parentAgent, parentKey))
665
+ throw new Error(`Parent session ${parentKey} is still active; try again in a moment.`);
666
+ rows.push(...structuredClone(parent.rows));
667
+ model = { provider: parent.provider, model: parent.model };
668
+ }
669
+ const ref = typeof p.model === 'string' ? p.model : undefined;
670
+ if (ref)
671
+ model = ref.includes('/') ? { provider: ref.slice(0, ref.indexOf('/')), model: ref.slice(ref.indexOf('/') + 1) } : { ...model, model: ref };
672
+ const session = { sessionId: randomUUID(), updatedAt: Date.now(), rows, ...model };
673
+ sessions.set(`${agentId}\n${key}`, session);
674
+ return { ok: true, key, sessionId: session.sessionId, runStarted: false };
675
+ },
676
+ // A changed session (id or `updatedAt` other than expected) is refused before anything goes, as the pin does.
677
+ 'sessions.delete': (p) => {
678
+ const agentId = String(p.agentId ?? 'main'), key = String(p.key ?? '');
679
+ const session = sessions.get(`${agentId}\n${key}`);
680
+ if ((p.expectedSessionId !== undefined && session?.sessionId !== p.expectedSessionId)
681
+ || (p.expectedSessionUpdatedAt !== undefined && session?.updatedAt !== p.expectedSessionUpdatedAt))
682
+ throw new Error(`Session ${key} changed before deletion. Retry.`);
683
+ if (session && activeOn(agentId, key))
684
+ throw new Error(`Session ${key} is still active; try again.`);
685
+ return { ok: true, key, deleted: sessions.delete(`${agentId}\n${key}`), archived: [] };
686
+ },
687
+ 'chat.history': (p) => {
688
+ const key = String(p.sessionKey ?? '');
689
+ const agentId = String(p.agentId ?? /^agent:([^:]+):/.exec(key)?.[1] ?? 'main');
690
+ const session = sessions.get(`${agentId}\n${key}`);
691
+ return { sessionKey: key, ...(session ? { sessionId: session.sessionId } : {}), messages: structuredClone(session?.rows ?? []) };
692
+ },
513
693
  'chat.abort': (p) => {
514
694
  for (const run of runs.values()) {
515
695
  if (run.result || run.sessionKey !== String(p.sessionKey ?? ''))
@@ -664,6 +844,20 @@ export function fakeGateway(script, o) {
664
844
  failNext,
665
845
  drop,
666
846
  handle: (method, fn) => { handlers.set(method, fn); },
847
+ setUsage: (agentId, provider, windows) => {
848
+ let perAgent = usage.get(agentId);
849
+ if (!perAgent) {
850
+ perAgent = new Map();
851
+ usage.set(agentId, perAgent);
852
+ }
853
+ perAgent.set(provider, windows);
854
+ },
855
+ signInAs: (fields) => { signInFields = fields && { ...fields }; },
856
+ seedProfile: (agentId, profileId, profile) => {
857
+ ensureAgent(agentId);
858
+ storeOf(agentId)[profileId] = profile;
859
+ setPrimaryOfId(agentId, `${profile.provider}/default@${profileId}`);
860
+ },
667
861
  transport: factory({ port: 0, token: 'test', identityPath: '', bridgeSock: '' }),
668
862
  };
669
863
  }
@@ -16,6 +16,7 @@ export type StubCall = {
16
16
  authorization: string;
17
17
  path: string;
18
18
  body: StubRequest;
19
+ session?: string;
19
20
  };
20
21
  /** The token usage every stub reply reports when asked (`stream_options.include_usage`), per model call. */
21
22
  export declare const STUB_USAGE: {
@@ -77,7 +77,8 @@ export function startModelStub(script = [], o = {}) {
77
77
  res.writeHead(400).end();
78
78
  return;
79
79
  }
80
- const call = { authorization: String(req.headers.authorization ?? ''), path: req.url ?? '', body };
80
+ const call = { authorization: String(req.headers.authorization ?? ''), path: req.url ?? '', body,
81
+ ...(req.headers['x-opencode-session'] ? { session: String(req.headers['x-opencode-session']) } : {}) };
81
82
  calls.push(call);
82
83
  o.onCall?.(call); // Fixture evidence is persisted before any response/hold awaits.
83
84
  if (req.url === '/api/embed' || req.url === '/api/embeddings') {
package/dist/types.d.ts CHANGED
@@ -50,6 +50,8 @@ export interface RunSpec<S extends OutputSchema | undefined = OutputSchema | und
50
50
  }[];
51
51
  thinking?: 'off' | 'low' | 'medium' | 'high';
52
52
  model?: string;
53
+ account?: AccountRef | 'default' | 'auto';
54
+ needs?: string[];
53
55
  /** Explicit paid key choice. Uses a separate member-owned agent and separate session history. */
54
56
  auth?: 'apiKey';
55
57
  tools?: string[];
@@ -60,6 +62,14 @@ export type RunEvent = {
60
62
  } | {
61
63
  type: 'thinking';
62
64
  tokens: number;
65
+ } | {
66
+ type: 'account';
67
+ account: AccountId;
68
+ model: string;
69
+ sessionKey: string;
70
+ how: 'chosen' | 'default' | 'auto';
71
+ why: PickWhy;
72
+ considered: Considered[];
63
73
  } | {
64
74
  type: 'text';
65
75
  text: string;
@@ -98,6 +108,8 @@ export type RunEnd<T = unknown> = {
98
108
  data?: T;
99
109
  usage?: RunUsage;
100
110
  planWindow?: PlanWindow;
111
+ account?: AccountId;
112
+ model?: string;
101
113
  } | {
102
114
  ok: false;
103
115
  aborted: true;
@@ -319,6 +331,13 @@ export type AccountPick = {
319
331
  code: 'none' | 'not_included' | 'unknown_account' | 'bound' | 'paid';
320
332
  reason: string;
321
333
  considered: Considered[];
334
+ } | {
335
+ ok: false;
336
+ code: 'resting';
337
+ account: AccountId;
338
+ until?: number;
339
+ reason: string;
340
+ considered: Considered[];
322
341
  };
323
342
  export type MoveResult = {
324
343
  ok: true;
package/dist/words.json CHANGED
@@ -107,6 +107,13 @@
107
107
  "ago.hours": "{n} h",
108
108
  "account.bound": "This conversation uses {name}. Move it to switch accounts.",
109
109
  "account.paid": "This conversation uses {name}, which is billed per use. Choose {name} to keep going.",
110
+ "account.another": "another account",
110
111
  "account.signOutFirst": "To add a different {provider} account, sign out of {provider} in your browser first.",
111
- "account.legacyMember": "This person needs a new profile before adding more accounts."
112
+ "account.legacyMember": "This person needs a new profile before adding more accounts.",
113
+ "move.unsupported": "This conversation has not started yet, so there is nothing to move.",
114
+ "move.busy": "Wait for this conversation to finish before moving it.",
115
+ "move.notReady": "Sign in to {name} first.",
116
+ "move.closeFailed": "This conversation now continues on {name}, but its earlier copy could not be removed.",
117
+ "move.taken": "{name} already holds a conversation under this name, so nothing was moved.",
118
+ "run.moved": "This conversation just moved to another account. Send it again to continue there."
112
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -62,7 +62,7 @@
62
62
  "dependencies": {
63
63
  "@byokit/pair": "0.10.0",
64
64
  "@byokit/relay": "0.6.1",
65
- "@byokit/discover": "0.8.0",
65
+ "@byokit/discover": "0.8.1",
66
66
  "@byokit/seal": "0.3.0",
67
67
  "@byokit/secrets": "0.5.1",
68
68
  "@openclaw/gateway-client": "2026.8.35",
@@ -124,8 +124,10 @@ async function handle(params) {
124
124
  let sealed = false;
125
125
  const store = await updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(s) {
126
126
  const credential = s.profiles?.[profileId];
127
- // No such profile, or the named profile belongs to another provider; on a member agent an api_key is refused.
128
- if (!credential || credential.provider !== provider || (!account && credential.type === 'api_key')) return false;
127
+ // No such profile, or the named profile belongs to another provider; on a member agent an api_key is refused
128
+ // unless it is a plan key (subscription billing, never billed per use).
129
+ if (!credential || credential.provider !== provider
130
+ || (!account && credential.type === 'api_key' && params.planKey !== true)) return false;
129
131
  who = whoOf(credential);
130
132
  s.profiles = sealProfiles(s.profiles, provider, profileId, account);
131
133
  s.order = account ? { [provider]: [profileId] } : { ...(s.order ?? {}), [provider]: [profileId] };
@@ -137,8 +139,15 @@ async function handle(params) {
137
139
  }
138
140
 
139
141
  if (params.action === 'clear') {
142
+ // With a provider only that provider's profiles go: a member agent keeps its other providers' sign-ins.
143
+ const provider = params.provider;
144
+ const scoped = typeof provider === 'string' && provider.length > 0;
140
145
  const cleared = await updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(s) {
141
- s.profiles = {}; s.order = {}; delete s.lastGood; return true;
146
+ if (!scoped) { s.profiles = {}; s.order = {}; delete s.lastGood; return true; }
147
+ s.profiles = Object.fromEntries(Object.entries(s.profiles ?? {}).filter(([, p]) => p.provider !== provider));
148
+ if (s.order) delete s.order[provider];
149
+ if (object(s.lastGood)) delete s.lastGood[provider];
150
+ return true;
142
151
  } });
143
152
  return cleared ? { ok: true } : { ok: false };
144
153
  }
@@ -0,0 +1,48 @@
1
+ // byokit.catalog (5.6 live provider catalogs): the provider plugin's own live model list, with each model's api and
2
+ // baseUrl from its catalog data, so the kit can register models embedded runs cannot resolve otherwise. The engine's
3
+ // catalog loader stops at a registered list; the provider's catalog hook does not.
4
+ export function registerCatalog(api) {
5
+ api.registerGatewayMethod('byokit.catalog', async ({ params, respond }) => {
6
+ try {
7
+ const provider = String(params?.provider ?? '');
8
+ const { resolveAgentDir } = await import('openclaw/plugin-sdk/agent-runtime');
9
+ const { getRuntimeConfig } = await import('openclaw/plugin-sdk/config-runtime');
10
+ const { resolvePluginProviders } = await import('openclaw/plugin-sdk/provider-catalog-runtime');
11
+ const { resolveApiKeyForProvider } = await import('openclaw/plugin-sdk/provider-auth-runtime');
12
+ const config = getRuntimeConfig();
13
+ const hook = resolvePluginProviders({ config, env: process.env }).find((p) => p.id === provider)?.catalog;
14
+ if (!provider || typeof hook?.run !== 'function') return respond(true, { ok: false, reason: 'failed' });
15
+ // The first agent holding the provider's key; every account of one provider sees the same catalog.
16
+ let unreadable = false;
17
+ for (const agentId of new Set([...Object.keys(config.agents?.entries ?? {}), 'main'])) {
18
+ const agentDir = resolveAgentDir(config, agentId);
19
+ const apiKey = await resolveApiKeyForProvider({ provider, cfg: config, agentDir })
20
+ .then((a) => a?.apiKey, () => { unreadable = true; return undefined; });
21
+ if (!apiKey) continue;
22
+ const run = async (discoveryApiKey) => (await hook.run({ config, agentDir, env: process.env, providerIds: [provider],
23
+ resolveProviderApiKey: () => ({ apiKey, discoveryApiKey }),
24
+ resolveProviderAuth: () => ({ apiKey, discoveryApiKey, mode: 'api_key', source: 'profile' }) }))?.provider;
25
+ const live = await run(apiKey);
26
+ // Each api's endpoint comes from the pinned catalog (no discovery key): upstream now names endpoints the
27
+ // pinned engine does not recognize, and it sends Go's routing header only to ones it does.
28
+ // ponytail: drop when the engine pin recognizes the live endpoints.
29
+ const pinned = await run(undefined);
30
+ const endpoint = new Map();
31
+ for (const m of pinned?.models ?? []) {
32
+ const api = m.api ?? pinned.api;
33
+ if (!endpoint.has(api) && (m.baseUrl ?? pinned.baseUrl)) endpoint.set(api, m.baseUrl ?? pinned.baseUrl);
34
+ }
35
+ if (!live?.models?.length) return respond(true, { ok: false, reason: 'failed' });
36
+ // A pinned model the live list omits stays in the catalog; live wins by id.
37
+ const liveIds = new Set(live.models.map((m) => m.id));
38
+ const models = [...live.models, ...(pinned?.models ?? []).filter((m) => !liveIds.has(m.id))]
39
+ .map(({ provider: _p, apiKey: _k, headers: _h, ...model }) =>
40
+ (endpoint.has(model.api) ? { ...model, baseUrl: endpoint.get(model.api) } : model));
41
+ return respond(true, { ok: true, models });
42
+ }
43
+ respond(true, { ok: false, reason: unreadable ? 'failed' : 'no-key' });
44
+ } catch {
45
+ respond(true, { ok: false, reason: 'failed' }); // never engine text: a hook error could carry the key
46
+ }
47
+ }, { scope: 'operator.admin' });
48
+ }
package/plugin/index.js CHANGED
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
6
6
  import { registerKeys } from './keys.js';
7
7
  import { registerUsage } from './usage.js';
8
8
  import { registerAccounts } from './accounts.js';
9
+ import { registerCatalog } from './catalog.js';
9
10
 
10
11
  /** TypeBox's Kind is a global symbol, so these schemas are valid TypeBox without importing typebox. */
11
12
  const Kind = Symbol.for('TypeBox.Kind');
@@ -147,7 +148,7 @@ export default {
147
148
  jsonSchema: { type: 'object', additionalProperties: false, properties: {} },
148
149
  },
149
150
  register(api) {
150
- if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); registerAccounts(api); }
151
+ if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); registerAccounts(api); registerCatalog(api); }
151
152
  if (BROWSER) {
152
153
  if (typeof api.registerAgentToolResultMiddleware !== 'function') throw new Error('browser result protection unavailable');
153
154
  api.registerAgentToolResultMiddleware(async (event, ctx) => {
package/plugin/keys.js CHANGED
@@ -4,7 +4,7 @@ export function registerKeys(api) {
4
4
  api.registerGatewayMethod('byokit.keys', async ({ params, respond }) => {
5
5
  const member = params?.member;
6
6
  if (typeof member !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(member) || member.startsWith('byokit-key-')
7
- || !['prepare', 'seal', 'ready'].includes(params.action)) {
7
+ || !['prepare', 'seal', 'ready', 'clear'].includes(params.action)) {
8
8
  respond(true, { ok: false });
9
9
  return;
10
10
  }
@@ -27,7 +27,10 @@ export function registerKeys(api) {
27
27
  const marker = join(keyDir, 'byokit-key-ready.json');
28
28
  if (params.action === 'ready') {
29
29
  if (diagnostics) diagnostics.stage = 'marker';
30
- const selected = JSON.parse(await readFile(marker, 'utf8'));
30
+ let text;
31
+ try { text = await readFile(marker, 'utf8'); }
32
+ catch (error) { if (error?.code !== 'ENOENT') throw error; reply({ ok: false, absent: true }); return; }
33
+ const selected = JSON.parse(text);
31
34
  let ready = false;
32
35
  if (diagnostics) diagnostics.stage = 'store';
33
36
  await updateAuthProfileStoreWithLock({ agentDir: keyDir, updater(store) {
@@ -45,7 +48,6 @@ export function registerKeys(api) {
45
48
  reply({ ok: ready, ...(ready ? { model: selected.model } : {}) });
46
49
  return;
47
50
  }
48
- await rm(marker, { force: true });
49
51
  if (params.action === 'prepare') {
50
52
  // Only local profiles are loaded by this transaction: no shared-store read-through or CLI sync.
51
53
  const normal = await updateAuthProfileStoreWithLock({
@@ -66,6 +68,18 @@ export function registerKeys(api) {
66
68
  const cleared = await updateAuthProfileStoreWithLock({ agentDir: keyDir,
67
69
  saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
68
70
  updater(store) { store.profiles = {}; store.order = {}; return true; } });
71
+ // The marker goes only once the replacement passes its own checks: a refused prepare keeps it.
72
+ if (cleared) await rm(marker, { force: true });
73
+ respond(true, { ok: Boolean(cleared) });
74
+ return;
75
+ }
76
+ if (params.action === 'clear') {
77
+ // A failed add drops the key it activated; if this clear never reaches the engine, the unsealed key stays but is
78
+ // unusable until the next add's prepare or the next kit start's sweep.
79
+ const cleared = await updateAuthProfileStoreWithLock({ agentDir: keyDir,
80
+ saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
81
+ updater(store) { store.profiles = {}; store.order = {}; delete store.lastGood; return true; } });
82
+ if (cleared) await rm(marker, { force: true });
69
83
  respond(true, { ok: Boolean(cleared) });
70
84
  return;
71
85
  }
@@ -74,7 +88,11 @@ export function registerKeys(api) {
74
88
  const at = typeof primary === 'string' ? primary.lastIndexOf('@') : -1;
75
89
  if (at < 1) { respond(true, { ok: false }); return; }
76
90
  const profileId = primary.slice(at + 1);
77
- const model = primary.slice(0, at);
91
+ const model = params.model ?? primary.slice(0, at);
92
+ // The kit checks the route catalog before seal; a direct call still cannot cross the activated provider.
93
+ if (typeof model !== 'string' || !model || model.split('/')[0] !== primary.split('/')[0]) {
94
+ respond(true, { ok: false }); return;
95
+ }
78
96
  let found = false;
79
97
  const sealed = await updateAuthProfileStoreWithLock({ agentDir: keyDir,
80
98
  saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },