@byokit/openclaw 0.14.0 → 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,44 @@
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
+
21
+ ## 0.14.2 (2026-10-16)
22
+
23
+ - Dependency update: pins @byokit/relay 0.7.1.
24
+ - Dependency update: pins @byokit/accounts 0.24.1.
25
+
26
+ - FIX: `addKey` takes `orgId` for the Go live-catalog route and writes it into the engine's provider config (`models.providers['opencode-go'].headers['x-opencode-org-id']`) before the activation, so a multi-workspace login token is accepted — the engine's own probe and every run carry the header, and the first chat runs. The slot is provider-wide (shared by every member's key on one engine): a replacement key with no `orgId` keeps the standing workspace, `removeKey` drops it, and naming an `orgId` on a route with no workspace is refused `not_included`/`model` instead of silently dropped. Keys and workspace ids never reach logs, errors or views.
27
+
28
+ ## 0.14.1 (2026-10-16)
29
+
30
+ - Dependency update: pins @byokit/ui 0.8.0.
31
+ - Dependency update: pins @byokit/discover 0.8.2.
32
+ - Dependency update: pins @byokit/relay 0.7.0.
33
+ - Dependency update: pins @byokit/pair 0.10.1.
34
+ - Dependency update: pins @byokit/accounts 0.24.0.
35
+
36
+ - FIX: when the engine answers a sign-in's cancel `cancelled`, `done` now also waits (up to 10 s) for it to free its setup admission, so a sign-in started right after is admitted instead of ending `why: 'busy'`.
37
+ - FIX: The kit now resolves `stateDir` to one absolute path through no symlink before it uses it. A native Claude CLI sign-in (`claude-cli`, browser or phone) with a relative `stateDir`, or one under a symlinked folder such as macOS `/tmp` or `os.tmpdir()`, failed with "The app supplied invalid account options." Engine paths the kit reports (for example the bridge socket and `identityPath`) now use that resolved path.
38
+ - FIX: `removeKey` (and `signOut`, which calls it for that provider) also drops the lane's `<provider>.key` index entry, so `accounts()` no longer lists a removed key; a scoped removal keeps another provider's entry.
39
+ - FIX: `pick` builds its live room/models reads from the filtered pick rows, so an Auto pick for a member whose only other account is the unnamed key lane makes no `models.list`/`models.authStatus` calls for the lane it never consults.
40
+ - FIX: the key-lane docs now scope the "no `<member>--<hex6>` per later key" claim to the `addKey` path; a plan key added with `addAccount` when the member agent already holds that provider still mints one `<member>--<hex6>` agent per key. The Choosing spec names the kit-only lane filter.
41
+ - The README states platform support: Linux and macOS x64/arm64 supported, Windows unsupported. CI runs the offline suite on macOS.
42
+
5
43
  ## 0.14.0 (2026-10-15)
6
44
 
7
45
  - Dependency update: pins @byokit/accounts 0.23.0.
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
 
@@ -568,7 +575,8 @@ for await (const e of oc.run('Say hello.')) console.log(e);
568
575
 
569
576
  ## Isolation and billing
570
577
 
571
- The kit writes only under the `stateDir` the app passes. It spawns the engine with an explicit isolated environment
578
+ The kit writes only under the `stateDir` the app passes, resolved to one absolute path through no symlink (so a
579
+ relative path or macOS `os.tmpdir()` works, and paths the kit reports use the resolved form). It spawns the engine with an explicit isolated environment
572
580
  (never `process.env`), reads no environment variables except `PATH` to find `npm`, and never bills an API behind the
573
581
  person's back (memory search is never a paid provider).
574
582
 
@@ -685,6 +693,8 @@ retained-login cleanup and sealed approval limits.
685
693
 
686
694
  ## Status
687
695
 
696
+ Linux and macOS x64/arm64 are supported; Windows is unsupported.
697
+
688
698
  Pinned to OpenClaw `2026.8.35` (protocol 4); `ENGINE_VERSION` and `PROTOCOL_VERSION` carry the pin. Signatures are
689
699
  frozen (docs/runtime-kits.md §5).
690
700
 
package/dist/accounts.js CHANGED
@@ -10,11 +10,11 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
10
10
  import { randomBytes, randomUUID } from 'node:crypto';
11
11
  import { join } from 'node:path';
12
12
  import { DatabaseSync } from 'node:sqlite';
13
- import { providerOf, resolveSelection } from "./pick.js";
14
- import { agentOf, memberOf as baseMemberOf } from "./attribution.js";
15
- import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed } from "./members.js";
16
- import { routes, routeFor } from "./routes.js";
17
- import { words } from "./words.js";
13
+ import { providerOf, resolveSelection } from './pick.js';
14
+ import { agentOf, memberOf as baseMemberOf } from './attribution.js';
15
+ import { KEY_PREFIX, MEMBER_ID, NEW_MEMBER_ID, keyAgentId, untilListed, untilUnlisted } from './members.js';
16
+ import { routes, routeFor } from './routes.js';
17
+ import { words } from './words.js';
18
18
  const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
19
19
  // `models.list { agentId, view: 'default' }` rows of the account's provider, mapped to `provider/model`. The pin
20
20
  // reports no per-plan availability, so `why: 'plan'` only comes from a run ending `plan`, never from here.
@@ -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/approvals.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Native approvals: exec.approval.*, plugin.approval.* and question.* surfaced through the same Approval shape (5.9, O5).
2
2
  import { randomBytes } from 'node:crypto';
3
- import { MEMBER_ID } from "./members.js";
3
+ import { MEMBER_ID } from './members.js';
4
4
  const strip = (a) => {
5
5
  const { native, ...approval } = a;
6
6
  void native;
@@ -1,6 +1,6 @@
1
1
  import { statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { signedInProviders, USABLE } from "./runs.js";
3
+ import { signedInProviders, USABLE } from './runs.js';
4
4
  /** Native Claude login stays in Claude Code. The pin's profile status does not include this synthetic auth;
5
5
  * setup detection asks the CLI itself, without handing credentials to the kit. */
6
6
  export async function authStatus(request, agentId, refresh = false, native = false) {
@@ -7,8 +7,8 @@
7
7
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
- import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
11
- import { damagedAccountMembers, heldBackAgent, removedAccountAgents, sweepOrphans } from "./accounts.js";
10
+ import { EngineAlreadyRunningError, pidAlive as live } from './engine-status.js';
11
+ import { damagedAccountMembers, heldBackAgent, removedAccountAgents, sweepOrphans } from './accounts.js';
12
12
  const agentDirOf = (path) => path.startsWith('state/agents/') ? path.split('/')[2] : undefined;
13
13
  const encoder = new TextEncoder();
14
14
  const archive = (name) => !name.endsWith('.sealed') && (/^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
package/dist/bridge.js CHANGED
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
3
3
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
5
5
  import { rm } from 'node:fs/promises';
6
- import { words } from "./words.js";
6
+ import { words } from './words.js';
7
7
  /** Newline-framed JSON, one request per connection; anything larger is not a gate request. */
8
8
  const FRAME_CAP = 1_000_000;
9
9
  /**
@@ -1,8 +1,8 @@
1
1
  // Internal host runtime (5.17). Production handoff is deliberately unprotected until W7/O17 qualification.
2
2
  import { randomBytes } from 'node:crypto';
3
- import { BrowserStoreError, checkUrl, validOrigin } from "./store.js";
4
- import { validateVerifiers, verifySignIn } from "./verify.js";
5
- import { dispatchResume, newResume, recoverResume } from "./resume.js";
3
+ import { BrowserStoreError, checkUrl, validOrigin } from './store.js';
4
+ import { validateVerifiers, verifySignIn } from './verify.js';
5
+ import { dispatchResume, newResume, recoverResume } from './resume.js';
6
6
  export class SignInRefused extends Error {
7
7
  why;
8
8
  constructor(why) { super(`sign-in refused: ${why}`); this.why = why; }
@@ -1,4 +1,4 @@
1
- import { checkUrl, validOrigin } from "./store.js";
1
+ import { checkUrl, validOrigin } from './store.js';
2
2
  export function validateVerifiers(verifiers) {
3
3
  for (const v of verifiers) {
4
4
  validOrigin(v.origin);
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,6 +1,7 @@
1
1
  import { dirname, join } from 'node:path';
2
- import { routes } from "./routes.js";
3
- import { MEMBER_ID, KEY_PREFIX, BASE_AGENT, keyAgentId } from "./members.js";
2
+ import { routes } from './routes.js';
3
+ import { pluginPins } from './plugins.js';
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) => {
6
7
  for (const [k, v] of Object.entries(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;
@@ -1,4 +1,4 @@
1
1
  export declare const ENGINE_VERSION = "2026.8.35";
2
2
  export declare const PROTOCOL_VERSION = 4;
3
3
  export declare const LIVE_CATALOGS: string[];
4
- export declare const OPERATOR_SCOPES: readonly ["operator.read", "operator.write", "operator.admin", "operator.approvals", "operator.questions", "operator.pairing", "operator.talk"];
4
+ export declare const OPERATOR_SCOPES: readonly ['operator.read', 'operator.write', 'operator.admin', 'operator.approvals', 'operator.questions', 'operator.pairing', 'operator.talk'];
package/dist/day-usage.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Portable additive day reader. No host files, pricing guesses or side totals for persisted turns.
2
- import { agentUsageOf } from "./usage.js";
2
+ import { agentUsageOf } from './usage.js';
3
3
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
4
4
  const count = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
5
5
  const text = (v) => typeof v === 'string' && v.length > 0 && v.length <= 512;
package/dist/device.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { openBoxFromSeed, boxKeyPairFromSeed } from '@byokit/seal';
2
- import { memberOf } from "./attribution.js";
3
- import { toAccountView } from "./words.js";
4
- import { b64urlDecode, b64urlEncode, openNotice } from "./notices.js";
2
+ import { memberOf } from './attribution.js';
3
+ import { toAccountView } from './words.js';
4
+ import { b64urlDecode, b64urlEncode, openNotice } from './notices.js';
5
5
  /**
6
6
  * The host ended a stream with its own words (e.g. a refusal). Portable twin of link's PublicLinkError:
7
7
  * same shape ({ code: 'failed', sealed: true }) so a screen can show .message the same way. A device-side
@@ -418,8 +418,8 @@ export function openclawDevice(link) {
418
418
  };
419
419
  }
420
420
  // Portable, re-exported by ./device (7.3).
421
- export { openNotice } from "./notices.js";
422
- export { readAgentUsage, agentUsageOf } from "./usage.js";
423
- export { readAgentDayUsage } from "./day-usage.js";
421
+ export { openNotice } from './notices.js';
422
+ export { readAgentUsage, agentUsageOf } from './usage.js';
423
+ export { readAgentDayUsage } from './day-usage.js';
424
424
  // The kit's sentences, so a phone or browser shows the same words the computer does (5.14).
425
- export { stateWords, toAccountView, words } from "./words.js";
425
+ export { stateWords, toAccountView, words } from './words.js';
package/dist/engine.d.ts CHANGED
@@ -2,6 +2,9 @@ import { remoteCliHost } from '@byokit/accounts/cli';
2
2
  import { AuthStore, AuthStoreSealSizeError } from './auth-store.ts';
3
3
  import type { KitOptions } from './kit.ts';
4
4
  import type { KitState, ToolSpec } from './types.ts';
5
+ /** The app's stateDir as an absolute path with no symlinked ancestor (macOS /tmp and /var are symlinks):
6
+ * the native Claude login's accounts store refuses any other, so a relative or symlinked stateDir failed it. */
7
+ export declare function canonicalStateDir(dir: string): string;
5
8
  export type EngineOptions = Pick<KitOptions, 'stateDir' | 'engineDir' | 'npmPath' | 'enginePath' | 'config' | 'offered' | 'appOwnedSessions' | 'installPolicy' | 'log' | 'bridge' | 'authSeal'> & {
6
9
  pluginId: string;
7
10
  tools: ToolSpec[];
@@ -50,6 +53,13 @@ export declare class Engine {
50
53
  private freePort;
51
54
  prepare(): Promise<void>;
52
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
+ }>;
53
63
  doctorContext(): {
54
64
  entry: string;
55
65
  env: Record<string, string>;
package/dist/engine.js CHANGED
@@ -2,18 +2,31 @@ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { randomBytes, generateKeyPairSync, randomUUID } from 'node:crypto';
3
3
  import { accessSync, constants, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync, realpathSync, statSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
5
- import { basename, join, dirname } from 'node:path';
5
+ import { basename, join, dirname, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
8
  import { cliAccounts, remoteCliHost } from '@byokit/accounts/cli';
9
- import { AuthStore, AuthStoreUnreadableError, AuthStoreSealSizeError } from "./auth-store.js";
10
- import { EngineAlreadyRunningError, pidAlive, StartedProcesses } from "./engine-status.js";
11
- import { EnginePatchError, atomic, prepareEngineSet, processStartTime, readPatchSet, sha256, verifyEngineSet } from "./engine-patches.js";
12
- import { ENGINE_VERSION } from "./constants.js";
13
- import { appendUsageBoot } from "./usage-boots.js";
14
- import { reconcileConfig, appRecoveryPrefixes } from "./config.js";
15
- import { writePlugin, resolveBridge } from "./bridge.js";
16
- import { sweepOrphans } from "./accounts.js";
9
+ import { AuthStore, AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.js';
10
+ import { EngineAlreadyRunningError, pidAlive, StartedProcesses } from './engine-status.js';
11
+ import { EnginePatchError, atomic, prepareEngineSet, processStartTime, readPatchSet, sha256, verifyEngineSet } from './engine-patches.js';
12
+ import { ENGINE_VERSION } from './constants.js';
13
+ import { appendUsageBoot } from './usage-boots.js';
14
+ import { reconcileConfig, appRecoveryPrefixes } from './config.js';
15
+ import { writePlugin, resolveBridge } from './bridge.js';
16
+ import { sweepOrphans } from './accounts.js';
17
+ import { installedPlugins } from './plugins.js';
18
+ /** The app's stateDir as an absolute path with no symlinked ancestor (macOS /tmp and /var are symlinks):
19
+ * the native Claude login's accounts store refuses any other, so a relative or symlinked stateDir failed it. */
20
+ export function canonicalStateDir(dir) {
21
+ const path = resolve(dir);
22
+ try {
23
+ return realpathSync(path);
24
+ }
25
+ catch {
26
+ const parent = dirname(path);
27
+ return parent === path ? path : join(canonicalStateDir(parent), basename(path));
28
+ }
29
+ }
17
30
  const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
18
31
  const putOnce = (path, data) => { if (!existsSync(path))
19
32
  writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); };
@@ -95,6 +108,7 @@ export class Engine {
95
108
  appOwnedSessionPrefixes;
96
109
  constructor(o) {
97
110
  this.appOwnedSessionPrefixes = appRecoveryPrefixes(o.appOwnedSessions);
111
+ o = { ...o, stateDir: canonicalStateDir(o.stateDir) };
98
112
  this.o = o;
99
113
  const bridge = resolveBridge(o.bridge);
100
114
  this.root = join(o.stateDir, 'openclaw');
@@ -277,9 +291,30 @@ export class Engine {
277
291
  const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
278
292
  const config = reconcileConfig(saved, { root: this.root, stateDir: this.o.stateDir, port: this.port, pluginId: this.o.pluginId,
279
293
  pluginDir, policyPath: join(kitDir, 'policy', 'policy.mjs'), app: this.o.config, offered: this.o.offered,
280
- installPolicy: this.o.installPolicy, browser: this.o.browserConfig?.() });
294
+ installPolicy: this.o.installPolicy, browser: this.o.browserConfig?.(), plugins: installedPlugins(this.root) });
281
295
  putChanged(path, JSON.stringify(config, null, 2) + '\n');
282
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
+ }
283
318
  doctorContext() {
284
319
  const home = join(this.root, 'home');
285
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
@@ -1,10 +1,11 @@
1
1
  // @byokit/openclaw — the OpenClaw runtime kit, host side (docs/runtime-kits.md 5.3). Entries: `.` (this),
2
2
  // `./device` (portable), `./link` (Node host-side adapter), `./testing` (fakes and contract suite).
3
- export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from "./constants.js";
4
- export { OpenClawKit } from "./kit.js";
5
- export { stateWords, toAccountView, words } from "./words.js";
6
- export { chooseAccount, consider, resolveSelection } from "./pick.js";
7
- export { AuthStoreUnreadableError, AuthStoreSealSizeError } from "./auth-store.js";
8
- export { EngineAlreadyRunningError } from "./engine-status.js";
9
- export { readAgentDayUsage } from "./day-usage.js";
10
- export { readAgentUsage, agentUsageOf } from "./usage.js";
3
+ export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.js';
4
+ export { OpenClawKit } from './kit.js';
5
+ export { stateWords, toAccountView, words } from './words.js';
6
+ export { chooseAccount, consider, resolveSelection } from './pick.js';
7
+ export { AuthStoreUnreadableError, AuthStoreSealSizeError } from './auth-store.js';
8
+ export { EngineAlreadyRunningError } from './engine-status.js';
9
+ export { PluginInstallError } from './plugins.js';
10
+ export { readAgentDayUsage } from './day-usage.js';
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
  }>;
@@ -22,6 +31,9 @@ export declare function createKeys(ctx: ActivationCtx & {
22
31
  indexLane(member: Member, route: RouteView): Promise<void>;
23
32
  dropLane(member: Member, provider?: string): Promise<void>;
24
33
  backfillLane(member: Member, model: string | undefined): Promise<void>;
34
+ /** The provider config headers a live-catalog key's calls and probes must carry (`null` clears them),
35
+ * written through the kit's own config patch. The Go workspace header is the one user. */
36
+ setProviderHeaders(provider: string, headers: Record<string, string> | null): Promise<void>;
25
37
  }): {
26
38
  models: (member: Member, o: {
27
39
  authChoice: string;
@@ -31,6 +43,7 @@ export declare function createKeys(ctx: ActivationCtx & {
31
43
  authChoice: string;
32
44
  apiKey: string;
33
45
  model?: string;
46
+ orgId?: string;
34
47
  }): AddKeyHandle;
35
48
  ready(member: Member, o?: {
36
49
  once?: boolean;
package/dist/keys.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { join } from 'node:path';
2
- import { activate } from "./activation.js";
3
- import { keyAgentId, MEMBER_ID, KEY_PREFIX, untilListed } from "./members.js";
4
- import { providerOf } from "./pick.js";
5
- import { routes } from "./routes.js";
6
- import { modelsOf } from "./accounts.js";
7
- import { LIVE_CATALOGS } from "./constants.js";
2
+ import { openCodeWorkspaces } from '@byokit/accounts/opencode-go';
3
+ import { activate } from './activation.js';
4
+ import { keyAgentId, MEMBER_ID, KEY_PREFIX, untilListed } from './members.js';
5
+ import { providerOf } from './pick.js';
6
+ import { modelsOf } from './accounts.js';
7
+ import { LIVE_CATALOGS } from './constants.js';
8
8
  export function createKeys(ctx) {
9
9
  // Serialize key replacement and key runs, so an in-flight run never sees a half-installed replacement.
10
10
  const tails = new Map();
@@ -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,9 +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
+ // A workspace names a live-catalog login's own workspace only; another route takes no header, and a
90
+ // refusal here leaves the member's lanes untouched (it lands before any prepare or activation).
91
+ let orgId = typeof o.orgId === 'string' ? o.orgId.trim() : '';
92
+ if (orgId) {
93
+ if (orgId.length > 256)
94
+ return failure('invalid', 'auth');
95
+ if (!LIVE_CATALOGS.includes(route.provider))
96
+ return failure('not_included', 'model');
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
+ }
89
116
  if (ac.signal.aborted)
90
117
  return failure('retry', 'busy');
91
118
  return exclusive(member, async () => {
@@ -93,8 +120,13 @@ export function createKeys(ctx) {
93
120
  // A key the engine may have taken but not sealed is cleared (prepare already emptied the agent); if the clear
94
121
  // does not reach the engine, the key stays unsealed and unusable until the next key add or kit start, so runs
95
122
  // fail closed.
123
+ let wroteHeader = false;
96
124
  const abandon = async (result) => {
97
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);
98
130
  return result;
99
131
  };
100
132
  const cancelled = () => failure('retry', 'busy');
@@ -112,6 +144,18 @@ export function createKeys(ctx) {
112
144
  return cancelled();
113
145
  if (!(await control(member, 'prepare', route.provider)).ok)
114
146
  return failure('retry', 'engine');
147
+ // The workspace rides the provider's probe and every run from its config; it is written before the
148
+ // activation, so the probe itself carries it. The slot is provider-wide (shared by every member's
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) {
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
+ }
115
159
  // The public pin's typed API-key path, never a CLI-login import. Discard the engine's lines/errors.
116
160
  sent = true;
117
161
  if (ac.signal.aborted)
@@ -187,9 +231,13 @@ export function createKeys(ctx) {
187
231
  else if (ready.ok !== true || providerOf(ready.model ?? '') !== o.provider)
188
232
  return;
189
233
  }
234
+ // The lane's own provider, when the removal can name it: the header clear below is scoped like the removal.
190
235
  const cleared = await control(member, 'clear').catch(() => undefined);
191
236
  if (cleared?.ok !== true && cleared?.absent !== true)
192
237
  refused();
238
+ // A workspace header the removed key named must not outlive it; the next add names its own (or none).
239
+ if (o.provider !== undefined && LIVE_CATALOGS.includes(o.provider))
240
+ await ctx.setProviderHeaders(o.provider, null).catch(() => { throw new Error('The key could not be removed.'); });
193
241
  await ctx.dropLane(member, o.provider);
194
242
  });
195
243
  },
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. */
@@ -153,11 +163,15 @@ export declare class OpenClawKit {
153
163
  removeAccount(member: Member, id: AccountId): Promise<void>;
154
164
  defaults(member: Member): Promise<Defaults>;
155
165
  setDefaults(member: Member, d: Defaults): Promise<void>;
156
- /** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
166
+ /** Save an explicitly supplied API key (billed per use) in this member's separate key agent.
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`). */
157
170
  addKey(agentId: Member, o: {
158
171
  authChoice: string;
159
172
  apiKey: string;
160
173
  model?: string;
174
+ orgId?: string;
161
175
  }): AddKeyHandle;
162
176
  private indexKeyLane;
163
177
  /** Remove this member's saved key (the `byokit-key-<member>` lane: its credential store and ready marker), so