@byokit/openclaw 0.3.2 → 0.3.3

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,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.3 (2026-09-30)
6
+
7
+ - Dependency update: pins @byokit/secrets 0.5.0.
8
+
9
+ - SECURITY: Opt-in dual-wrap credential upgrades are authenticated and atomically replaced under the store lock; protection is only as strong as the owner-only host-key file, which must stay out of sealed-store backups.
10
+ - FIX: A locked or unresponsive keyring no longer throws from prepare or start. The kit reports a locked saved sign-in in plain words, preserves the sealed store and retries normally on a later start after unlock; dual-wrapped stores open through their host key without prompting.
11
+
12
+ - FIX: ChatGPT subscription device pairing was blocked by the plugin allowlist unless the app added the provider plugin itself. The kit now merges every offered route's provider plugin with the bridge and caller-provided plugins, without adding plugins for unoffered routes.
13
+ - Label the explicit Anthropic route as API key (billed per use), keeping API and plan routes off by default.
14
+
5
15
  ## 0.3.2 (2026-09-30)
6
16
 
7
17
  - Dependency update: pins @byokit/secrets 0.4.0.
package/README.md CHANGED
@@ -172,8 +172,16 @@ The pinned engine has no supported hook for sealing OAuth profile writes. Its `a
172
172
  credential JSON in agent SQLite databases and a shared state database, and doctor imports leave migration
173
173
  archives. `authSeal` therefore protects the complete isolated `state` and `home` directories, including SQLite
174
174
  journals. It uses the injected `SealingAdapter` from `@byokit/secrets`. `osKeyringSeal()` automatically
175
- falls back to a persistent private file key when no non-interactive keyring is available, including
176
- locked or unresponsive Linux keyrings; existing `authSeal` calls pick up the chain unchanged.
175
+ uses a persistent private file key for new stores when no non-interactive keyring is available.
176
+ Opening follows the saved envelope's mode. A locked or unresponsive keyring-only store leaves
177
+ `kit.state.phase === 'locked'`: `prepare()` and `start()` resolve, show `stateWords(kit.state)` for
178
+ recovery, and keep the sealed snapshot unchanged without launching the engine. Call `start()` again
179
+ after unlock to restore the same credentials, even if a fallback key directory now exists.
180
+
181
+ For hosts whose keyring stays locked, opt into `osKeyringSeal({ service, dualWrap: true })`.
182
+ While unlocked, reading an existing keyring-only snapshot atomically upgrades it to dual-wrap.
183
+ Later starts and stops can use the host wrap without prompting. Default off: **protection is only
184
+ as strong as the owner-only host-key file**, which must stay outside sealed-store backups.
177
185
 
178
186
  ```ts
179
187
  import { osKeyringSeal, hostKeySeal } from '@byokit/secrets';
@@ -187,7 +195,7 @@ await kit.prepare(); // seals an existing plaintext store and migration archives
187
195
  await kit.start(); // authenticates and restores the isolated files for the engine
188
196
  await kit.stop(); // waits for exit, verifies an atomic sealed snapshot, then removes plaintext
189
197
 
190
- // Inspect the adapter's mode for host UI: 'keyring' or 'host-key-file'.
198
+ // Inspect the adapter's write mode: 'keyring', 'host-key-file' or opt-in 'dual-wrap'.
191
199
  // Hosts can also explicitly supply a 32-byte key kept outside stateDir and its backups:
192
200
  declare const hostKey: Uint8Array;
193
201
  const server = new OpenClawKit({
@@ -200,7 +208,8 @@ The automatic key lives in the platform state directory, separate from the engin
200
208
  on the same disk protects copied stores/backups only when the key directory is excluded; it does
201
209
  not protect against code running as the same OS user. See [secrets' rotation and threat model](../secrets/README.md#servers-and-headless-node).
202
210
  Stop the kit, acquire the host writer lock and call `seal.rotate()` with `auth-store.sealed` plus
203
- every retained sealed migration archive before retiring an automatic key.
211
+ every retained sealed migration archive before retiring an automatic key. Rotate each envelope mode
212
+ separately; dual-wrap rotation retains old wrapping keys for backups.
204
213
 
205
214
  Without `authSeal`, engine credentials remain plaintext. With it, successful `prepare()` and `stop()` leave
206
215
  only a sealed snapshot, `auth-store.sealed`, for those directories. The adapter authenticates the snapshot
package/SECURITY.md CHANGED
@@ -23,6 +23,13 @@ running as the app's user, a compromised host/engine/plugin, or readable backups
23
23
  The isolated environment and tool gate are not an OS filesystem/network sandbox.
24
24
  The app must sandbox `ToolHost.call` and judge requests in `ToolHost.gate`.
25
25
 
26
+ A locked or unresponsive keyring leaves the sealed snapshot unchanged. `prepare()` and `start()`
27
+ resolve with `phase: 'locked'`, do not restore plaintext or launch the engine, and a later start
28
+ retries after unlock. Opt-in dual wrapping opens through an owner-only host key without prompting;
29
+ its protection is only as strong as that file, which must stay outside sealed-store backups.
30
+ Unlocked keyring-only snapshots upgrade atomically under the store's exclusive lock after verifying
31
+ the replacement decrypts to identical contents. Other sealing failures still reject.
32
+
26
33
  ## Secrets at rest and in memory
27
34
 
28
35
  | Asset | Location relative to `stateDir` | Protection and consequence of theft |
@@ -149,7 +149,17 @@ export class AuthStore {
149
149
  if (!existsSync(this.file))
150
150
  return undefined;
151
151
  regular(this.file);
152
- return snapshot(this.o.seal.decryptString(readFileSync(this.file)));
152
+ const seal = this.o.seal;
153
+ const bytes = readFileSync(this.file);
154
+ const text = seal.decryptString(bytes);
155
+ const saved = snapshot(text);
156
+ const upgraded = seal.upgrade?.(bytes);
157
+ if (upgraded) {
158
+ if (seal.decryptString(Buffer.from(upgraded)) !== text)
159
+ throw new Error('credential upgrade verification failed');
160
+ put(this.file, upgraded);
161
+ }
162
+ return saved;
153
163
  }
154
164
  collect() {
155
165
  const s = { v: 1, dirs: [], files: [] };
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { dirname, join } from 'node:path';
2
+ import { routes } from "./routes.js";
2
3
  const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
3
4
  const merge = (a, b) => {
4
5
  for (const [k, v] of Object.entries(b))
@@ -53,7 +54,8 @@ export function reconcileConfig(saved, o) {
53
54
  c.plugins ??= {};
54
55
  c.plugins.load ??= {};
55
56
  c.plugins.load.paths = [...new Set([...(c.plugins.load.paths ?? []).filter((p) => !p.includes('byokit-openclaw-bridge')), o.pluginDir])];
56
- c.plugins.allow = [...new Set([...(c.plugins.allow ?? []), o.pluginId])];
57
+ c.plugins.allow = [...new Set([...(c.plugins.allow ?? []), o.pluginId,
58
+ ...routes().filter(route => route.offer && route.plugin).map(route => route.plugin)])];
57
59
  c.plugins.entries ??= {};
58
60
  c.plugins.entries[o.pluginId] = merge(c.plugins.entries[o.pluginId] ?? {}, { hooks: { timeouts: { before_tool_call: 200_000 } } });
59
61
  c.security ??= {};
package/dist/engine.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare class Engine {
16
16
  private starting?;
17
17
  private readonly authStore;
18
18
  private stopping;
19
+ private credentialsLocked;
19
20
  private repaired;
20
21
  private prepared?;
21
22
  private port;
@@ -40,7 +41,8 @@ export declare class Engine {
40
41
  port: number;
41
42
  token: string;
42
43
  identityPath: string;
43
- }>;
44
+ } | undefined>;
45
+ private locked;
44
46
  private startOnce;
45
47
  private launch;
46
48
  withAuthStore<T>(task: () => Promise<T>): Promise<T>;
package/dist/engine.js CHANGED
@@ -52,6 +52,7 @@ export class Engine {
52
52
  starting;
53
53
  authStore;
54
54
  stopping = false;
55
+ credentialsLocked = false;
55
56
  repaired = false;
56
57
  prepared;
57
58
  port = 0;
@@ -94,7 +95,15 @@ export class Engine {
94
95
  throw new Error('authSeal required for sealed credential store');
95
96
  for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs'), this.dir])
96
97
  mkdirSync(d, { recursive: true, mode: 0o700 });
97
- await this.authStore.prepare();
98
+ try {
99
+ await this.authStore.prepare();
100
+ this.credentialsLocked = false;
101
+ }
102
+ catch (error) {
103
+ if (this.locked(error))
104
+ return;
105
+ throw error;
106
+ }
98
107
  if (this.o.spawnEngine) {
99
108
  const versionPath = join(this.dir, 'node_modules', 'openclaw', 'package.json');
100
109
  if (!existsSync(this.entry) || !installMatches(this.dir)) {
@@ -183,9 +192,25 @@ export class Engine {
183
192
  this.starting = undefined; }).catch(() => { });
184
193
  return pending;
185
194
  }
195
+ locked(error) {
196
+ if (!(error instanceof Error) || error.name !== 'KeystoreError' || !('code' in error) || error.code !== 'keyring-locked')
197
+ return false;
198
+ this.credentialsLocked = true;
199
+ this.state('locked');
200
+ return true;
201
+ }
186
202
  async startOnce() {
187
203
  await this.prepare();
188
- await this.authStore.start();
204
+ if (this.credentialsLocked)
205
+ return undefined;
206
+ try {
207
+ await this.authStore.start();
208
+ }
209
+ catch (error) {
210
+ if (this.locked(error))
211
+ return undefined;
212
+ throw error;
213
+ }
189
214
  try {
190
215
  return this.launch();
191
216
  }
@@ -275,7 +300,8 @@ export class Engine {
275
300
  throw new Error('engine did not stop; credential store still in use');
276
301
  this.child = undefined;
277
302
  rmSync(join(this.root, 'gateway.pid'), { force: true });
278
- await this.authStore.stop();
303
+ if (!this.credentialsLocked)
304
+ await this.authStore.stop();
279
305
  rmSync(this.bridgeSock, { force: true });
280
306
  this.state('stopped');
281
307
  }
package/dist/kit.js CHANGED
@@ -468,7 +468,7 @@ export class OpenClawKit {
468
468
  let transport;
469
469
  try {
470
470
  const ctx = await this.engine.start();
471
- if (this.stopping)
471
+ if (!ctx || this.stopping)
472
472
  return;
473
473
  this.setState({ phase: 'starting' });
474
474
  transport = (this.o.transport ?? gatewayTransport)({ ...ctx, bridgeSock: this.engine.bridgeSock });
package/dist/routes.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { Route } from './types.ts';
2
2
  /** The pin's auth-choice table. `provider` is the id a person's account is known by (the doc's matrix), which for
3
3
  * MiniMax's portal routes is the account-visible `minimax`, not the wizard's internal `minimax-portal`. `plugin` is
4
- * the bundled plugin the app must allow for that route to start (5.6). Only the pin's bundled provider contracts are
4
+ * the bundled plugin needed for that route to start (5.6); the kit allows offered routes, and the app allows any
5
+ * unoffered route it explicitly uses. Only the pin's bundled provider contracts are
5
6
  * listed: install-catalog plugins (external providers needing their own install) are not contracted by the pin and
6
7
  * are deliberately absent. */
7
8
  export declare function routes(): Route[];
package/dist/routes.js CHANGED
@@ -3,7 +3,8 @@ import routesJson from './routes.json' with { type: 'json' };
3
3
  const table = routesJson;
4
4
  /** The pin's auth-choice table. `provider` is the id a person's account is known by (the doc's matrix), which for
5
5
  * MiniMax's portal routes is the account-visible `minimax`, not the wizard's internal `minimax-portal`. `plugin` is
6
- * the bundled plugin the app must allow for that route to start (5.6). Only the pin's bundled provider contracts are
6
+ * the bundled plugin needed for that route to start (5.6); the kit allows offered routes, and the app allows any
7
+ * unoffered route it explicitly uses. Only the pin's bundled provider contracts are
7
8
  * listed: install-catalog plugins (external providers needing their own install) are not contracted by the pin and
8
9
  * are deliberately absent. */
9
10
  export function routes() {
package/dist/routes.json CHANGED
@@ -117,7 +117,7 @@
117
117
  "via": "browser",
118
118
  "prerequisite": "the Claude CLI, signed in inside the engine's own folder",
119
119
  "offer": false,
120
- "reason": "byokit never adds Claude plan sign-in",
120
+ "reason": "Requires an explicit app opt-in; the pinned Gateway does not expose this plan route",
121
121
  "source": "dist/extensions/anthropic/openclaw.plugin.json (2026.8.1)"
122
122
  },
123
123
  {
@@ -128,7 +128,7 @@
128
128
  "via": "browser",
129
129
  "prerequisite": null,
130
130
  "offer": false,
131
- "reason": "API billing, not a subscription; never offered (D12): no Anthropic API fallback.",
131
+ "reason": "API key (billed per use); shown only when an app explicitly asks.",
132
132
  "source": "dist/extensions/anthropic/openclaw.plugin.json (2026.8.1)"
133
133
  },
134
134
  {
@@ -348,7 +348,7 @@
348
348
  "via": "browser",
349
349
  "prerequisite": null,
350
350
  "offer": false,
351
- "reason": "byokit never adds Claude plan sign-in",
351
+ "reason": "Requires an explicit app opt-in; the pinned Gateway does not expose this plan route",
352
352
  "source": "dist/extensions/anthropic/openclaw.plugin.json (2026.8.1)"
353
353
  },
354
354
  {
package/dist/types.d.ts CHANGED
@@ -108,7 +108,7 @@ export type Decision = {
108
108
  answer?: unknown;
109
109
  };
110
110
  export type KitState = {
111
- phase: 'stopped' | 'installing' | 'starting' | 'repairing' | 'ready' | 'restarting' | 'failed' | 'needs-update';
111
+ phase: 'stopped' | 'installing' | 'starting' | 'repairing' | 'ready' | 'restarting' | 'failed' | 'needs-update' | 'locked';
112
112
  why?: 'install' | 'handshake' | 'exited' | 'port' | 'version';
113
113
  retryAt?: number;
114
114
  };
package/dist/words.js CHANGED
@@ -14,6 +14,7 @@ const PHASE_WORDS = {
14
14
  ready: 'engine.ready',
15
15
  restarting: 'engine.restarting',
16
16
  failed: 'engine.failed',
17
+ locked: 'engine.locked',
17
18
  'needs-update': 'engine.needsUpdate',
18
19
  };
19
20
  export function stateWords(s) {
package/dist/words.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "engine.installing": "Getting things ready on this computer. The first time takes a few minutes.",
3
3
  "engine.starting": "Starting up…",
4
4
  "engine.repairing": "Fixing a small problem with the setup. This takes a moment.",
5
+ "engine.locked": "Your saved sign-in is locked. Unlock your password storage, then try again.",
5
6
  "engine.ready": "Ready.",
6
7
  "engine.restarting": "Something stopped. Starting it again by itself.",
7
8
  "engine.failed": "This computer couldn't start the helper. Restart the app to try again.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",
@@ -56,7 +56,7 @@
56
56
  "@byokit/relay": "0.4.2",
57
57
  "@byokit/reach": "0.4.0",
58
58
  "@byokit/seal": "0.2.0",
59
- "@byokit/secrets": "0.4.0",
59
+ "@byokit/secrets": "0.5.0",
60
60
  "@openclaw/gateway-client": "2026.8.1",
61
61
  "@openclaw/gateway-protocol": "2026.8.1",
62
62
  "ws": "8.21.3"