@byokit/openclaw 0.14.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.14.4 (2026-10-17)
6
+
7
+ - Dependency update: pins @byokit/relay 0.8.0.
8
+ - Dependency update: pins @byokit/pair 0.10.2.
9
+ - Dependency update: pins @byokit/accounts 0.24.3.
10
+
11
+ - FIX: `routes()` now describes the engine the kit actually pins. The generated table was still built from the 2026.8.1 pin while `ENGINE_VERSION` is 2026.8.35, so every row said `source: "... (2026.8.1)"` and `revision: "2026.8.1"`. `scripts/gen-routes.ts` pins 2026.8.35 (upstream commit from `engine/patches.json`), reads manifest directories only (the 2026.8.35 source tree keeps an `extensions/.npmignore` file), and a new public-API journey test fails whenever the table names anything but `ENGINE_VERSION`. All 96 rows re-verified against the 2026.8.35 manifests: the choice set is identical (nothing added or removed); every row now carries `(2026.8.35)`.
12
+
13
+ ## 0.14.3 (2026-10-17)
14
+
15
+ - Dependency update: pins @byokit/ui 0.9.0.
16
+ - Dependency update: pins @byokit/discover 0.8.3.
17
+ - Dependency update: pins @byokit/accounts 0.24.2.
18
+
19
+ - FIX: `addKey` for an OpenCode Go plan key with no `orgId` no longer saves a key whose every call is refused with `403 Workspace selection is required`. The add lists the key's workspaces first (`openCodeWorkspaces` from `@byokit/accounts/opencode-go`): one is used as the `x-opencode-org-id` header; several resolve the new `AddKeyResult` `{ status: 'needs_workspace', workspaces: [{ id, name }] }` for the app's pick (add again with `orgId`); a key the console refuses adds with no header. A listing that could not be read resolves `{ status: 'retry', reason: 'network' }` and changes nothing. An explicit `orgId` is used as before.
20
+
5
21
  ## 0.14.2 (2026-10-16)
6
22
 
7
23
  - Dependency update: pins @byokit/relay 0.7.1.
package/README.md CHANGED
@@ -157,6 +157,13 @@ register (`retry`). `network` is a timeout or transport failure; `inference` is
157
157
  probe failure, or a typed request-validation refusal; `engine` is a control or preparation failure; `busy` is a rate limit or a
158
158
  restarting engine. `not_included` covers an unsupported route, an unknown chosen model and a probe reporting the route unavailable;
159
159
  every other failure is `retry`. Migrate from the old string result with `result.status === 'ok'`, not `result === 'ok'`.
160
+ An OpenCode Go plan login can act for several workspaces, and the Go gateway refuses its every call until one is named
161
+ (`403 Workspace selection is required`). Pass the workspace as `orgId` when you have it; it rides the probe and every run as
162
+ `x-opencode-org-id`. Without `orgId` the add lists the key's workspaces first (the console listing `@byokit/accounts` uses):
163
+ one is used unasked; several resolve `{ status: 'needs_workspace', workspaces: [{ id, name }] }` with nothing saved, so show
164
+ the pick (the names) and add again with `orgId: '<picked id>'`; a key the console refuses (a workspace-bound service key) adds
165
+ with no workspace. A listing that could not be read (offline, a server error) resolves `{ status: 'retry', reason: 'network' }`
166
+ and changes nothing. Workspace ids are row ids, never secrets. Another route refuses an `orgId` with `not_included`/`model`.
160
167
  A member that is not your own throws. `retry` means the engine may have taken the key but it was not kept
161
168
  sealed (for example a gateway restart ran late); no usable key was kept, so add it again. Failures never contain keys, key fragments, headers or raw engine diagnostics. Label the option **API key (billed per use)**. Select only `routes()` rows with
162
169
  `keyEntry: true`; API rows remain `offer: false`. Each route also carries its engine `revision`, `checked` date,
@@ -381,7 +388,7 @@ streams, [`examples/openclaw-kit`](../../examples/openclaw-kit) for a whole app,
381
388
  Subscription sign-ins are offered by default. API-billed routes remain in `kit.routes()` with `offer: false`;
382
389
  apps can offer them when the app or person opts in, labelled API key (billed per use). Each `Route` carries `billing` (`subscription`, `api` or
383
390
  `local`) so the app can label what is billed. Proxy routes, compatibility aliases, local runtimes stay off. Native Claude Code sign-in is offered by default. A plan key (OpenCode Go: `kit.addAccount(member, { authChoice: 'opencode-go' }, on)`) asks for `Plan key`; `paste` it and the engine live-tests it before `done`. A paired device starts the same sign-in with either `via` and pastes the key over the sealed link, which keeps it on the host. A run reaches the pinned engine's static Go models (default `opencode-go/deepseek-v4-pro`); the kit keeps OpenCode Go's `x-opencode-session` header on every request, the setup probe included. A live-only Go model such as Muse needs no app config: the kit registers the plan's live catalog with each model's API, so `run({ model: 'opencode-go/muse-spark-1.3-contributor' })` runs on the account (or with `auth: 'apiKey'` on a Go key); an app that sets `config.models.providers['opencode-go'].models` keeps its own list. A choice the pinned Gateway cannot set up has readiness `no_upstream_flow` and is never offered. Pass the accounts the app offers in `KitOptions.offered` (the provider, auth choice or alias each route carries, e.g. `'openrouter'`); the kit allow-lists the plugin of every route those name, so an opted-in sign-in is not blocked. An app can still name a plugin directly in `config.plugins.allow`, and pass the route's `choice` explicitly to `kit.signIn()`.
384
- Offered provider plugins are allowed by default alongside the app's plugin ids.
391
+ Offered provider plugins are allowed by default alongside the app's plugin ids. A route whose provider plugin ships outside the engine (Z.AI's GLM Coding Plan, for one) has readiness `needs_plugin` until the app calls `await kit.installPlugin(route.needs.plugin)`: it installs that plugin into the kit's own engine at exactly the engine's version and pinned npm integrity, keeps it allowed across restarts, and rejects `PluginInstallError` (`code` `version` or `integrity`) with nothing installed on a mismatch. Discovery never installs.
385
392
 
386
393
  ## API at a glance
387
394
 
package/dist/accounts.js CHANGED
@@ -12,7 +12,7 @@ import { join } from 'node:path';
12
12
  import { DatabaseSync } from 'node:sqlite';
13
13
  import { providerOf, resolveSelection } from './pick.js';
14
14
  import { agentOf, memberOf as baseMemberOf } from './attribution.js';
15
- import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed } from './members.js';
15
+ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed, untilUnlisted } from './members.js';
16
16
  import { routes, routeFor } from './routes.js';
17
17
  import { words } from './words.js';
18
18
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -1007,6 +1007,9 @@ export function createAccounts(ctx) {
1007
1007
  if (listed.profiles.length)
1008
1008
  throw new Error('removeAccount: the account is still signing in');
1009
1009
  await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
1010
+ // The roster hot-reload applies a beat after the delete answer: wait until the agent is gone, so a
1011
+ // roster read right after the removal no longer sees it.
1012
+ await untilUnlisted(ctx.request, agent);
1010
1013
  }
1011
1014
  rmSync(agentDir(agent), { recursive: true, force: true });
1012
1015
  // Already holding the index lock: write directly instead of `edit`, which would wait on itself.
@@ -1027,6 +1030,9 @@ export function createAccounts(ctx) {
1027
1030
  if (created) {
1028
1031
  await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
1029
1032
  await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
1033
+ // The roster hot-reload applies a beat after the delete answer: `done` resolves only once the agent is
1034
+ // gone, so a roster read right after a cancelled sign-in no longer sees it.
1035
+ await untilUnlisted(ctx.request, agent);
1030
1036
  }
1031
1037
  rmSync(agentDir(agent), { recursive: true, force: true });
1032
1038
  await edit(member, (file) => { delete file.accounts[id]; });
package/dist/config.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare function reconcileConfig(saved: object | undefined, o: {
12
12
  app?: object;
13
13
  installPolicy?: KitOptions['installPolicy'];
14
14
  offered?: readonly string[];
15
+ plugins?: readonly string[];
15
16
  browser?: {
16
17
  profiles: Record<string, {
17
18
  cdpUrl: string;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from 'node:path';
2
2
  import { routes } from './routes.js';
3
+ import { pluginPins } from './plugins.js';
3
4
  import { MEMBER_ID, KEY_PREFIX, BASE_AGENT, keyAgentId } from './members.js';
4
5
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
5
6
  const merge = (a, b) => {
@@ -84,7 +85,9 @@ export function reconcileConfig(saved, o) {
84
85
  ...routes().filter(route => route.offerPolicy === 'default' && !route.needs?.plugin && route.plugin)
85
86
  .map(route => route.plugin),
86
87
  // Plus every explicitly offered route the app names, so an opted-in sign-in (OpenRouter) is not blocked.
87
- ...offeredPlugins(o.offered)])];
88
+ ...offeredPlugins(o.offered),
89
+ // Plus every external plugin the kit installed (B7): an app's own allow list must not disable it at the next boot.
90
+ ...o.plugins ?? []])];
88
91
  c.plugins.entries ??= {};
89
92
  c.plugins.entries[o.pluginId] = merge(c.plugins.entries[o.pluginId] ?? {}, { hooks: { timeouts: { before_tool_call: 200_000 } } });
90
93
  if (o.browser) {
@@ -112,6 +115,7 @@ export function reconcileConfig(saved, o) {
112
115
  passEnv: ['OPENCLAW_STATE_DIR'], env: {
113
116
  BYOKIT_TRUSTED_SKILLS: o.installPolicy?.trustedSkills ?? '',
114
117
  BYOKIT_OWN_ROOTS: JSON.stringify(o.installPolicy?.ownRoots ?? []),
118
+ BYOKIT_PLUGIN_PINS: JSON.stringify(Object.values(pluginPins())),
115
119
  },
116
120
  } };
117
121
  return c;
package/dist/engine.d.ts CHANGED
@@ -53,6 +53,13 @@ export declare class Engine {
53
53
  private freePort;
54
54
  prepare(): Promise<void>;
55
55
  private prepareOnce;
56
+ /** B7: the engine's own installer for one pinned external plugin, in the isolated engine env with the kit's npm
57
+ * first on PATH (the installer runs `npm`). The explicit kit call is the consent; the install policy holds the pin and
58
+ * its integrity. Resolves the installer's exit code and output tail; the kit then restarts the gateway to load it. */
59
+ installPlugin(spec: string): Promise<{
60
+ code: number | null;
61
+ output: string;
62
+ }>;
56
63
  doctorContext(): {
57
64
  entry: string;
58
65
  env: Record<string, string>;
package/dist/engine.js CHANGED
@@ -14,6 +14,7 @@ import { appendUsageBoot } from './usage-boots.js';
14
14
  import { reconcileConfig, appRecoveryPrefixes } from './config.js';
15
15
  import { writePlugin, resolveBridge } from './bridge.js';
16
16
  import { sweepOrphans } from './accounts.js';
17
+ import { installedPlugins } from './plugins.js';
17
18
  /** The app's stateDir as an absolute path with no symlinked ancestor (macOS /tmp and /var are symlinks):
18
19
  * the native Claude login's accounts store refuses any other, so a relative or symlinked stateDir failed it. */
19
20
  export function canonicalStateDir(dir) {
@@ -290,9 +291,30 @@ export class Engine {
290
291
  const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
291
292
  const config = reconcileConfig(saved, { root: this.root, stateDir: this.o.stateDir, port: this.port, pluginId: this.o.pluginId,
292
293
  pluginDir, policyPath: join(kitDir, 'policy', 'policy.mjs'), app: this.o.config, offered: this.o.offered,
293
- installPolicy: this.o.installPolicy, browser: this.o.browserConfig?.() });
294
+ installPolicy: this.o.installPolicy, browser: this.o.browserConfig?.(), plugins: installedPlugins(this.root) });
294
295
  putChanged(path, JSON.stringify(config, null, 2) + '\n');
295
296
  }
297
+ /** B7: the engine's own installer for one pinned external plugin, in the isolated engine env with the kit's npm
298
+ * first on PATH (the installer runs `npm`). The explicit kit call is the consent; the install policy holds the pin and
299
+ * its integrity. Resolves the installer's exit code and output tail; the kit then restarts the gateway to load it. */
300
+ installPlugin(spec) {
301
+ const { entry, env } = this.doctorContext();
302
+ const npm = this.o.npmPath && this.o.npmPath.includes('/') ? [dirname(this.o.npmPath)] : (process.env.PATH ?? '').split(':');
303
+ return new Promise((resolve, reject) => {
304
+ const child = spawn(process.execPath, [entry, 'plugins', 'install', spec, '--force', '--accept-capabilities'], {
305
+ cwd: env.HOME, env: { ...env, PATH: [...npm, env.PATH].join(':'), npm_config_cache: join(this.root, 'npm-cache') },
306
+ stdio: ['ignore', 'pipe', 'pipe'],
307
+ });
308
+ let output = '';
309
+ this.started.add(child);
310
+ const keep = (chunk) => { output = (output + chunk).slice(-2000); };
311
+ child.stdout.on('data', keep);
312
+ child.stderr.on('data', keep);
313
+ const timer = setTimeout(() => child.kill('SIGKILL'), 300_000);
314
+ child.once('error', error => { clearTimeout(timer); this.started.forget(child); reject(error); });
315
+ child.once('close', code => { clearTimeout(timer); this.started.forget(child); resolve({ code, output }); });
316
+ });
317
+ }
296
318
  doctorContext() {
297
319
  const home = join(this.root, 'home');
298
320
  return { entry: this.entry, env: {
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { stateWords, toAccountView, words, type AccountView, type WordKey } from
9
9
  export { chooseAccount, consider, resolveSelection, type RoomOf } from './pick.ts';
10
10
  export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.ts';
11
11
  export { EngineAlreadyRunningError } from './engine-status.ts';
12
+ export { PluginInstallError, type PluginInstallFailure } from './plugins.ts';
12
13
  export { readAgentDayUsage } from './day-usage.ts';
13
14
  export type { AgentDayUsage, DayUsageClient, EngineStartedCharge, EngineStartedKind } from './day-usage.ts';
14
15
  export { readAgentUsage, agentUsageOf } from './usage.ts';
package/dist/index.js CHANGED
@@ -6,5 +6,6 @@ export { stateWords, toAccountView, words } from './words.js';
6
6
  export { chooseAccount, consider, resolveSelection } from './pick.js';
7
7
  export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.js';
8
8
  export { EngineAlreadyRunningError } from './engine-status.js';
9
+ export { PluginInstallError } from './plugins.js';
9
10
  export { readAgentDayUsage } from './day-usage.js';
10
11
  export { readAgentUsage, agentUsageOf } from './usage.js';
package/dist/keys.d.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  import { type ActivationCtx } from './activation.ts';
2
- import { type RouteView } from './routes.ts';
2
+ import type { RouteView } from './routes.ts';
3
3
  import type { Member, ModelInfo } from './types.ts';
4
4
  export type KeyFailureReason = 'auth' | 'billing' | 'empty' | 'model' | 'network' | 'engine' | 'inference' | 'busy';
5
+ /** `needs_workspace`: the Go plan login acts for several workspaces (console orgs); show the pick and add again with
6
+ * its `orgId`. Nothing was saved or sent to the engine. Ids are row ids, never secrets. */
5
7
  export type AddKeyResult = {
6
8
  status: 'ok';
7
9
  } | {
8
10
  status: 'invalid' | 'not_included' | 'retry';
9
11
  reason: KeyFailureReason;
12
+ } | {
13
+ status: 'needs_workspace';
14
+ workspaces: {
15
+ id: string;
16
+ name: string;
17
+ }[];
10
18
  };
11
19
  /** The add resolves like a Promise; `cancel()` stops a pending add and clears any partial key (retry/busy). */
12
20
  export type AddKeyHandle = Promise<AddKeyResult> & {
@@ -14,6 +22,7 @@ export type AddKeyHandle = Promise<AddKeyResult> & {
14
22
  };
15
23
  export declare function createKeys(ctx: ActivationCtx & {
16
24
  root: string;
25
+ routes(): RouteView[];
17
26
  ensure(member: Member): Promise<{
18
27
  agentId: string;
19
28
  }>;
package/dist/keys.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { join } from 'node:path';
2
+ import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
2
3
  import { activate } from './activation.js';
3
4
  import { keyAgentId, MEMBER_ID, KEY_PREFIX, untilListed } from './members.js';
4
5
  import { providerOf } from './pick.js';
5
- import { routes } from './routes.js';
6
6
  import { modelsOf } from './accounts.js';
7
7
  import { LIVE_CATALOGS } from './constants.js';
8
8
  export function createKeys(ctx) {
@@ -60,7 +60,7 @@ export function createKeys(ctx) {
60
60
  const models = async (member, o) => {
61
61
  if (!check(member))
62
62
  throw new Error('Choose your own member.');
63
- const route = routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
63
+ const route = ctx.routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
64
64
  if (!route || !LIVE_CATALOGS.includes(route.provider))
65
65
  return [];
66
66
  await ctx.ensure(member);
@@ -83,18 +83,36 @@ export function createKeys(ctx) {
83
83
  throw new Error('Choose your own member.');
84
84
  if (!o.apiKey.trim())
85
85
  return failure('invalid', 'empty');
86
- const route = routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
86
+ const route = ctx.routes().find((r) => r.choice === o.authChoice && r.keyEntry && r.readiness === 'ready');
87
87
  if (!route || (o.model !== undefined && !LIVE_CATALOGS.includes(route.provider)))
88
88
  return failure('not_included', 'model');
89
89
  // A workspace names a live-catalog login's own workspace only; another route takes no header, and a
90
90
  // refusal here leaves the member's lanes untouched (it lands before any prepare or activation).
91
- const orgId = typeof o.orgId === 'string' ? o.orgId.trim() : '';
91
+ let orgId = typeof o.orgId === 'string' ? o.orgId.trim() : '';
92
92
  if (orgId) {
93
93
  if (orgId.length > 256)
94
94
  return failure('invalid', 'auth');
95
95
  if (!LIVE_CATALOGS.includes(route.provider))
96
96
  return failure('not_included', 'model');
97
97
  }
98
+ else if (route.provider === 'opencode-go') {
99
+ // The Go gateway refuses a login acting for several workspaces until one is named (403 Workspace selection
100
+ // is required), so an add without `orgId` lists the key's own (the console listing `@byokit/accounts` uses):
101
+ // one is used, several come back for the app's pick, and a key the console refuses (a workspace-bound
102
+ // service key) needs none. A listing that could not be read fails the add before anything changes.
103
+ let workspaces;
104
+ try {
105
+ workspaces = await openCodeWorkspaces(o.apiKey, (input, init) => fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, ac.signal]) : ac.signal }));
106
+ }
107
+ catch { // `workspaces_unread`: offline, timeout, a server error or a body that is no list
108
+ return ac.signal.aborted ? failure('retry', 'busy') : failure('retry', 'network');
109
+ }
110
+ if (ac.signal.aborted)
111
+ return failure('retry', 'busy');
112
+ if (workspaces.length > 1)
113
+ return { status: 'needs_workspace', workspaces };
114
+ orgId = workspaces[0]?.id ?? '';
115
+ }
98
116
  if (ac.signal.aborted)
99
117
  return failure('retry', 'busy');
100
118
  return exclusive(member, async () => {
@@ -102,8 +120,13 @@ export function createKeys(ctx) {
102
120
  // A key the engine may have taken but not sealed is cleared (prepare already emptied the agent); if the clear
103
121
  // does not reach the engine, the key stays unsealed and unusable until the next key add or kit start, so runs
104
122
  // fail closed.
123
+ let wroteHeader = false;
105
124
  const abandon = async (result) => {
106
125
  await control(member, 'clear').catch(() => undefined);
126
+ // A header this add wrote must not outlive its abandoned key; a clear that fails never throws past
127
+ // the abandon, like the key clear itself.
128
+ if (wroteHeader)
129
+ await ctx.setProviderHeaders(route.provider, null).catch(() => undefined);
107
130
  return result;
108
131
  };
109
132
  const cancelled = () => failure('retry', 'busy');
@@ -123,9 +146,16 @@ export function createKeys(ctx) {
123
146
  return failure('retry', 'engine');
124
147
  // The workspace rides the provider's probe and every run from its config; it is written before the
125
148
  // activation, so the probe itself carries it. The slot is provider-wide (shared by every member's
126
- // key on this engine), so a replacement with no `orgId` keeps the standing one and `removeKey` drops it.
127
- if (orgId)
149
+ // key on this engine), so a workspace-scoped add that names none clears the standing one before the
150
+ // probe: a bound service key never carries another workspace, and `removeKey` drops it.
151
+ if (orgId) {
128
152
  await ctx.setProviderHeaders(route.provider, { 'x-opencode-org-id': orgId });
153
+ wroteHeader = true;
154
+ }
155
+ else if (LIVE_CATALOGS.includes(route.provider)) {
156
+ await ctx.setProviderHeaders(route.provider, null);
157
+ wroteHeader = true;
158
+ }
129
159
  // The public pin's typed API-key path, never a CLI-login import. Discard the engine's lines/errors.
130
160
  sent = true;
131
161
  if (ac.signal.aborted)
package/dist/kit.d.ts CHANGED
@@ -41,6 +41,10 @@ export type KitOptions = {
41
41
  spawnEngine?: boolean;
42
42
  onState?: (s: KitState) => void;
43
43
  log?: (line: string) => void;
44
+ listWorkspaces?: (key: string, signal: AbortSignal) => Promise<{
45
+ id: string;
46
+ name: string;
47
+ }[]>;
44
48
  };
45
49
  export type RetainedLogin = {
46
50
  path: string;
@@ -56,6 +60,7 @@ export declare class OpenClawKit {
56
60
  private members?;
57
61
  private readonly locks;
58
62
  private readonly routeMap;
63
+ private installs;
59
64
  private accountsCtl?;
60
65
  private readonly bridge;
61
66
  private readonly keys;
@@ -110,6 +115,11 @@ export declare class OpenClawKit {
110
115
  }>;
111
116
  private signInCtx;
112
117
  routes(): RouteView[];
118
+ /** B7 (5.7): install one external provider plugin a `needs_plugin` route names into this kit's own engine, at its
119
+ * pin; resolves once the restarted gateway lists it enabled at that version. Discovery never installs. Serialized:
120
+ * concurrent installs would race the allow-list update and double-spawn the installer into one engine tree. */
121
+ installPlugin(pluginId: string): Promise<void>;
122
+ private runInstall;
113
123
  providerStatus(member: Member): Promise<string[] | undefined>;
114
124
  providers(member: Member): Promise<string[]>;
115
125
  /** A member sign-in to `provider`, or a ready key (5.8) whose saved model is that provider's. */
@@ -154,8 +164,9 @@ export declare class OpenClawKit {
154
164
  defaults(member: Member): Promise<Defaults>;
155
165
  setDefaults(member: Member, d: Defaults): Promise<void>;
156
166
  /** Save an explicitly supplied API key (billed per use) in this member's separate key agent.
157
- * `orgId` names the workspace a Go plan login acts for (the pick the app captured at connect);
158
- * it rides every probe and run as `x-opencode-org-id`, and replacing the key rewrites it. */
167
+ * `orgId` names the workspace a Go plan login acts for; it rides every probe and run as `x-opencode-org-id`,
168
+ * and replacing the key rewrites it. Without it a Go key's workspaces are listed: one is used, several resolve
169
+ * `needs_workspace` with the rows for the app's pick (add again with `orgId`). */
159
170
  addKey(agentId: Member, o: {
160
171
  authChoice: string;
161
172
  apiKey: string;
package/dist/kit.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { setTimeout as delay } from 'node:timers/promises';
@@ -20,6 +21,7 @@ import { authStatus, createAuthStatus } from './auth-status.js';
20
21
  import { outputSchema } from './output.js';
21
22
  import { createKeys } from './keys.js';
22
23
  import { routes as routeTable } from './routes.js';
24
+ import { installedPlugins, pinnedSpec, recordPlugin, PluginInstallError } from './plugins.js';
23
25
  import { providerOf } from './pick.js';
24
26
  import { signIn as startSignIn, signOut as engineSignOut } from './signin.js';
25
27
  import { reconcileConfig, browserToolPolicySafe, browserProfileAcknowledged, browserSessionMayRun, memoryLimited as configMemoryLimited, configKeyPath, readConfigKey, writeConfigKey } from './config.js';
@@ -436,6 +438,7 @@ export class OpenClawKit {
436
438
  members;
437
439
  locks = createLocks();
438
440
  routeMap = new Map();
441
+ installs = Promise.resolve();
439
442
  accountsCtl;
440
443
  // One bridge and one approval surface per kit: listeners and run registrations survive reconnects (B3).
441
444
  // Only the socket itself is per-connection. Approvals always reaches the live transport through this.request().
@@ -668,6 +671,7 @@ export class OpenClawKit {
668
671
  dropLane: (member, provider) => this.dropKeyLane(member, provider),
669
672
  backfillLane: (member, model) => this.backfillKeyLane(member, model),
670
673
  restarting: () => this.engineRestarting(),
674
+ routes: () => this.routes(),
671
675
  request: (method, params, options) => this.request()(method, params, options),
672
676
  ensure: (member) => this.ensureMember(member),
673
677
  authConfig: () => this.authSnapshot(),
@@ -961,10 +965,69 @@ export class OpenClawKit {
961
965
  onDisconnect: (fn) => { this.signInDisconnects.add(fn); return () => { this.signInDisconnects.delete(fn); }; },
962
966
  stopSignal: this.engine.stopSignal,
963
967
  authConfig: () => this.authSnapshot(),
964
- restarted: (authBefore, signal) => this.restarted(authBefore, signal) };
968
+ restarted: (authBefore, signal) => this.restarted(authBefore, signal),
969
+ setProviderHeaders: (provider, headers) => this.patchConfig({ models: { providers: { [provider]: { headers: headers ?? null } } } }),
970
+ listWorkspaces: (key, signal) => this.o.listWorkspaces
971
+ ? this.o.listWorkspaces(key, signal)
972
+ : openCodeWorkspaces(key, (input, init) => fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal })) };
965
973
  }
966
974
  routes() {
967
- return routeTable();
975
+ return routeTable({ plugins: installedPlugins(this.engine.root) });
976
+ }
977
+ /** B7 (5.7): install one external provider plugin a `needs_plugin` route names into this kit's own engine, at its
978
+ * pin; resolves once the restarted gateway lists it enabled at that version. Discovery never installs. Serialized:
979
+ * concurrent installs would race the allow-list update and double-spawn the installer into one engine tree. */
980
+ installPlugin(pluginId) {
981
+ const run = this.installs.then(() => this.runInstall(pluginId));
982
+ this.installs = run.catch(() => { });
983
+ return run;
984
+ }
985
+ async runInstall(pluginId) {
986
+ const pin = pinnedSpec(pluginId);
987
+ const version = pin.spec.slice(pin.spec.lastIndexOf('@') + 1);
988
+ const loaded = async () => {
989
+ const { plugins } = await this.request()('plugins.list', {});
990
+ return plugins.some((p) => p.id === pluginId && p.enabled && p.version === version);
991
+ };
992
+ if (!(await loaded())) {
993
+ // The installer enables a plugin only when the (restrictive once non-empty) allow list already names it.
994
+ const allow = (this.getConfigKey('plugins.allow') ?? []);
995
+ const added = !allow.includes(pluginId);
996
+ if (added)
997
+ this.setConfigKey('plugins.allow', [...allow, pluginId]);
998
+ let code;
999
+ let output;
1000
+ try {
1001
+ ({ code, output } = await this.engine.installPlugin(pin.spec));
1002
+ }
1003
+ catch (error) {
1004
+ if (added)
1005
+ this.setConfigKey('plugins.allow', allow);
1006
+ throw error;
1007
+ }
1008
+ if (code !== 0) {
1009
+ if (added)
1010
+ this.setConfigKey('plugins.allow', allow);
1011
+ if (output.includes('pinned plugin integrity mismatch'))
1012
+ throw new PluginInstallError('integrity', `${pin.spec} does not match its pinned integrity; nothing was installed.`);
1013
+ if (output.includes('not a pinned plugin version'))
1014
+ throw new PluginInstallError('version', `The registry does not serve ${pin.spec} as pinned; nothing was installed.`);
1015
+ const reason = output.trim().split('\n').filter((line) => line.trim()).slice(-3).join(' ');
1016
+ throw new PluginInstallError('install', `${pin.spec} did not install: ${reason || `the installer exited ${code}`}`);
1017
+ }
1018
+ }
1019
+ // The running gateway hot-reloads the installer's config write without loading the plugin (verified live on
1020
+ // 2026.8.35); a safe restart (after in-flight requests) loads it, and the kit reconnects. Reads fail while it is down.
1021
+ if (!(await loaded()))
1022
+ await this.request()('gateway.restart.request', { reason: `byokit installed plugin ${pluginId}` });
1023
+ const deadline = Date.now() + RESTART_MS;
1024
+ while (!(await loaded().catch(() => false))) {
1025
+ if (Date.now() >= deadline)
1026
+ throw new PluginInstallError('install', `${pin.spec} installed, but the engine did not load it.`);
1027
+ await delay(500);
1028
+ }
1029
+ // Readiness (routes(), the keys gates) tracks this record, so it names the plugin only once the engine lists it loaded.
1030
+ recordPlugin(this.engine.root, pluginId);
968
1031
  }
969
1032
  async providerStatus(member) {
970
1033
  const { agentId } = await this.ensureMember(member);
@@ -1070,8 +1133,9 @@ export class OpenClawKit {
1070
1133
  return this.accountsCtl.setDefaults(member, d);
1071
1134
  }
1072
1135
  /** Save an explicitly supplied API key (billed per use) in this member's separate key agent.
1073
- * `orgId` names the workspace a Go plan login acts for (the pick the app captured at connect);
1074
- * it rides every probe and run as `x-opencode-org-id`, and replacing the key rewrites it. */
1136
+ * `orgId` names the workspace a Go plan login acts for; it rides every probe and run as `x-opencode-org-id`,
1137
+ * and replacing the key rewrites it. Without it a Go key's workspaces are listed: one is used, several resolve
1138
+ * `needs_workspace` with the rows for the app's pick (add again with `orgId`). */
1075
1139
  addKey(agentId, o) {
1076
1140
  return this.keys.add(agentId, o);
1077
1141
  }
@@ -1098,7 +1162,7 @@ export class OpenClawKit {
1098
1162
  await this.accountsCtl.unkey(member, provider).catch((error) => this.o.log?.(`removeKey: the key lane entry is not dropped yet: ${error instanceof Error ? error.message : String(error)}`));
1099
1163
  }
1100
1164
  async backfillKeyLane(member, model) {
1101
- const found = model === undefined ? [] : routeTable().filter((r) => r.keyEntry && r.readiness === 'ready' && r.provider === providerOf(model));
1165
+ const found = model === undefined ? [] : this.routes().filter((r) => r.keyEntry && r.readiness === 'ready' && r.provider === providerOf(model));
1102
1166
  await this.accountsCtl?.backfill(member, found).catch((error) => this.o.log?.(`key lane ${member}: not indexed yet: ${error instanceof Error ? error.message : String(error)}`));
1103
1167
  }
1104
1168
  async prepareKeyOwner(agentId) {
@@ -1140,7 +1204,7 @@ export class OpenClawKit {
1140
1204
  return confirmLogin({ ...this.signInCtx(), seal: this.o.authSeal, log: this.o.log }, member, source);
1141
1205
  }
1142
1206
  // runs (5.8): the facade owns no run state; every call delegates to the O8 module
1143
- // with this kit's live transport, member table and bridge (firstmate clearance, O9).
1207
+ // with this kit's live transport, member table and bridge (spec item O9).
1144
1208
  runs() {
1145
1209
  return createRuns({
1146
1210
  request: (method, params, o) => this.request()(method, params, o),
@@ -1233,7 +1297,8 @@ export class OpenClawKit {
1233
1297
  const safe = reconcileConfig(response.config, { root: this.engine.root, stateDir: this.o.stateDir,
1234
1298
  port: Number(readFileSync(join(this.engine.root, 'port'), 'utf8')), pluginId: this.o.plugin?.id ?? 'byokit',
1235
1299
  pluginDir: join(this.engine.root, 'plugin'), policyPath: fileURLToPath(new URL('../policy/policy.mjs', import.meta.url)), app: patch,
1236
- offered: this.o.offered, installPolicy: this.o.installPolicy, ...(this.o.browser ? { browser: this.browserConfig() } : {}) });
1300
+ offered: this.o.offered, installPolicy: this.o.installPolicy, plugins: installedPlugins(this.engine.root),
1301
+ ...(this.o.browser ? { browser: this.browserConfig() } : {}) });
1237
1302
  if (this.o.browser && !browserToolPolicySafe(safe, this.toolNames()))
1238
1303
  throw new Error('browser tool policy refused');
1239
1304
  if (this.o.browser) {
package/dist/members.d.ts CHANGED
@@ -6,6 +6,9 @@ export declare const BASE_AGENT = "byokit-base";
6
6
  export declare const keyAgentId: (member: Member) => string;
7
7
  /** Resolves once agents.list shows `id`, which agents.create answers before. */
8
8
  export declare function untilListed(request: GatewayTransport['request'], id: string): Promise<void>;
9
+ /** Resolves once agents.list no longer shows `id`, which agents.delete answers before: the engine's roster
10
+ * hot-reload applies a beat after the delete answer, the same lag as after agents.create. */
11
+ export declare function untilUnlisted(request: GatewayTransport['request'], id: string): Promise<void>;
9
12
  export declare function createMembers(ctx: {
10
13
  request: GatewayTransport['request'];
11
14
  root: string;
package/dist/members.js CHANGED
@@ -23,6 +23,16 @@ export async function untilListed(request, id) {
23
23
  await new Promise((resolve) => setTimeout(resolve, VISIBLE_POLL_MS));
24
24
  }
25
25
  }
26
+ /** Resolves once agents.list no longer shows `id`, which agents.delete answers before: the engine's roster
27
+ * hot-reload applies a beat after the delete answer, the same lag as after agents.create. */
28
+ export async function untilUnlisted(request, id) {
29
+ const deadline = Date.now() + VISIBLE_TIMEOUT_MS;
30
+ while (await listed(request, id)) {
31
+ if (Date.now() >= deadline)
32
+ throw new Error(`agent ${id} is still listed by agents.list after agents.delete`);
33
+ await new Promise((resolve) => setTimeout(resolve, VISIBLE_POLL_MS));
34
+ }
35
+ }
26
36
  export function createMembers(ctx) {
27
37
  const cache = new Map();
28
38
  return { ensure(member) {
@@ -0,0 +1,17 @@
1
+ export type PluginInstallFailure = 'unknown_plugin' | 'version' | 'integrity' | 'install';
2
+ export declare class PluginInstallError extends Error {
3
+ readonly code: PluginInstallFailure;
4
+ constructor(code: PluginInstallFailure, message: string);
5
+ }
6
+ type Pin = {
7
+ spec: string;
8
+ integrity: string;
9
+ };
10
+ /** engine/plugins.json: `{ "<plugin id>": { spec: '<package>@<engine version>', integrity } }`. */
11
+ export declare const pluginPins: () => Record<string, Pin>;
12
+ /** The exact spec to install for a plugin a route needs: its `install.npmSpec` at the engine pin, and pinned so. */
13
+ export declare function pinnedSpec(pluginId: string): Pin;
14
+ /** The external plugins this kit installed into its own engine. */
15
+ export declare function installedPlugins(root: string): string[];
16
+ export declare function recordPlugin(root: string, pluginId: string): void;
17
+ export {};
@@ -0,0 +1,40 @@
1
+ // External provider plugins (5.7, B7): the pins the kit installs at, and the ids it installed. Installing is the
2
+ // kit's explicit call; discovery only reads the record.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { atomic } from './engine-patches.js';
7
+ import { ENGINE_VERSION } from './constants.js';
8
+ import { routes } from './routes.js';
9
+ export class PluginInstallError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = 'PluginInstallError';
14
+ this.code = code;
15
+ }
16
+ }
17
+ const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ /** engine/plugins.json: `{ "<plugin id>": { spec: '<package>@<engine version>', integrity } }`. */
19
+ export const pluginPins = () => JSON.parse(readFileSync(join(kitDir, 'engine', 'plugins.json'), 'utf8'));
20
+ /** The exact spec to install for a plugin a route needs: its `install.npmSpec` at the engine pin, and pinned so. */
21
+ export function pinnedSpec(pluginId) {
22
+ const npmSpec = routes().find(route => route.needs?.plugin === pluginId && route.install?.npmSpec)?.install?.npmSpec;
23
+ if (!npmSpec)
24
+ throw new PluginInstallError('unknown_plugin', `No route needs a plugin named ${pluginId}.`);
25
+ const pin = pluginPins()[pluginId];
26
+ if (pin?.spec !== `${npmSpec}@${ENGINE_VERSION}`)
27
+ throw new PluginInstallError('version', `${pluginId} has no pin for engine ${ENGINE_VERSION}.`);
28
+ return pin;
29
+ }
30
+ const recordOf = (root) => join(root, 'installed-plugins.json');
31
+ /** The external plugins this kit installed into its own engine. */
32
+ export function installedPlugins(root) {
33
+ const path = recordOf(root);
34
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : [];
35
+ }
36
+ export function recordPlugin(root, pluginId) {
37
+ const installed = installedPlugins(root);
38
+ if (!installed.includes(pluginId))
39
+ atomic(recordOf(root), JSON.stringify([...installed, pluginId]) + '\n');
40
+ }