@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.
@@ -117,6 +117,7 @@ export function fakeGateway(script, o) {
117
117
  const orders = new Map();
118
118
  const models = new Map();
119
119
  const wizards = new Map();
120
+ let signInFields;
120
121
  // Each workspace's files by name, as `agents.files.get/set` and the create-time IDENTITY.md rewrite see them.
121
122
  const workspaces = new Map();
122
123
  const filesOf = (agentId) => {
@@ -138,6 +139,20 @@ export function fakeGateway(script, o) {
138
139
  ...(content === undefined ? {} : { size: Buffer.byteLength(content), content }) } };
139
140
  };
140
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);
141
156
  // The engine's own cached read of each provider's usage endpoint (O14): cold until a `models.authStatus`
142
157
  // answer has carried the provider once (no `usage` on that first answer), then whatever `setUsage` stored.
143
158
  // A pre-sign-in read names no provider, so it warms nothing: the journey's first room read stays cold.
@@ -270,10 +285,14 @@ export function fakeGateway(script, o) {
270
285
  if (params.agentId != null && !agents.has(String(params.agentId)))
271
286
  throw new Error(`unknown agent: ${params.agentId}`);
272
287
  // Like the engine: the provider/model the run is called on, its own override or the member's (here openai).
273
- 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,
274
289
  provider: String(params.provider ?? 'openai'), model: String(params.model ?? 'gpt-5.1'), waiters: [] };
275
290
  runs.set(run.runId, run);
291
+ const session = sessionOf(run.agentId, run.sessionKey);
292
+ session.provider = run.provider;
293
+ session.model = run.model;
276
294
  const message = String(params.message ?? '');
295
+ append(session, { role: 'user', content: [{ type: 'text', text: message }] });
277
296
  const text = `fake: ${message}`;
278
297
  setTimeout(() => {
279
298
  void (async () => {
@@ -304,8 +323,11 @@ export function fakeGateway(script, o) {
304
323
  // Completion rides `phase: 'result'` like the real engine (O11), not `'end'`, with its result.
305
324
  emitRun(run, 'tool', { phase: 'result', name: call.name, toolCallId, isError,
306
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 }] });
307
328
  }
308
329
  emitRun(run, 'assistant', { text });
330
+ append(session, { role: 'assistant', content: [{ type: 'text', text }] });
309
331
  // The engine sums the run's usage (zero buckets omitted); the fake counts characters.
310
332
  run.usage = { input: message.length, output: text.length, total: message.length + text.length };
311
333
  setTimeout(() => finish(run, { status: 'ok', terminalReply: { text } }), ABORT_WINDOW_MS);
@@ -424,20 +446,27 @@ export function fakeGateway(script, o) {
424
446
  },
425
447
  'byokit.keys': (p) => {
426
448
  const id = `byokit-key-${p.member}`;
427
- if (p.action === 'prepare') {
449
+ if (p.action === 'prepare' || p.action === 'clear') {
428
450
  keys.delete(id);
429
451
  ensureAgent(id).providers = [];
430
452
  return { ok: true };
431
453
  }
432
454
  const key = keys.get(id);
433
455
  if (!key)
434
- return { ok: false };
456
+ return { ok: false, absent: true };
435
457
  if (p.action === 'seal') {
436
458
  key.sealed = true;
459
+ if (typeof p.model === 'string')
460
+ key.model = p.model;
437
461
  return { ok: true };
438
462
  }
439
- return { ok: key.sealed, ...(key.sealed ? { model: key.model } : {}) };
463
+ return key.sealed ? { ok: true, model: key.model } : { ok: false, absent: true };
440
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' }),
441
470
  // Account sealing (5.15): the same five actions, branches and refusals the shipped plugin method serves.
442
471
  'byokit.accounts': (p) => {
443
472
  const action = String(p.action ?? '');
@@ -485,11 +514,21 @@ export function fakeGateway(script, o) {
485
514
  return { ok: true, profiles: Object.entries(store).map(([profileId, credential]) => ({ provider: credential.provider,
486
515
  profileId, type: credential.type, status: statusOf(credential, Date.now()), ...(whoOf(credential) ? { who: whoOf(credential) } : {}) })) };
487
516
  if (action === 'clear') {
488
- profiles.set(id, {});
489
- 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
+ }
490
529
  const cleared = agents.get(id);
491
530
  if (cleared)
492
- cleared.providers = [];
531
+ cleared.providers = scope ? cleared.providers.filter((x) => x !== scope) : [];
493
532
  return { ok: true };
494
533
  }
495
534
  if (action === 'ready') {
@@ -512,7 +551,8 @@ export function fakeGateway(script, o) {
512
551
  const profileId = primary.slice(at + 1);
513
552
  const credential = store[profileId];
514
553
  const account = ACCOUNT_AGENT.test(id);
515
- 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))
516
556
  return { ok: false };
517
557
  for (const [otherId, other] of Object.entries(store))
518
558
  if (account ? otherId !== profileId : other.provider === p.provider && otherId !== profileId)
@@ -537,15 +577,21 @@ export function fakeGateway(script, o) {
537
577
  'openclaw.setup.activate': (p) => {
538
578
  if (p.kind !== 'api-key')
539
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`).
540
581
  const route = routes().find((r) => r.choice === String(p.authChoice) && r.keyEntry);
541
- if (!route)
542
- 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.' };
543
584
  const model = `${route.provider}/fake-key-model`;
544
585
  keys.set(String(p.agentId), { provider: route.provider, key: String(p.apiKey), model, sealed: false });
545
- 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`);
546
588
  return { ok: true, status: 'ok', modelRef: model };
547
589
  },
548
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.');
549
595
  // The pin's single setup admission: a second start while a session runs is refused.
550
596
  if (wizards.size)
551
597
  throw new Error('setup is already in progress');
@@ -566,7 +612,7 @@ export function fakeGateway(script, o) {
566
612
  if (session.pulls++ === 0)
567
613
  return { done: false, step: PROGRESS_STEP };
568
614
  wizards.delete(String(p.sessionId));
569
- signInAgent(session.agentId, 'openai');
615
+ signInAgent(session.agentId, 'openai', signInFields);
570
616
  return { done: true, status: 'done', modelActivation: { modelRef: 'openai/gpt-5.1' } };
571
617
  },
572
618
  'wizard.cancel': (p) => {
@@ -584,6 +630,66 @@ export function fakeGateway(script, o) {
584
630
  run.waiters.push(resolve);
585
631
  }),
586
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
+ },
587
693
  'chat.abort': (p) => {
588
694
  for (const run of runs.values()) {
589
695
  if (run.result || run.sessionKey !== String(p.sessionKey ?? ''))
@@ -746,6 +852,25 @@ export function fakeGateway(script, o) {
746
852
  }
747
853
  perAgent.set(provider, windows);
748
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
+ },
861
+ refresh: (agentId, provider, o) => {
862
+ const agent = ensureAgent(agentId);
863
+ if (!agent.providers.includes(provider))
864
+ agent.providers.push(provider);
865
+ const profileId = `${provider}:default`;
866
+ storeOf(agentId)[profileId] = { provider, type: 'oauth', status: 'ok', access: 'fake-access',
867
+ refresh: 'fake-refresh', expires: o?.expires ?? Date.now() + 30 * 86_400_000,
868
+ accountId: `fake-refresh-${agentId}` };
869
+ if (o?.order !== false)
870
+ orderOf(agentId)[provider] = [profileId];
871
+ else if (orderOf(agentId)[provider])
872
+ delete orderOf(agentId)[provider];
873
+ },
749
874
  transport: factory({ port: 0, token: 'test', identityPath: '', bridgeSock: '' }),
750
875
  };
751
876
  }
@@ -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/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.1",
3
+ "version": "0.13.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",
@@ -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 },