@byokit/openclaw 0.3.4 → 0.3.5

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,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.5 (2026-10-01)
6
+
7
+
8
+
9
+ - 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.
10
+
5
11
  ## 0.3.4 (2026-09-30)
6
12
 
7
13
 
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";
@@ -93,6 +94,7 @@ export class Engine {
93
94
  async prepareOnce() {
94
95
  if (!this.o.authSeal && existsSync(join(this.root, 'auth-store.sealed')))
95
96
  throw new Error('authSeal required for sealed credential store');
97
+ await this.recoverOrphan();
96
98
  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
99
  mkdirSync(d, { recursive: true, mode: 0o700 });
98
100
  try {
@@ -168,19 +170,67 @@ export class Engine {
168
170
  const result = spawnSync(process.execPath, [entry, 'doctor', '--fix', '--yes', '--non-interactive'], { cwd: env.HOME, env, timeout: timeoutMs, stdio: 'pipe' });
169
171
  return { status: result.status };
170
172
  }
171
- stalePid() {
173
+ verifiedOrphan(pid) {
174
+ if (process.platform !== 'linux' || !process.getuid)
175
+ return false;
176
+ try {
177
+ const proc = `/proc/${pid}`;
178
+ const cmd = readFileSync(`${proc}/cmdline`, 'utf8').split('\0');
179
+ const env = readFileSync(`${proc}/environ`, 'utf8').split('\0');
180
+ const home = join(this.root, 'home');
181
+ const owner = join(this.root, 'auth-store.lock', 'pid');
182
+ return statSync(proc).uid === process.getuid()
183
+ && realpathSync(`${proc}/exe`) === realpathSync(process.execPath)
184
+ && cmd.indexOf(this.entry) > 0 && cmd[cmd.indexOf(this.entry) + 1] === 'gateway'
185
+ && realpathSync(`${proc}/cwd`) === realpathSync(home)
186
+ && env.includes(`HOME=${home}`)
187
+ && env.includes(`OPENCLAW_STATE_DIR=${join(this.root, 'state')}`)
188
+ && env.includes(`OPENCLAW_CONFIG_PATH=${join(this.root, 'openclaw.json')}`)
189
+ && existsSync(owner) && !pidAlive(Number(readFileSync(owner, 'utf8')));
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ async recoverOrphan() {
196
+ if (this.child && this.child.exitCode === null && this.child.signalCode === null)
197
+ return;
172
198
  const path = join(this.root, 'gateway.pid');
173
199
  if (!existsSync(path))
174
200
  return;
175
201
  const pid = Number(readFileSync(path, 'utf8'));
176
- if (!Number.isSafeInteger(pid) || pid < 1)
177
- return;
202
+ if (!pidAlive(pid))
203
+ return; // AuthStore removes stale guards only under its lock.
204
+ const occupied = () => { this.state('failed', 'engine-already-running'); return new EngineAlreadyRunningError(); };
205
+ if (!this.verifiedOrphan(pid))
206
+ throw occupied();
207
+ // Verify a stable process identity again immediately before signalling this pid, never its group.
208
+ const identity = () => {
209
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
210
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]; // starttime, not a reusable pid alone
211
+ };
212
+ try {
213
+ const before = identity();
214
+ if (!before || !this.verifiedOrphan(pid) || before !== identity())
215
+ throw occupied();
216
+ }
217
+ catch {
218
+ if (!pidAlive(pid))
219
+ return;
220
+ throw occupied();
221
+ }
178
222
  try {
179
- const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
180
- if (cmd.includes(this.entry) && cmd.includes('gateway'))
181
- process.kill(-pid, 'SIGTERM');
223
+ process.kill(pid, 'SIGTERM');
224
+ }
225
+ catch (error) {
226
+ if (error.code !== 'ESRCH')
227
+ throw occupied();
182
228
  }
183
- catch { /* pid is gone or not ours */ }
229
+ for (let i = 0; i < 15 && pidAlive(pid); i++)
230
+ await delay(200);
231
+ if (pidAlive(pid))
232
+ throw occupied();
233
+ // The new store owner now seals crash leftovers under its lock before restoring them.
184
234
  }
185
235
  start() {
186
236
  if (this.starting)
@@ -215,7 +265,9 @@ export class Engine {
215
265
  return this.launch();
216
266
  }
217
267
  catch (error) {
218
- await this.authStore.stop();
268
+ // A launch failure after spawn still has a writer; the caller's stop must await its exit.
269
+ if (!this.child)
270
+ await this.authStore.stop();
219
271
  throw error;
220
272
  }
221
273
  }
@@ -229,7 +281,6 @@ export class Engine {
229
281
  return { port: this.port, token: this.token, identityPath };
230
282
  if (this.child && this.child.exitCode === null)
231
283
  return { port: this.port, token: this.token, identityPath };
232
- this.stalePid();
233
284
  this.state('starting');
234
285
  const { entry, env } = this.doctorContext();
235
286
  const fd = openSync(join(this.o.stateDir, 'logs', 'openclaw.log'), 'a', 0o600);
@@ -255,7 +306,7 @@ export class Engine {
255
306
  if (this.child !== child || this.stopping)
256
307
  return;
257
308
  this.child = undefined;
258
- rmSync(join(this.root, 'gateway.pid'), { force: true });
309
+ this.removeOwnedPid(child.pid);
259
310
  void (async () => {
260
311
  await this.authStore.stop();
261
312
  if (this.stopping)
@@ -275,6 +326,11 @@ export class Engine {
275
326
  });
276
327
  return { port: this.port, token: this.token, identityPath };
277
328
  }
329
+ removeOwnedPid(pid) {
330
+ const path = join(this.root, 'gateway.pid');
331
+ if (pid && existsSync(path) && readFileSync(path, 'utf8') === String(pid))
332
+ rmSync(path, { force: true });
333
+ }
278
334
  withAuthStore(task) { return this.authStore.offline(task); }
279
335
  async stop() {
280
336
  this.stopping = true;
@@ -299,10 +355,9 @@ export class Engine {
299
355
  if (child && child.exitCode === null && child.signalCode === null)
300
356
  throw new Error('engine did not stop; credential store still in use');
301
357
  this.child = undefined;
302
- rmSync(join(this.root, 'gateway.pid'), { force: true });
358
+ this.removeOwnedPid(child?.pid);
303
359
  if (!this.credentialsLocked)
304
360
  await this.authStore.stop();
305
- rmSync(this.bridgeSock, { force: true });
306
361
  this.state('stopped');
307
362
  }
308
363
  }
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.5",
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",