@byokit/openclaw 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.6 (2026-10-01)
6
+
7
+ - FIX: Live orphan gateways recover after the engine rewrites its process title. Recovery verifies the recorded launch pid and process start time with the executable and isolated store paths; ambiguous ownership preserves the saved sign-in state.
8
+
9
+ - Dependency update: pins @byokit/ui-core 0.5.0.
10
+ - Dependency update: pins @byokit/reach 0.5.0.
11
+ - Dependency update: pins @byokit/relay 0.5.0.
12
+ - Dependency update: pins @byokit/link 0.7.0.
13
+ - SECURITY: (from @byokit/link 0.7.0) `Host.shortCode()` adds a machine-key commitment to typed pairing; `pairWithCode()` verifies it before disclosing device identity or asking for approval. Legacy codes remain compatible; use the full new code when relay lookup is untrusted.
14
+
15
+ ## 0.3.5 (2026-10-01)
16
+
17
+
18
+
19
+ - FIX: Failed engine starts no longer remove another live gateway’s guards or saved sign-in state. Verified dead-host gateways stop gracefully before restart; ambiguous ownership returns a typed engine-already-running error and preserves the store.
20
+
5
21
  ## 0.3.4 (2026-09-30)
6
22
 
7
23
 
package/README.md CHANGED
@@ -232,7 +232,14 @@ before any restoration; a wrong key, tampering, or a missing adapter rejects. Fi
232
232
  have mode 0600 and directories 0700. File symlinks are sealed only when their fully resolved targets are
233
233
  regular files inside the isolated engine root; they restore as regular files at the link paths. Outside-root,
234
234
  dangling and directory symlinks (including loops), sockets, FIFOs and devices are skipped.
235
- One kit owns the store at a time; a live owner or orphan gateway blocks preparation instead of racing its writes.
235
+ One kit owns the store at a time. After a host crash, preparation stops a verified same-user gateway
236
+ only when its host lock owner is dead, waits up to three seconds for exit, then seals and restores the
237
+ leftover state under the acquired lock. If process identity is ambiguous (including unavailable process
238
+ inspection or a rewritten command line), another host is alive, or shutdown times out, `prepare()` and
239
+ `start()` reject with exported `EngineAlreadyRunningError` (`code: 'engine-already-running'`). The kit reports
240
+ `{ phase: 'failed', why: 'engine-already-running' }`; show `stateWords(kit.state)` and retry after the other
241
+ session stops. Failed-start cleanup and `stop()` on an instance with no ownership preserve the other
242
+ writer's pid, lock, sealed snapshot and live state.
236
243
  Sealing copies the whole store through memory, so startup and stop cost grows with session history.
237
244
 
238
245
  The migration doctor temporarily opens the same store and reseals it even when import fails. Once the gateway
@@ -2,20 +2,10 @@
2
2
  // Seal the complete isolated stores, including journals, only after their writer has exited.
3
3
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
5
6
  const encoder = new TextEncoder();
6
7
  const archive = (name) => /^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
7
8
  || name.endsWith('.moved-to-engine');
8
- const live = (pid) => {
9
- if (!Number.isSafeInteger(pid) || pid < 1)
10
- return false;
11
- try {
12
- process.kill(pid, 0);
13
- return true;
14
- }
15
- catch (error) {
16
- return error.code !== 'ESRCH';
17
- }
18
- };
19
9
  function regular(path) {
20
10
  if (!lstatSync(path).isFile())
21
11
  throw new Error(`credential store requires regular files: ${path}`);
@@ -108,18 +98,16 @@ export class AuthStore {
108
98
  return;
109
99
  if (existsSync(this.file) && !this.o.seal)
110
100
  throw new Error('authSeal required for sealed credential store');
111
- if (!this.o.seal)
112
- return;
113
101
  // Never race a live gateway or another kit, including an orphan after its host crashed.
114
102
  const gateway = join(this.o.root, 'gateway.pid');
115
103
  if (existsSync(gateway) && live(Number(readFileSync(gateway, 'utf8'))))
116
- throw new Error('credential store is in use');
104
+ throw new EngineAlreadyRunningError();
117
105
  if (existsSync(this.lock)) {
118
106
  if (!lstatSync(this.lock).isDirectory() || lstatSync(this.lock).isSymbolicLink())
119
107
  throw new Error('invalid credential lock');
120
108
  const owner = join(this.lock, 'pid');
121
109
  if (!existsSync(owner) || live(Number(readFileSync(owner, 'utf8'))))
122
- throw new Error('credential store is in use');
110
+ throw new EngineAlreadyRunningError();
123
111
  // Only one stale-owner recovery can proceed. Recheck after claiming it so a stale reader
124
112
  // cannot remove the lock a newer owner has just acquired.
125
113
  const recovery = join(this.lock, 'recovery');
@@ -127,17 +115,36 @@ export class AuthStore {
127
115
  mkdirSync(recovery, { mode: 0o700 });
128
116
  }
129
117
  catch {
130
- throw new Error('credential store is in use');
118
+ throw new EngineAlreadyRunningError();
131
119
  }
132
120
  if (!existsSync(owner) || live(Number(readFileSync(owner, 'utf8')))) {
133
121
  rmSync(recovery, { recursive: true });
134
- throw new Error('credential store is in use');
122
+ throw new EngineAlreadyRunningError();
135
123
  }
136
124
  rmSync(this.lock, { recursive: true });
137
125
  }
138
- mkdirSync(this.lock, { mode: 0o700 });
139
- writeFileSync(join(this.lock, 'pid'), String(process.pid), { mode: 0o600, flag: 'wx' });
126
+ try {
127
+ mkdirSync(this.lock, { mode: 0o700 });
128
+ }
129
+ catch (error) {
130
+ if (error.code === 'EEXIST')
131
+ throw new EngineAlreadyRunningError();
132
+ throw error;
133
+ }
140
134
  this.owned = true;
135
+ try {
136
+ writeFileSync(join(this.lock, 'pid'), String(process.pid), { mode: 0o600, flag: 'wx' });
137
+ // Recheck under the acquired lock before removing a dead writer's guard.
138
+ if (existsSync(gateway)) {
139
+ if (live(Number(readFileSync(gateway, 'utf8'))))
140
+ throw new EngineAlreadyRunningError();
141
+ removeMarker(gateway);
142
+ }
143
+ }
144
+ catch (error) {
145
+ this.release();
146
+ throw error;
147
+ }
141
148
  }
142
149
  release() {
143
150
  if (!this.owned)
@@ -297,7 +304,8 @@ export class AuthStore {
297
304
  }
298
305
  stop() {
299
306
  return this.serial(async () => {
300
- this.acquire();
307
+ if (!this.owned)
308
+ return;
301
309
  await this.archives();
302
310
  await this.persist();
303
311
  this.active = false;
@@ -0,0 +1,6 @@
1
+ /** A live or unverifiable writer still owns the isolated engine state. Retry after it stops. */
2
+ export declare class EngineAlreadyRunningError extends Error {
3
+ readonly code = "engine-already-running";
4
+ constructor();
5
+ }
6
+ export declare function pidAlive(pid: number): boolean;
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /** A live or unverifiable writer still owns the isolated engine state. Retry after it stops. */
3
+ export class EngineAlreadyRunningError extends Error {
4
+ code = 'engine-already-running';
5
+ constructor() {
6
+ super('engine already running; credential store is in use');
7
+ this.name = 'EngineAlreadyRunningError';
8
+ }
9
+ }
10
+ export function pidAlive(pid) {
11
+ if (!Number.isSafeInteger(pid) || pid < 1)
12
+ return true; // An invalid guard is ambiguous, never stale.
13
+ try {
14
+ process.kill(pid, 0);
15
+ // An orphan can remain a zombie until its new parent reaps it; it has no writer left.
16
+ if (process.platform === 'linux') {
17
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
18
+ if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z '))
19
+ return false;
20
+ }
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ return error.code !== 'ESRCH';
25
+ }
26
+ }
package/dist/engine.d.ts CHANGED
@@ -36,7 +36,8 @@ export declare class Engine {
36
36
  doctor(timeoutMs: number): {
37
37
  status: number | null;
38
38
  };
39
- private stalePid;
39
+ private verifiedOrphan;
40
+ private recoverOrphan;
40
41
  start(): Promise<{
41
42
  port: number;
42
43
  token: string;
@@ -45,6 +46,7 @@ export declare class Engine {
45
46
  private locked;
46
47
  private startOnce;
47
48
  private launch;
49
+ private removeOwnedPid;
48
50
  withAuthStore<T>(task: () => Promise<T>): Promise<T>;
49
51
  stop(): Promise<void>;
50
52
  }
package/dist/engine.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { randomBytes, generateKeyPairSync } from 'node:crypto';
3
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
3
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync, realpathSync, statSync } from 'node:fs';
4
4
  import { createServer } from 'node:net';
5
5
  import { join, dirname } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
8
  import { AuthStore } from "./auth-store.js";
9
+ import { EngineAlreadyRunningError, pidAlive } from "./engine-status.js";
9
10
  import { ENGINE_VERSION } from "./constants.js";
10
11
  import { reconcileConfig } from "./config.js";
11
12
  import { writePlugin, resolveBridge } from "./bridge.js";
@@ -16,6 +17,14 @@ const putChanged = (path, data) => {
16
17
  if (!existsSync(path) || readFileSync(path, 'utf8') !== data)
17
18
  writeFileSync(path, data, { mode: 0o600 });
18
19
  };
20
+ function processStartTime(pid) {
21
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
22
+ // comm can contain spaces and parentheses; field 22 is index 19 after its final ')'.
23
+ const startTime = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
24
+ if (!startTime || !/^\d+$/.test(startTime))
25
+ throw new Error('unverifiable engine start time');
26
+ return startTime;
27
+ }
19
28
  const supports = (list, value) => !list ||
20
29
  (!list.includes(`!${value}`) && (list.includes('any') || list.every(item => item.startsWith('!')) || list.includes(value)));
21
30
  function installMatches(dir) {
@@ -93,6 +102,7 @@ export class Engine {
93
102
  async prepareOnce() {
94
103
  if (!this.o.authSeal && existsSync(join(this.root, 'auth-store.sealed')))
95
104
  throw new Error('authSeal required for sealed credential store');
105
+ await this.recoverOrphan();
96
106
  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])
97
107
  mkdirSync(d, { recursive: true, mode: 0o700 });
98
108
  try {
@@ -168,19 +178,64 @@ export class Engine {
168
178
  const result = spawnSync(process.execPath, [entry, 'doctor', '--fix', '--yes', '--non-interactive'], { cwd: env.HOME, env, timeout: timeoutMs, stdio: 'pipe' });
169
179
  return { status: result.status };
170
180
  }
171
- stalePid() {
181
+ verifiedOrphan(pid) {
182
+ if (process.platform !== 'linux' || !process.getuid)
183
+ return false;
184
+ try {
185
+ const proc = `/proc/${pid}`;
186
+ const identity = JSON.parse(readFileSync(join(this.root, 'gateway.identity'), 'utf8'));
187
+ const env = readFileSync(`${proc}/environ`, 'utf8').split('\0');
188
+ const home = join(this.root, 'home');
189
+ const owner = join(this.root, 'auth-store.lock', 'pid');
190
+ return statSync(proc).uid === process.getuid()
191
+ && Number(readFileSync(join(this.root, 'gateway.pid'), 'utf8')) === pid
192
+ && identity.pid === pid && identity.startTime === processStartTime(pid)
193
+ && realpathSync(`${proc}/exe`) === realpathSync(process.execPath)
194
+ && realpathSync(`${proc}/cwd`) === realpathSync(home)
195
+ && env.includes(`HOME=${home}`)
196
+ && env.includes(`OPENCLAW_STATE_DIR=${join(this.root, 'state')}`)
197
+ && env.includes(`OPENCLAW_CONFIG_PATH=${join(this.root, 'openclaw.json')}`)
198
+ && existsSync(owner) && !pidAlive(Number(readFileSync(owner, 'utf8')));
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
204
+ async recoverOrphan() {
205
+ if (this.child && this.child.exitCode === null && this.child.signalCode === null)
206
+ return;
172
207
  const path = join(this.root, 'gateway.pid');
173
208
  if (!existsSync(path))
174
209
  return;
175
210
  const pid = Number(readFileSync(path, 'utf8'));
176
- if (!Number.isSafeInteger(pid) || pid < 1)
177
- return;
211
+ if (!pidAlive(pid))
212
+ return; // AuthStore removes stale guards only under its lock.
213
+ const occupied = () => { this.state('failed', 'engine-already-running'); return new EngineAlreadyRunningError(); };
214
+ if (!this.verifiedOrphan(pid))
215
+ throw occupied();
216
+ // Verify a stable process identity again immediately before signalling this pid, never its group.
178
217
  try {
179
- const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
180
- if (cmd.includes(this.entry) && cmd.includes('gateway'))
181
- process.kill(-pid, 'SIGTERM');
218
+ const before = processStartTime(pid);
219
+ if (!this.verifiedOrphan(pid) || before !== processStartTime(pid))
220
+ throw occupied();
221
+ }
222
+ catch {
223
+ if (!pidAlive(pid))
224
+ return;
225
+ throw occupied();
182
226
  }
183
- catch { /* pid is gone or not ours */ }
227
+ try {
228
+ process.kill(pid, 'SIGTERM');
229
+ }
230
+ catch (error) {
231
+ if (error.code !== 'ESRCH')
232
+ throw occupied();
233
+ }
234
+ for (let i = 0; i < 15 && pidAlive(pid); i++)
235
+ await delay(200);
236
+ if (pidAlive(pid))
237
+ throw occupied();
238
+ // The new store owner now seals crash leftovers under its lock before restoring them.
184
239
  }
185
240
  start() {
186
241
  if (this.starting)
@@ -215,7 +270,9 @@ export class Engine {
215
270
  return this.launch();
216
271
  }
217
272
  catch (error) {
218
- await this.authStore.stop();
273
+ // A launch failure after spawn still has a writer; the caller's stop must await its exit.
274
+ if (!this.child)
275
+ await this.authStore.stop();
219
276
  throw error;
220
277
  }
221
278
  }
@@ -229,7 +286,6 @@ export class Engine {
229
286
  return { port: this.port, token: this.token, identityPath };
230
287
  if (this.child && this.child.exitCode === null)
231
288
  return { port: this.port, token: this.token, identityPath };
232
- this.stalePid();
233
289
  this.state('starting');
234
290
  const { entry, env } = this.doctorContext();
235
291
  const fd = openSync(join(this.o.stateDir, 'logs', 'openclaw.log'), 'a', 0o600);
@@ -250,12 +306,15 @@ export class Engine {
250
306
  this.state('failed', 'exited');
251
307
  throw new Error('engine spawn failed');
252
308
  }
309
+ if (process.platform === 'linux') {
310
+ writeFileSync(join(this.root, 'gateway.identity'), JSON.stringify({ pid: child.pid, startTime: processStartTime(child.pid) }), { mode: 0o600 });
311
+ }
253
312
  writeFileSync(join(this.root, 'gateway.pid'), String(child.pid), { mode: 0o600 });
254
313
  child.once('exit', (code) => {
255
314
  if (this.child !== child || this.stopping)
256
315
  return;
257
316
  this.child = undefined;
258
- rmSync(join(this.root, 'gateway.pid'), { force: true });
317
+ this.removeOwnedPid(child.pid);
259
318
  void (async () => {
260
319
  await this.authStore.stop();
261
320
  if (this.stopping)
@@ -275,6 +334,13 @@ export class Engine {
275
334
  });
276
335
  return { port: this.port, token: this.token, identityPath };
277
336
  }
337
+ removeOwnedPid(pid) {
338
+ const path = join(this.root, 'gateway.pid');
339
+ if (pid && existsSync(path) && readFileSync(path, 'utf8') === String(pid)) {
340
+ rmSync(path, { force: true });
341
+ rmSync(join(this.root, 'gateway.identity'), { force: true });
342
+ }
343
+ }
278
344
  withAuthStore(task) { return this.authStore.offline(task); }
279
345
  async stop() {
280
346
  this.stopping = true;
@@ -299,10 +365,9 @@ export class Engine {
299
365
  if (child && child.exitCode === null && child.signalCode === null)
300
366
  throw new Error('engine did not stop; credential store still in use');
301
367
  this.child = undefined;
302
- rmSync(join(this.root, 'gateway.pid'), { force: true });
368
+ this.removeOwnedPid(child?.pid);
303
369
  if (!this.credentialsLocked)
304
370
  await this.authStore.stop();
305
- rmSync(this.bridgeSock, { force: true });
306
371
  this.state('stopped');
307
372
  }
308
373
  }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.t
2
2
  export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, RunUsage, SignInOptions, SignInView, ToolHost, ToolSpec, } from './types.ts';
3
3
  export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
4
4
  export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
5
+ export { EngineAlreadyRunningError } from './engine-status.ts';
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@
3
3
  export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from "./constants.js";
4
4
  export { OpenClawKit } from "./kit.js";
5
5
  export { stateWords, toAccountView, words } from "./words.js";
6
+ export { EngineAlreadyRunningError } from "./engine-status.js";
package/dist/kit.js CHANGED
@@ -503,6 +503,10 @@ export class OpenClawKit {
503
503
  if (transport && this.transport === transport)
504
504
  await this.disconnect();
505
505
  await this.engine.stop();
506
+ if (error instanceof Error && 'code' in error && error.code === 'engine-already-running') {
507
+ this.setState({ phase: 'failed', why: 'engine-already-running' });
508
+ throw error;
509
+ }
506
510
  if (needsUpdate)
507
511
  this.setState({ phase: 'needs-update', why: 'version' });
508
512
  if (!needsUpdate && !this.stopping)
package/dist/types.d.ts CHANGED
@@ -115,7 +115,7 @@ export type Decision = {
115
115
  };
116
116
  export type KitState = {
117
117
  phase: 'stopped' | 'installing' | 'starting' | 'repairing' | 'ready' | 'restarting' | 'failed' | 'needs-update' | 'locked';
118
- why?: 'install' | 'handshake' | 'exited' | 'port' | 'version';
118
+ why?: 'install' | 'handshake' | 'exited' | 'port' | 'version' | 'engine-already-running';
119
119
  retryAt?: number;
120
120
  };
121
121
  export type Hello = {
package/dist/words.js CHANGED
@@ -19,6 +19,8 @@ const PHASE_WORDS = {
19
19
  };
20
20
  export function stateWords(s) {
21
21
  // 'stopped' is never on screen (4.3 covers what a person can see), so 5.14 has no row for it.
22
+ if (s.phase === 'failed' && s.why === 'engine-already-running')
23
+ return words('engine.alreadyRunning');
22
24
  return s.phase === 'stopped' ? '' : words(PHASE_WORDS[s.phase]);
23
25
  }
24
26
  export function toAccountView(view, ready) {
package/dist/words.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "engine.locked": "Your saved sign-in is locked. Unlock your password storage, then try again.",
6
6
  "engine.ready": "Ready.",
7
7
  "engine.restarting": "Something stopped. Starting it again by itself.",
8
+ "engine.alreadyRunning": "Your saved sign-in is in use. Try again after the other session stops.",
8
9
  "engine.failed": "This computer couldn't start the helper. Restart the app to try again.",
9
10
  "engine.needsUpdate": "This app needs an update to keep working.",
10
11
  "member.signedOut": "Sign in with {name} to start.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",
@@ -52,9 +52,9 @@
52
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
53
53
  },
54
54
  "dependencies": {
55
- "@byokit/link": "0.6.0",
56
- "@byokit/relay": "0.4.2",
57
- "@byokit/reach": "0.4.0",
55
+ "@byokit/link": "0.7.0",
56
+ "@byokit/relay": "0.5.0",
57
+ "@byokit/reach": "0.5.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "@byokit/secrets": "0.5.0",
60
60
  "@openclaw/gateway-client": "2026.8.1",
@@ -62,7 +62,7 @@
62
62
  "ws": "8.21.3"
63
63
  },
64
64
  "devDependencies": {
65
- "@byokit/ui-core": "0.4.0"
65
+ "@byokit/ui-core": "0.5.0"
66
66
  },
67
67
  "publishConfig": {
68
68
  "access": "public"