@byokit/openclaw 0.16.1 → 0.16.2

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,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.16.2 (2026-10-18)
6
+
7
+ - Dependency update: pins @byokit/accounts 0.27.1.
8
+ - FIX: (from @byokit/accounts 0.27.1) classifyFailure returns needs_workspace for an OpenCode Go key that acts for several workspaces, so the app can ask the person to pick one instead of showing a run error.
9
+
10
+ - FIX: A first start that misses its gateway handshake recovers by itself. A handshake timeout, or an engine exit before hello, now moves the state to `restarting` with `retryAt` on the same backoff as a reconnect after `ready` (1 s, 2 s, 4 s, capped at 30 s), then starts the engine again; the fifth missed attempt moves to `failed` with why `handshake`, as before. Each `start()` call has its own five attempts and resolves once the kit is ready and rejects once it has given up or is stopped, `stop()` cancels its pending retry, and the new `KitOptions.handshakeMs` (default 90 000) sets the hello deadline per attempt.
11
+
5
12
  ## 0.16.1 (2026-10-17)
6
13
 
7
14
  - Dependency update: pins @byokit/accounts 0.27.0.
package/README.md CHANGED
@@ -387,7 +387,7 @@ streams, [`examples/openclaw-kit`](../../examples/openclaw-kit) for a whole app,
387
387
 
388
388
  Subscription sign-ins are offered by default. API-billed routes remain in `kit.routes()` with `offer: false`;
389
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
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()`.
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. The engine picks the installed Claude login when it starts; a person who signs in to Claude on that machine after the engine started restarts the engine to use it. 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()`.
391
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.
392
392
 
393
393
  ## API at a glance
@@ -518,7 +518,8 @@ itself — no manual file surgery:
518
518
  `cleanup`/`restoring` transitions are completed from the authenticated snapshot; empty live trees are treated as
519
519
  debris.
520
520
  - An engine exit code 78 means the engine asked for repair: the kit's own repair path runs
521
- `doctor --fix --yes` with `doctorContext()`'s exact env.
521
+ `doctor --fix --yes` with `doctorContext()`'s exact env. Call `doctorContext()` after `prepare()` or `start()`: its
522
+ `CLAUDE_CONFIG_DIR` reflects the Claude login the engine chose there, and before that it reflects only the installed login.
522
523
 
523
524
  What a killed home leaves behind: live plaintext `state/` and `home/` (never resealed), `auth-store.lock/` with a
524
525
  dead owner pid, `gateway.pid` and `gateway.identity` (a still-live orphan Gateway is the dangerous case),
@@ -600,6 +601,17 @@ const oc = openclawDevice(link);
600
601
  for await (const e of oc.run('Say hello.')) console.log(e);
601
602
  ```
602
603
 
604
+ ## First start that misses its handshake
605
+
606
+ `start()` waits up to `KitOptions.handshakeMs` (default 90 000) for the gateway's hello on each attempt. A timeout, or an
607
+ engine exit before hello (except an over-cap auth-store seal refusal, which fails at once with `auth-store-seal-size`), moves the state to `restarting` with `retryAt` on the same backoff as a reconnect after
608
+ `ready` (1 s, 2 s, 4 s, capped at 30 s), then starts the engine again. A transport that closes before hello keeps
609
+ retrying inside the transport, so it ends as the timeout; a refused connect (a bad token) fails at once. The fifth
610
+ missed attempt moves the state to `{ phase: 'failed', why: 'handshake' }` and `start()` rejects. Each `start()` call
611
+ has its own five attempts, and `stop()` cancels its pending retry and makes that `start()` reject. Show `stateWords(kit.state)` while the state is
612
+ `restarting`; the tests pass a short `handshakeMs`. A timeout that fires while the engine is repairing (exit 78) waits for the
613
+ repair to end before the engine is stopped and retried.
614
+
603
615
  ## Isolation and billing
604
616
 
605
617
  The kit writes only under the `stateDir` the app passes, resolved to one absolute path through no symlink (so a
@@ -713,7 +725,8 @@ compromised engine/plugin, memory dump, or stolen host key can read credentials.
713
725
  against replacement with an older authentic snapshot. Gateway/device keys, inline config secrets, logs, engine
714
726
  install/cache files and app workspaces are outside this adapter's sealing scope; protect them separately.
715
727
  `doctorContext()` is an advanced escape hatch: launching an external doctor bypasses this lifecycle; use the
716
- kit's migration method for sealed stores.
728
+ kit's migration method for sealed stores. Call it after `prepare()` or `start()`, because its Claude login folder
729
+ is the one chosen there; before that it reflects only the installed login.
717
730
 
718
731
  See [SECURITY.md](SECURITY.md) for the credential and approval threat model, private-directory requirements,
719
732
  retained-login cleanup and sealed approval limits.
@@ -735,4 +748,4 @@ frozen (docs/runtime-kits.md §5).
735
748
 
736
749
  Apache-2.0. See [LICENSE](LICENSE); the bundled engine's MIT text is [engine/OPENCLAW-LICENSE](engine/OPENCLAW-LICENSE).
737
750
 
738
- Claude: `anthropic-cli` uses your own unmodified Claude Code login on this machine (provider `claude-cli`, plugin `anthropic`; subscription billing; `kit.signedIn(member, 'claude-cli')` is true once Claude Code is signed in, and `'anthropic'` stays the API-billed provider a Claude Code login never satisfies. Before 0.7.0 this route reported provider `anthropic`; that value is deprecated, kept as `deprecatedProvider` until 0.9.0), with login kept in Claude Code under the kit’s isolated `HOME=<stateDir>/openclaw/home` and `CLAUDE_CONFIG_DIR=<stateDir>/openclaw/home/.claude`; `kit.signIn(member, { authChoice: 'anthropic-cli' }, on)` starts `claude auth login --claudeai` with that `CLAUDE_CONFIG_DIR` (in its own login HOME) when it is signed out. The waiting view shows the native sign-in address and asks for the returned code; send it through the same handle's `paste`, or `openclawDevice(link).signIn.paste('claude-cli', code)` on the phone. Cancel and expiry stop only this login. The `setup.activate` call is the one commit point: a phone cancel or engine shutdown before it (even with a ready native row) declines with nothing activated; after it a cancel changes nothing, and the result is `done` once the engine restart is confirmed, or says the sign-in was saved but the engine did not restart in time. Engine shutdown also ends that restart wait. An already-ready native row can be detected and activated without a new login. If engine detection disagrees, the stale ready Claude roster row is cleared through Accounts, which deletes that folder's Claude login files (`.credentials.json`, `.claude.json`) but keeps the folder, and one fresh login is started. Pass the installed Claude binary's directory in `enginePath`. For remote ChatGPT use `openai-device-code` with `via: 'code'`; it needs no localhost callback on the phone. Native sign-out is through Claude Code. `apiKey` is explicit API billing (“API key (billed per use)”, `offer: false`): `signIn` asks for the key through `paste`, then engine activation verifies it. The `setup-token` choice is listed but never offered: the pinned Gateway has no app-guided sign-in for it. The kit never imports Claude credentials or implements direct Claude.ai OAuth; [Anthropic’s terms](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) apply.
751
+ Claude: `anthropic-cli` uses your own unmodified Claude Code login on this machine (provider `claude-cli`, plugin `anthropic`; subscription billing; `kit.signedIn(member, 'claude-cli')` is true once Claude Code is signed in, and `'anthropic'` stays the API-billed provider a Claude Code login never satisfies. Before 0.7.0 this route reported provider `anthropic`; that value is deprecated, kept as `deprecatedProvider` until 0.9.0), and the engine uses the installed Claude Code’s own login as is: it runs with your `HOME` (and your own `CLAUDE_CONFIG_DIR` when you set one), so a signed-in Claude Code needs no sign-in step. Only when the installed `claude auth status --json` is not signed in does the engine use its own fallback folder `CLAUDE_CONFIG_DIR=<stateDir>/openclaw/home/.claude`; `kit.signIn(member, { authChoice: 'anthropic-cli' }, on)` then starts `claude auth login --claudeai` with that `CLAUDE_CONFIG_DIR` (in its own login HOME). When the status check gave no answer (a timeout, say), the engine keeps your own login location, and a sign-in it does not detect fails at once with “Claude Code is installed here, but its sign-in could not be checked. Run claude once on this machine to sign in, then try again.” Codex and the engine’s other agent CLIs likewise see your `HOME` and your own `CODEX_HOME`, never a kit-owned one. The gateway and doctor run with your `HOME` too; it is not an access boundary, since engine processes run with your account’s own rights. The waiting view shows the native sign-in address and asks for the returned code; send it through the same handle's `paste`, or `openclawDevice(link).signIn.paste('claude-cli', code)` on the phone. Cancel and expiry stop only this login. The `setup.activate` call is the one commit point: a phone cancel or engine shutdown before it (even with a ready native row) declines with nothing activated; after it a cancel changes nothing, and the result is `done` once the engine restart is confirmed, or says the sign-in was saved but the engine did not restart in time. Engine shutdown also ends that restart wait. An already-ready native row can be detected and activated without a new login. If engine detection disagrees, the stale ready Claude roster row is cleared through Accounts, which deletes that folder's Claude login files (`.credentials.json`, `.claude.json`) but keeps the folder, and one fresh login is started. Pass the installed Claude binary's directory in `enginePath`. For remote ChatGPT use `openai-device-code` with `via: 'code'`; it needs no localhost callback on the phone. Native sign-out is through Claude Code. `apiKey` is explicit API billing (“API key (billed per use)”, `offer: false`): `signIn` asks for the key through `paste`, then engine activation verifies it. The `setup-token` choice is listed but never offered: the pinned Gateway has no app-guided sign-in for it. The kit never imports Claude credentials or implements direct Claude.ai OAuth; [Anthropic’s terms](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) apply.
package/SECURITY.md CHANGED
@@ -87,9 +87,16 @@ already copied credentials or erase snapshots/backups.
87
87
 
88
88
  ## Engine isolation and installation
89
89
 
90
- `src/engine.ts` builds the gateway/doctor environment explicitly: HOME, XDG,
91
- provider-tool homes, tmp, config and state paths point into the app's tree. It does
92
- not forward shell API keys, `NODE_OPTIONS` or shell environment snapshots. The native
90
+ `src/engine.ts` builds the gateway/doctor environment explicitly: the engine's own
91
+ home (`OPENCLAW_HOME`), cache, tmp, config and state paths point into the app's tree,
92
+ while `HOME` (and the person's own `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`,
93
+ `CLAUDE_CONFIG_DIR` and `CODEX_HOME` when set) is the person's for every engine process
94
+ (gateway, doctor and the agent CLIs), so the installed, signed-in logins are used as is. HOME is not an
95
+ access boundary: the engine runs with the person's own account rights and can open any path that account
96
+ can, including other CLIs' credentials and `~/.ssh`. Engine npm runs use the engine's own npm cache and config
97
+ (`npm_config_cache`, `npm_config_userconfig`), not the person's `~/.npm` or `~/.npmrc`. It does not forward shell API keys, `NODE_OPTIONS` or shell environment
98
+ snapshots. Only when the installed `claude auth status --json` is not signed in does the
99
+ gateway get `CLAUDE_CONFIG_DIR` at the engine's fallback folder. The native fallback
93
100
  Claude sign-in child (`claude auth login`, started through `@byokit/accounts/cli`) gets
94
101
  the same treatment: its env is only `PATH`, its own login HOME
95
102
  (`openclaw/login-home`), `LANG`, `TMPDIR` and the `XDG_*` variables — never the gateway
package/dist/engine.d.ts CHANGED
@@ -37,6 +37,7 @@ export declare class Engine {
37
37
  private claudeAccounts?;
38
38
  private claudeReset?;
39
39
  private credentialsLocked;
40
+ private claudeFallback;
40
41
  private repaired;
41
42
  private prepared?;
42
43
  patchSet?: string | null;
@@ -60,13 +61,20 @@ export declare class Engine {
60
61
  code: number | null;
61
62
  output: string;
62
63
  }>;
64
+ private get path();
65
+ private get claudeFolder();
63
66
  doctorContext(): {
64
67
  entry: string;
65
68
  env: Record<string, string>;
66
69
  };
67
70
  /** The engine's own shutdown signal; aborted by stop() and replaced by the next start(). */
68
71
  get stopSignal(): AbortSignal;
72
+ private installedClaudeSignedOut;
73
+ /** The fallback Claude sign-in for a machine whose installed Claude Code is signed out: it signs into the engine's folder. */
69
74
  nativeClaudeLogin(): ReturnType<typeof remoteCliHost>;
75
+ /** Whether a fallback sign-in would write a folder this engine's gateway does not read: it started on the person's
76
+ * login location (installed sign-in, or a probe with no answer), and an in-process restart keeps that env. */
77
+ claudeLoginUnread(): boolean;
70
78
  resetNativeClaudeLogin(): Promise<void>;
71
79
  doctor(timeoutMs: number): {
72
80
  status: number | null;
package/dist/engine.js CHANGED
@@ -1,7 +1,8 @@
1
- import { spawn, spawnSync } from 'node:child_process';
1
+ import { execFile, 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 { homedir } from 'node:os';
5
6
  import { basename, join, dirname, resolve } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { setTimeout as delay } from 'node:timers/promises';
@@ -34,6 +35,23 @@ const putChanged = (path, data) => {
34
35
  if (!existsSync(path) || readFileSync(path, 'utf8') !== data)
35
36
  writeFileSync(path, data, { mode: 0o600 });
36
37
  };
38
+ // The person's own homes for every engine process: the gateway, doctor and agent CLIs use the installed, signed-in logins as is.
39
+ const personal = () => {
40
+ const env = { HOME: homedir() };
41
+ for (const name of ['XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME']) {
42
+ const value = process.env[name];
43
+ if (value)
44
+ env[name] = value;
45
+ }
46
+ return env;
47
+ };
48
+ const onPath = (path, name) => path.split(':').map(dir => join(dir, name)).find(file => { try {
49
+ accessSync(file, constants.X_OK);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ } });
37
55
  const supports = (list, value) => !list ||
38
56
  (!list.includes(`!${value}`) && (list.includes('any') || list.every(item => item.startsWith('!')) || list.includes(value)));
39
57
  // First failed check of the most recent installMatches call; retained with the npm facts in
@@ -98,6 +116,8 @@ export class Engine {
98
116
  claudeAccounts;
99
117
  claudeReset;
100
118
  credentialsLocked = false;
119
+ // True when the last start found no installed Claude Code login: the engine then uses its own fallback folder.
120
+ claudeFallback = false;
101
121
  repaired = false;
102
122
  prepared;
103
123
  patchSet;
@@ -184,7 +204,10 @@ export class Engine {
184
204
  await this.recoverOrphan();
185
205
  for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'usage'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs')])
186
206
  mkdirSync(d, { recursive: true, mode: 0o700 });
207
+ putOnce(join(this.root, 'npmrc'), ''); // engine npm runs read this empty config, never the person's ~/.npmrc
187
208
  this.sweepAccountOrphans();
209
+ if (this.o.spawnEngine)
210
+ this.claudeFallback = await this.installedClaudeSignedOut();
188
211
  try {
189
212
  await this.authStore.prepare();
190
213
  this.credentialsLocked = false;
@@ -304,7 +327,7 @@ export class Engine {
304
327
  const npm = this.o.npmPath && this.o.npmPath.includes('/') ? [dirname(this.o.npmPath)] : (process.env.PATH ?? '').split(':');
305
328
  return new Promise((resolve, reject) => {
306
329
  const child = spawn(process.execPath, [entry, 'plugins', 'install', spec, '--force', '--accept-capabilities'], {
307
- cwd: env.HOME, env: { ...env, PATH: [...npm, env.PATH].join(':'), npm_config_cache: join(this.root, 'npm-cache') },
330
+ cwd: env.OPENCLAW_HOME, env: { ...env, PATH: [...npm, env.PATH].join(':'), npm_config_cache: join(this.root, 'npm-cache') },
308
331
  stdio: ['ignore', 'pipe', 'pipe'],
309
332
  });
310
333
  let output = '';
@@ -317,14 +340,17 @@ export class Engine {
317
340
  child.once('close', code => { clearTimeout(timer); this.started.forget(child); resolve({ code, output }); });
318
341
  });
319
342
  }
343
+ get path() { return ['/usr/bin', '/bin', ...this.o.enginePath ?? []].join(':'); }
344
+ // The engine's own Claude folder: the fallback login writes it and, only with claudeFallback, the gateway reads it.
345
+ get claudeFolder() { return join(this.root, 'home', '.claude'); }
320
346
  doctorContext() {
321
347
  const home = join(this.root, 'home');
322
348
  return { entry: this.entry, env: {
323
- PATH: ['/usr/bin', '/bin', ...this.o.enginePath ?? []].join(':'), LANG: 'C.UTF-8', HOME: home, OPENCLAW_HOME: home,
349
+ PATH: this.path, LANG: 'C.UTF-8', ...personal(), OPENCLAW_HOME: home,
324
350
  OPENCLAW_STATE_DIR: join(this.root, 'state'), OPENCLAW_CONFIG_PATH: join(this.root, 'openclaw.json'),
325
- XDG_CONFIG_HOME: join(home, '.config'), XDG_CACHE_HOME: join(home, '.cache'), XDG_DATA_HOME: join(home, '.local/share'),
326
- XDG_STATE_HOME: join(home, '.local/state'), CODEX_HOME: join(home, '.codex'), CLAUDE_CONFIG_DIR: join(home, '.claude'),
327
- TMPDIR: join(this.root, 'tmp'), OPENCLAW_NO_RESPAWN: '1', OPENCLAW_SKIP_CHANNELS: '1', OPENCLAW_DISABLE_BONJOUR: '1',
351
+ XDG_CACHE_HOME: join(home, '.cache'), ...this.claudeFallback ? { CLAUDE_CONFIG_DIR: this.claudeFolder } : {},
352
+ TMPDIR: join(this.root, 'tmp'), npm_config_cache: join(this.root, 'npm-cache'), npm_config_userconfig: join(this.root, 'npmrc'),
353
+ OPENCLAW_NO_RESPAWN: '1', OPENCLAW_SKIP_CHANNELS: '1', OPENCLAW_DISABLE_BONJOUR: '1',
328
354
  OPENCLAW_EXEC_SHELL_SNAPSHOT: '0', OPENCLAW_LOAD_SHELL_ENV: '0', OPENCLAW_GATEWAY_TOKEN: this.token,
329
355
  BYOKIT_BRIDGE_SOCK: this.bridgeSock,
330
356
  BYOKIT_APP_OWNED_SESSION_PREFIXES: JSON.stringify(this.appOwnedSessionPrefixes),
@@ -332,26 +358,45 @@ export class Engine {
332
358
  }
333
359
  /** The engine's own shutdown signal; aborted by stop() and replaced by the next start(). */
334
360
  get stopSignal() { return this.stopOwner.signal; }
361
+ installedClaudeSignedOut() {
362
+ const env = { PATH: this.path, LANG: 'C.UTF-8', TMPDIR: join(this.root, 'tmp'), ...personal() };
363
+ const bin = onPath(env.PATH, 'claude');
364
+ if (!bin)
365
+ return Promise.resolve(true);
366
+ return new Promise(resolve => execFile(bin, ['auth', 'status', '--json'], { env, timeout: 15_000, maxBuffer: 256 * 1024 }, (error, stdout) => {
367
+ let loggedIn;
368
+ try {
369
+ loggedIn = JSON.parse(stdout).loggedIn;
370
+ }
371
+ catch { /* no parsed answer */ }
372
+ if (loggedIn === true)
373
+ return resolve(false);
374
+ if (loggedIn === false) {
375
+ this.o.log?.('claude: no installed sign-in, the engine uses its own sign-in folder');
376
+ return resolve(true);
377
+ }
378
+ this.o.log?.(`claude: sign-in status unknown, using the person's own login location (${String(error?.message ?? 'unparseable output').slice(0, 200)})`);
379
+ resolve(false);
380
+ }));
381
+ }
382
+ /** The fallback Claude sign-in for a machine whose installed Claude Code is signed out: it signs into the engine's folder. */
335
383
  nativeClaudeLogin() {
336
384
  if (this.claudeLogin)
337
385
  return this.claudeLogin;
338
- const { env } = this.doctorContext();
339
- const bin = env.PATH.split(':').map(path => join(path, 'claude')).find(path => { try {
340
- accessSync(path, constants.X_OK);
341
- return true;
342
- }
343
- catch {
344
- return false;
345
- } });
386
+ const { env } = this.doctorContext(), home = join(this.root, 'home');
387
+ const bin = onPath(env.PATH, 'claude');
346
388
  const bins = bin ? { claude: bin } : {};
347
389
  // The login runs in its own HOME: CLAUDE_CONFIG_DIR points it at the engine's folder, which accounts refuses as a HOME's default login.
348
390
  for (const dir of ['home', 'login-home'])
349
391
  mkdirSync(join(this.root, dir), { recursive: true, mode: 0o700 });
350
- const cli = cliAccounts({ stateDir: this.root, bins, env: { PATH: env.PATH, HOME: join(this.root, 'login-home'), LANG: env.LANG, TMPDIR: env.TMPDIR, XDG_CONFIG_HOME: env.XDG_CONFIG_HOME, XDG_CACHE_HOME: env.XDG_CACHE_HOME, XDG_DATA_HOME: env.XDG_DATA_HOME, XDG_STATE_HOME: env.XDG_STATE_HOME, }, managedFolders: { claude: env.CLAUDE_CONFIG_DIR } });
392
+ const cli = cliAccounts({ stateDir: this.root, bins, env: { PATH: env.PATH, HOME: join(this.root, 'login-home'), LANG: env.LANG, TMPDIR: env.TMPDIR, XDG_CONFIG_HOME: join(home, '.config'), XDG_CACHE_HOME: join(home, '.cache'), XDG_DATA_HOME: join(home, '.local/share'), XDG_STATE_HOME: join(home, '.local/state') }, managedFolders: { claude: this.claudeFolder } });
351
393
  this.claudeAccounts = cli;
352
394
  this.claudeLogin = remoteCliHost({ cli, bins });
353
395
  return this.claudeLogin;
354
396
  }
397
+ /** Whether a fallback sign-in would write a folder this engine's gateway does not read: it started on the person's
398
+ * login location (installed sign-in, or a probe with no answer), and an in-process restart keeps that env. */
399
+ claudeLoginUnread() { return Boolean(this.o.spawnEngine) && !this.claudeFallback; }
355
400
  resetNativeClaudeLogin() {
356
401
  if (this.claudeReset)
357
402
  return this.claudeReset;
@@ -361,7 +406,7 @@ export class Engine {
361
406
  this.claudeReset = (async () => { for (const row of await cli.list()) {
362
407
  if (host.signal.aborted)
363
408
  return;
364
- if (row.provider === 'claude' && row.state === 'ready' && cli.folderOf(row.id) === join(this.root, 'home', '.claude'))
409
+ if (row.provider === 'claude' && row.state === 'ready' && cli.folderOf(row.id) === this.claudeFolder)
365
410
  await cli.remove(row.id);
366
411
  } })().finally(() => { this.claudeReset = undefined; });
367
412
  return this.claudeReset;
@@ -370,7 +415,7 @@ export class Engine {
370
415
  const { entry, env } = this.doctorContext();
371
416
  if (this.o.spawnEngine)
372
417
  verifyEngineSet(this.setDir, this.wantedSet, installMatches);
373
- const result = spawnSync(process.execPath, [entry, 'doctor', '--fix', '--yes', '--non-interactive'], { cwd: env.HOME, env, timeout: timeoutMs, stdio: 'pipe' });
418
+ const result = spawnSync(process.execPath, [entry, 'doctor', '--fix', '--yes', '--non-interactive'], { cwd: env.OPENCLAW_HOME, env, timeout: timeoutMs, stdio: 'pipe' });
374
419
  if (this.o.spawnEngine)
375
420
  verifyEngineSet(this.setDir, this.wantedSet, installMatches);
376
421
  return { status: result.status };
@@ -389,7 +434,7 @@ export class Engine {
389
434
  && identity.pid === pid && identity.startTime === processStartTime(pid)
390
435
  && realpathSync(`${proc}/exe`) === realpathSync(process.execPath)
391
436
  && realpathSync(`${proc}/cwd`) === realpathSync(home)
392
- && env.includes(`HOME=${home}`)
437
+ && env.includes(`OPENCLAW_HOME=${home}`)
393
438
  && env.includes(`OPENCLAW_STATE_DIR=${join(this.root, 'state')}`)
394
439
  && env.includes(`OPENCLAW_CONFIG_PATH=${join(this.root, 'openclaw.json')}`)
395
440
  && existsSync(owner) && !pidAlive(Number(readFileSync(owner, 'utf8')));
@@ -527,7 +572,7 @@ export class Engine {
527
572
  }
528
573
  // Detached keeps the engine out of the host's session; stop() still signals its pid alone, never its group.
529
574
  try {
530
- this.child = spawn(process.execPath, [entry, 'gateway', '--port', String(this.port)], { cwd: env.HOME, env, detached: true, stdio: ['ignore', fd, fd] });
575
+ this.child = spawn(process.execPath, [entry, 'gateway', '--port', String(this.port)], { cwd: env.OPENCLAW_HOME, env, detached: true, stdio: ['ignore', fd, fd] });
531
576
  }
532
577
  catch (error) {
533
578
  if (env.BYOKIT_ENGINE_BOOT)
@@ -552,7 +597,7 @@ export class Engine {
552
597
  if (this.child !== child || this.stopping)
553
598
  return;
554
599
  this.child = undefined;
555
- void this.authStore.stop().then(() => undefined, refusedOnly).then((refused) => { this.exitedState(refused); this.o.onExit(null); }, () => this.state('failed', 'exited'));
600
+ void this.authStore.stop().then(() => undefined, refusedOnly).then((refused) => { this.exitedState(refused); this.o.onExit(null); }, () => { this.state('failed', 'exited'); this.o.onExit(null); });
556
601
  });
557
602
  if (!child.pid) {
558
603
  this.state('failed', 'exited');
@@ -588,7 +633,7 @@ export class Engine {
588
633
  }
589
634
  this.exitedState(refused);
590
635
  this.o.onExit(code);
591
- })().catch(() => this.state('failed', 'exited'));
636
+ })().catch(() => { this.state('failed', 'exited'); this.o.onExit(code); });
592
637
  });
593
638
  return { port: this.port, token: this.token, identityPath };
594
639
  }
package/dist/kit.d.ts CHANGED
@@ -35,6 +35,7 @@ export type KitOptions = {
35
35
  };
36
36
  callbackPort?: number;
37
37
  approvalTimeoutMs?: number;
38
+ handshakeMs?: number;
38
39
  transport?: (ctx: {
39
40
  port: number;
40
41
  token: string;
@@ -74,8 +75,13 @@ export declare class OpenClawKit {
74
75
  private signInDisconnects;
75
76
  private readonly phaseListeners;
76
77
  private starting?;
78
+ private stopped;
77
79
  private stopping;
78
80
  private failures;
81
+ private reachedReady;
82
+ private startRun?;
83
+ private retry?;
84
+ private engineExit?;
79
85
  private swept;
80
86
  private catalogs;
81
87
  browser?: BrowserHost;
@@ -99,13 +105,17 @@ export declare class OpenClawKit {
99
105
  get state(): KitState;
100
106
  prepare(): Promise<void>;
101
107
  start(): Promise<void>;
108
+ private attempts;
102
109
  private connect;
110
+ private repairEnded;
103
111
  private disconnect;
104
112
  private engineRestarting;
105
113
  private authSnapshot;
106
114
  private restarted;
107
115
  private closed;
116
+ private backoff;
108
117
  stop(): Promise<void>;
118
+ private teardown;
109
119
  private closeBrowsers;
110
120
  private request;
111
121
  call<M extends GatewayMethod>(method: M, params: GatewayParams<M>, o?: CallOptions): Promise<GatewayResult<M>>;
@@ -221,6 +231,7 @@ export declare class OpenClawKit {
221
231
  setLearning(mode: LearningMode | 'default'): LearningCapture;
222
232
  restoreLearning(captured: LearningCapture): LearningCapture;
223
233
  private assertLearningWritable;
234
+ /** The env for an external doctor. Its Claude login folder is the one chosen at `prepare()`/`start()`; before that it is the installed-login view only. */
224
235
  doctorContext(): {
225
236
  entry: string;
226
237
  env: Record<string, string>;
package/dist/kit.js CHANGED
@@ -32,6 +32,8 @@ import { launchBroker } from './browser/broker.js';
32
32
  /** The engine's learning-mode config key (5.6 "Engine learning state"). */
33
33
  const LEARNING_KEY = 'skills.workshop.autonomous.mode';
34
34
  const RESTART_MS = 60_000;
35
+ /** Consecutive failed starts before `failed/handshake` (5.4 step 2): the first start plus four retries. */
36
+ const START_ATTEMPTS = 5;
35
37
  const operatorMethods = new Set([
36
38
  'agent',
37
39
  'agent.identity.get',
@@ -452,8 +454,13 @@ export class OpenClawKit {
452
454
  signInDisconnects = new Set();
453
455
  phaseListeners = new Set();
454
456
  starting;
457
+ stopped = Promise.resolve();
455
458
  stopping = false;
456
459
  failures = 0;
460
+ reachedReady = false;
461
+ startRun;
462
+ retry;
463
+ engineExit;
457
464
  swept = false;
458
465
  catalogs = Promise.resolve();
459
466
  browser;
@@ -737,20 +744,52 @@ export class OpenClawKit {
737
744
  if (this.engine.patchSet !== undefined)
738
745
  this.setState(this.current);
739
746
  }
740
- start() {
747
+ async start() {
741
748
  if (this.current.phase === 'ready')
742
- return Promise.resolve();
749
+ return;
750
+ if (this.stopping) {
751
+ await this.starting?.catch(() => undefined);
752
+ await this.stopped;
753
+ }
743
754
  if (this.starting)
744
755
  return this.starting;
745
756
  this.stopping = false;
746
- const task = this.connect();
757
+ this.retry?.abort();
758
+ const task = this.attempts(this.reachedReady ? 1 : START_ATTEMPTS);
747
759
  this.starting = task;
748
760
  void task.finally(() => { if (this.starting === task)
749
761
  this.starting = undefined; }).catch(() => { });
750
762
  return task;
751
763
  }
764
+ // One start() call's attempts (5.4 step 2): a missed hello waits the reconnect backoff and starts the engine afresh
765
+ // until `budget` attempts have missed. The call owns its wait (stop() aborts it) and, on a first start, every close.
766
+ async attempts(budget) {
767
+ const run = this.startRun = new AbortController();
768
+ try {
769
+ for (let missed = 0;;) {
770
+ const miss = await this.connect();
771
+ if (!miss) {
772
+ if (this.stopping)
773
+ throw new Error('kit stopped before ready');
774
+ return;
775
+ }
776
+ if (++missed === budget) {
777
+ this.setState({ phase: 'failed', why: 'handshake' });
778
+ throw miss;
779
+ }
780
+ await this.backoff(missed - 1, run.signal);
781
+ }
782
+ }
783
+ finally {
784
+ if (this.startRun === run)
785
+ this.startRun = undefined;
786
+ }
787
+ }
788
+ // Resolves the error of a missed hello (a deadline miss, or an engine exit before hello) for attempts() to retry.
752
789
  async connect() {
753
790
  let transport;
791
+ const timeout = new Error('gateway handshake timed out');
792
+ const exited = new Error('engine exited before the gateway handshake');
754
793
  try {
755
794
  await this.prepareBrowsers();
756
795
  const ctx = await this.engine.start();
@@ -761,7 +800,9 @@ export class OpenClawKit {
761
800
  this.transport = transport;
762
801
  this.off = [transport.onEvent((e) => { for (const fn of this.listeners)
763
802
  fn(e); }), transport.onClose((why) => this.closed(why))];
764
- const hello = await Promise.race([transport.start(), delay(90_000, undefined, { ref: false }).then(() => { throw new Error('gateway handshake timed out'); })]);
803
+ const deadline = delay(this.o.handshakeMs ?? 90_000, undefined, { ref: false }).then(() => { throw timeout; });
804
+ const exit = new Promise((_, reject) => { this.engineExit = () => reject(exited); });
805
+ const hello = await Promise.race([transport.start(), deadline, exit]);
765
806
  if (this.stopping || this.transport !== transport)
766
807
  return;
767
808
  if (hello.protocol !== PROTOCOL_VERSION) {
@@ -782,9 +823,10 @@ export class OpenClawKit {
782
823
  signIn: (agentId, o, on, fresh) => startSignIn({ ...this.signInCtx(), ensure: async () => ({ agentId }), fresh }, agentId, o, on),
783
824
  routes: this.routeMap, log: this.o.log, reseal: () => this.engine.authStore.reseal() });
784
825
  // The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
785
- await this.bridge.start();
826
+ await Promise.race([this.bridge.start(), exit]);
786
827
  this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
787
828
  this.failures = 0;
829
+ this.reachedReady = true;
788
830
  this.setState({ phase: 'ready' });
789
831
  this.syncCatalogs();
790
832
  // A failed key add whose abandon clear never reached the engine leaves an unsealed key with no ready marker;
@@ -803,6 +845,9 @@ export class OpenClawKit {
803
845
  const patchFailure = this.current.why === 'engine-patch';
804
846
  if (transport && this.transport === transport)
805
847
  await this.disconnect();
848
+ if (error === timeout && this.current.phase === 'repairing')
849
+ await this.repairEnded();
850
+ const engineFailure = this.current.phase === 'failed' ? this.current : undefined;
806
851
  const refused = await this.engine.stop().then(() => undefined, refusedOnly);
807
852
  await this.closeBrowsers();
808
853
  const sealRefusal = refused ?? (error instanceof AuthStoreSealSizeError ? error : undefined);
@@ -822,12 +867,32 @@ export class OpenClawKit {
822
867
  this.setState({ phase: 'failed', why: 'engine-patch' });
823
868
  throw error;
824
869
  }
870
+ // A missed hello on a loaded host is the engine still coming up: attempts() decides whether to retry.
871
+ if (engineFailure?.why === 'auth-store-seal-size' && !this.stopping) {
872
+ this.setState(engineFailure);
873
+ throw error;
874
+ }
875
+ const miss = error === exited && engineFailure?.why === 'exited' ? exited : error === timeout ? timeout : undefined;
876
+ if (miss && !this.stopping)
877
+ return miss;
825
878
  if (needsUpdate)
826
879
  this.setState({ phase: 'needs-update', why: 'version' });
827
880
  if (!needsUpdate && !this.stopping)
828
881
  this.setState({ phase: 'failed', why: 'handshake' });
829
882
  throw error;
830
883
  }
884
+ finally {
885
+ this.engineExit = undefined;
886
+ }
887
+ }
888
+ repairEnded() {
889
+ return new Promise((resolve) => {
890
+ const check = () => { if (this.current.phase !== 'repairing') {
891
+ this.phaseListeners.delete(check);
892
+ resolve();
893
+ } };
894
+ this.phaseListeners.add(check);
895
+ });
831
896
  }
832
897
  async disconnect() {
833
898
  this.admission.invalidate();
@@ -874,8 +939,14 @@ export class OpenClawKit {
874
939
  signal?.addEventListener('abort', abort, { once: true });
875
940
  });
876
941
  }
877
- closed(_why) {
878
- const engineFailed = _why === 'engine exited' && this.current.phase === 'failed' && this.current.why === 'exited';
942
+ closed(why) {
943
+ // A first start's loop owns an engine exit until it reaches ready; other closes are the transport client's to retry.
944
+ if (this.startRun && !this.reachedReady) {
945
+ if (why === 'engine exited')
946
+ this.engineExit?.();
947
+ return;
948
+ }
949
+ const engineFailed = why === 'engine exited' && this.current.phase === 'failed' && this.current.why === 'exited';
879
950
  if (this.stopping || (this.current.phase !== 'ready' && !engineFailed))
880
951
  return;
881
952
  for (const fn of this.signInDisconnects)
@@ -885,13 +956,26 @@ export class OpenClawKit {
885
956
  this.setState({ phase: 'failed', why: 'handshake' });
886
957
  return;
887
958
  }
888
- const retryAt = Date.now() + Math.min(30_000, 1000 * 2 ** this.failures++);
959
+ this.retry?.abort();
960
+ const retry = this.retry = new AbortController();
961
+ void this.backoff(this.failures++, retry.signal).then(() => { if (!this.stopping && this.current.phase === 'restarting')
962
+ void this.start().catch(() => { }); }, () => { });
963
+ }
964
+ // The reconnect backoff (step 3): state restarting, resolving at retryAt = now + min(30 s, 1 s × 2^failures).
965
+ backoff(failures, signal) {
966
+ const retryAt = Date.now() + Math.min(30_000, 1000 * 2 ** failures);
889
967
  this.setState({ phase: 'restarting', retryAt });
890
- void delay(retryAt - Date.now()).then(() => { if (!this.stopping && this.current.phase === 'restarting')
891
- void this.start().catch(() => { }); });
968
+ return delay(retryAt - Date.now(), undefined, { signal });
892
969
  }
893
970
  async stop() {
894
971
  this.stopping = true;
972
+ this.reachedReady = false;
973
+ this.startRun?.abort();
974
+ this.retry?.abort();
975
+ this.stopped = this.teardown();
976
+ await this.stopped;
977
+ }
978
+ async teardown() {
895
979
  await this.disconnect();
896
980
  await this.engine.stop();
897
981
  await this.closeBrowsers();
@@ -971,6 +1055,7 @@ export class OpenClawKit {
971
1055
  ensure: (member) => this.ensureMember(member), callbackPort: this.o.callbackPort ?? 1455,
972
1056
  nativeClaudeLogin: () => this.engine.nativeClaudeLogin(),
973
1057
  resetNativeClaudeLogin: () => this.engine.resetNativeClaudeLogin(),
1058
+ claudeLoginUnread: () => this.engine.claudeLoginUnread(),
974
1059
  onDisconnect: (fn) => { this.signInDisconnects.add(fn); return () => { this.signInDisconnects.delete(fn); }; },
975
1060
  stopSignal: this.engine.stopSignal,
976
1061
  authConfig: () => this.authSnapshot(),
@@ -1382,5 +1467,6 @@ export class OpenClawKit {
1382
1467
  if (this.o.config?.skills !== undefined)
1383
1468
  throw new Error(`${op}: KitOptions.config also passes skills; that option wins at every boot (5.6)`);
1384
1469
  }
1470
+ /** The env for an external doctor. Its Claude login folder is the one chosen at `prepare()`/`start()`; before that it is the installed-login view only. */
1385
1471
  doctorContext() { return this.engine.doctorContext(); }
1386
1472
  }
package/dist/signin.d.ts CHANGED
@@ -10,6 +10,7 @@ export type SignInCtx = {
10
10
  callbackPort: number;
11
11
  nativeClaudeLogin?(): ReturnType<typeof remoteCliHost>;
12
12
  resetNativeClaudeLogin?(): Promise<void>;
13
+ claudeLoginUnread?(): boolean;
13
14
  onDisconnect?(fn: () => void): () => void;
14
15
  authConfig(): string;
15
16
  restarted(authBefore: string | undefined, signal?: AbortSignal): Promise<boolean>;
package/dist/signin.js CHANGED
@@ -242,6 +242,8 @@ export function signIn(ctx, member, o, on) {
242
242
  };
243
243
  native = ctx.nativeClaudeLogin?.();
244
244
  if (!await detected()) {
245
+ if (ctx.claudeLoginUnread?.())
246
+ return { state: 'failed', via, why: 'failed', error: 'Claude Code is installed here, but its sign-in could not be checked. Run claude once on this machine to sign in, then try again.' };
245
247
  if (!native)
246
248
  return { state: 'failed', via, why: 'failed', error: 'Claude Code sign-in is not available on this engine.' };
247
249
  let started = await native.start('claude', { signal });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
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",
@@ -60,7 +60,7 @@
60
60
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
61
61
  },
62
62
  "dependencies": {
63
- "@byokit/accounts": "0.27.0",
63
+ "@byokit/accounts": "0.27.1",
64
64
  "@byokit/pair": "0.10.3",
65
65
  "@byokit/relay": "0.8.1",
66
66
  "@byokit/discover": "0.8.4",