machine-bridge-mcp 0.11.0 → 0.12.0

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.
@@ -1,10 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
- import { replaceFileSync } from "./atomic-fs.mjs";
6
+ import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
8
+ import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
8
9
  import { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
9
10
 
10
11
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
@@ -23,7 +24,7 @@ const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted
23
24
  const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
24
25
 
25
26
  export class ManagedJobManager {
26
- constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", logger = console, recover = true }) {
27
+ constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", stateRoot = "", logger = console, recover = true }) {
27
28
  const jobRootInput = resolve(jobRoot);
28
29
  ensureOwnerOnlyDir(jobRootInput);
29
30
  this.jobRoot = realpathSync.native ? realpathSync.native(jobRootInput) : realpathSync(jobRootInput);
@@ -32,12 +33,15 @@ export class ManagedJobManager {
32
33
  this.policy = policy;
33
34
  this.resources = normalizeResourceRegistry(resources);
34
35
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
36
+ this.stateRoot = stateRoot ? resolve(stateRoot) : "";
35
37
  this.logger = logger;
38
+ this.assertMaintenanceAvailable();
36
39
  this.prune();
37
40
  if (recover) this.recoverInterruptedJobs();
38
41
  }
39
42
 
40
43
  status() {
44
+ this.assertMaintenanceAvailable();
41
45
  const jobs = this.list({ limit: MAX_JOBS }).jobs;
42
46
  return {
43
47
  active: jobs.filter((job) => ACTIVE_JOB_STATES.has(job.status)).length,
@@ -48,6 +52,7 @@ export class ManagedJobManager {
48
52
  }
49
53
 
50
54
  resourceInfo() {
55
+ this.assertMaintenanceAvailable();
51
56
  const resources = this.currentResources();
52
57
  return {
53
58
  count: Object.keys(resources).length,
@@ -57,6 +62,7 @@ export class ManagedJobManager {
57
62
  }
58
63
 
59
64
  listResources() {
65
+ this.assertMaintenanceAvailable();
60
66
  const resources = [];
61
67
  for (const [name, resource] of Object.entries(this.currentResources()).sort(([a], [b]) => a.localeCompare(b))) {
62
68
  try {
@@ -70,6 +76,7 @@ export class ManagedJobManager {
70
76
  }
71
77
 
72
78
  diagnoseStorage() {
79
+ this.assertMaintenanceAvailable();
73
80
  const probe = join(this.jobRoot, `.probe-${process.pid}-${randomBytes(6).toString("hex")}`);
74
81
  try {
75
82
  writeFileSync(probe, "ok\n", { mode: 0o600, flag: "wx" });
@@ -84,16 +91,19 @@ export class ManagedJobManager {
84
91
 
85
92
 
86
93
  stage(args = {}) {
94
+ this.assertMaintenanceAvailable();
87
95
  if (this.policy.allowWrite !== true) throw new Error("stage_job is disabled by daemon policy");
88
96
  return this.createJob(args, { launch: false });
89
97
  }
90
98
 
91
99
  start(args = {}) {
100
+ this.assertMaintenanceAvailable();
92
101
  this.assertEnabled("start_job");
93
102
  return this.createJob(args, { launch: true });
94
103
  }
95
104
 
96
105
  approve(args = {}, { localOperator = false } = {}) {
106
+ this.assertMaintenanceAvailable();
97
107
  if (!localOperator) this.assertEnabled("approve_job");
98
108
  const dir = this.jobDir(args.job_id);
99
109
  const transition = acquireJobTransitionLock(dir);
@@ -203,22 +213,29 @@ export class ManagedJobManager {
203
213
 
204
214
 
205
215
  list(args = {}) {
216
+ this.assertMaintenanceAvailable();
206
217
  this.prune();
207
218
  const limit = clampInt(args.limit, 20, 1, MAX_JOBS);
208
219
  const jobs = [];
209
220
  for (const entry of safeReadDir(this.jobRoot)) {
210
221
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
211
222
  const dir = join(this.jobRoot, entry.name);
212
- this.reconcileStatus(dir);
213
- const status = readJson(join(dir, "status.json"), 256 * 1024);
214
- if (!status) continue;
215
- jobs.push(publicStatus(status));
223
+ try {
224
+ this.reconcileStatus(dir);
225
+ const status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
226
+ if (!status) continue;
227
+ jobs.push(publicStatus(status));
228
+ } catch (error) {
229
+ this.logger.warn?.("managed job status is unreadable; retaining it for inspection", { error_class: resourceErrorClass(error) });
230
+ jobs.push({ job_id: entry.name, name: "unavailable", status: "unreadable", error_class: resourceErrorClass(error) });
231
+ }
216
232
  }
217
233
  jobs.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
218
234
  return { jobs: jobs.slice(0, limit), retained: jobs.length, maximum: MAX_JOBS };
219
235
  }
220
236
 
221
237
  read(args = {}) {
238
+ this.assertMaintenanceAvailable();
222
239
  const dir = this.jobDir(args.job_id);
223
240
  this.reconcileStatus(dir);
224
241
  const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
@@ -230,6 +247,7 @@ export class ManagedJobManager {
230
247
  }
231
248
 
232
249
  inspectLocal(args = {}) {
250
+ this.assertMaintenanceAvailable();
233
251
  const dir = this.jobDir(args.job_id);
234
252
  this.reconcileStatus(dir);
235
253
  const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
@@ -243,6 +261,7 @@ export class ManagedJobManager {
243
261
  }
244
262
 
245
263
  cancel(args = {}) {
264
+ this.assertMaintenanceAvailable();
246
265
  const dir = this.jobDir(args.job_id);
247
266
  const transition = acquireJobTransitionLock(dir);
248
267
  if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
@@ -286,12 +305,18 @@ export class ManagedJobManager {
286
305
  }
287
306
 
288
307
  currentResources() {
308
+ this.assertMaintenanceAvailable();
289
309
  if (!this.resourceStatePath) return this.resources;
290
- const state = readJson(this.resourceStatePath, 2 * 1024 * 1024);
291
- if (!state || typeof state !== "object" || Array.isArray(state)) return existsSync(this.resourceStatePath) ? {} : this.resources;
310
+ const state = readJson(this.resourceStatePath, 2 * 1024 * 1024, "resource state");
311
+ if (!state) return this.resources;
312
+ if (typeof state !== "object" || Array.isArray(state)) throw new Error("resource state is not a JSON object");
292
313
  return normalizeResourceRegistry(state.resources);
293
314
  }
294
315
 
316
+ assertMaintenanceAvailable() {
317
+ if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
318
+ }
319
+
295
320
  assertEnabled(tool) {
296
321
  if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") {
297
322
  throw new Error(`${tool} is disabled by daemon policy`);
@@ -307,14 +332,14 @@ export class ManagedJobManager {
307
332
  }
308
333
 
309
334
  reconcileStatus(dir) {
335
+ this.assertMaintenanceAvailable();
310
336
  const file = join(dir, "status.json");
311
337
  const initial = readJson(file, 256 * 1024);
312
338
  if (!initial || !ACTIVE_JOB_STATES.has(initial.status)) {
313
339
  if (initial) scrubFinishedPlan(dir, initial);
314
340
  return;
315
341
  }
316
- const initialPid = Number(initial.runner_pid) || readRunnerPid(dir);
317
- if (Number.isInteger(initialPid) && initialPid > 0 && isPidAlive(initialPid)) return;
342
+ if (runnerProcessIsCurrent(initial, dir)) return;
318
343
  const updated = Date.parse(initial.updated_at || initial.created_at || "");
319
344
  if (Number.isFinite(updated) && Date.now() - updated < 10_000) return;
320
345
 
@@ -324,14 +349,13 @@ export class ManagedJobManager {
324
349
  try {
325
350
  const status = readJson(file, 256 * 1024);
326
351
  if (!status || !ACTIVE_JOB_STATES.has(status.status)) return;
327
- const pid = Number(status.runner_pid) || readRunnerPid(dir);
328
- if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) return;
352
+ if (runnerProcessIsCurrent(status, dir)) return;
329
353
  const recoveryAttempts = Number(status.recovery_attempts || 0);
330
354
  if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
331
355
  markRecoveryExhausted(dir, file, status, recoveryAttempts);
332
356
  return;
333
357
  }
334
- const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts);
358
+ const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token);
335
359
  recoveryLock.handoff(runnerPid);
336
360
  handedOff = true;
337
361
  } finally {
@@ -340,22 +364,35 @@ export class ManagedJobManager {
340
364
  }
341
365
 
342
366
  recoverInterruptedJobs() {
367
+ this.assertMaintenanceAvailable();
343
368
  for (const entry of safeReadDir(this.jobRoot)) {
344
369
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
345
- this.reconcileStatus(join(this.jobRoot, entry.name));
370
+ try {
371
+ this.reconcileStatus(join(this.jobRoot, entry.name));
372
+ } catch (error) {
373
+ this.logger.warn?.("managed job recovery skipped unreadable state; retaining it for inspection", { error_class: resourceErrorClass(error) });
374
+ }
346
375
  }
347
376
  }
348
377
 
349
378
  prune() {
379
+ this.assertMaintenanceAvailable();
350
380
  const entries = [];
351
381
  for (const entry of safeReadDir(this.jobRoot)) {
352
382
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
353
383
  const dir = join(this.jobRoot, entry.name);
354
- const status = readJson(join(dir, "status.json"), 256 * 1024);
355
- const mtime = safeMtime(dir);
384
+ let status;
385
+ let mtime;
386
+ try {
387
+ status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
388
+ mtime = statSync(dir).mtimeMs;
389
+ } catch (error) {
390
+ this.logger.warn?.("managed job pruning skipped unreadable state; retaining it for inspection", { error_class: resourceErrorClass(error) });
391
+ continue;
392
+ }
356
393
  if (!status) {
357
- const pid = readRunnerPid(dir);
358
- if ((!pid || !isPidAlive(pid)) && Date.now() - mtime > 60_000) {
394
+ const runner = readRunnerOwner(dir, { startedAt: new Date(mtime).toISOString() });
395
+ if (!runnerProcessIsCurrent(runner, dir, { ownerOnly: true }) && Date.now() - mtime > 60_000) {
359
396
  rmSync(dir, { recursive: true, force: true });
360
397
  continue;
361
398
  }
@@ -384,13 +421,19 @@ export class ManagedJobManager {
384
421
 
385
422
  export function activeManagedJobs(jobRoot) {
386
423
  const root = resolve(jobRoot);
424
+ if (!existsSync(root)) return [];
387
425
  const jobs = [];
388
426
  for (const entry of safeReadDir(root)) {
389
427
  if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
390
428
  const dir = join(root, entry.name);
391
- const status = readJson(join(dir, "status.json"), 256 * 1024);
392
- const pid = Number(status?.runner_pid) || readRunnerPid(dir);
393
- const runnerAlive = Number.isInteger(pid) && pid > 0 && isPidAlive(pid);
429
+ let status;
430
+ try {
431
+ status = readJson(join(dir, "status.json"), 256 * 1024, "job status");
432
+ } catch (error) {
433
+ jobs.push({ job_id: entry.name, status: "unreadable", runner_alive: true, error_class: resourceErrorClass(error) });
434
+ continue;
435
+ }
436
+ const runnerAlive = runnerProcessIsCurrent(status, dir);
394
437
  const lifecycleActive = status && ACTIVE_JOB_STATES.has(status.status);
395
438
  if (runnerAlive || lifecycleActive) {
396
439
  jobs.push({
@@ -673,7 +716,7 @@ function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
673
716
  scrubFinishedPlan(dir, status);
674
717
  }
675
718
 
676
- function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
719
+ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken) {
677
720
  status.status = "interrupted";
678
721
  status.updated_at = new Date().toISOString();
679
722
  status.finished_at = status.updated_at;
@@ -682,7 +725,7 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
682
725
  atomicWriteJson(statusFile, status, 256 * 1024);
683
726
  rmSync(join(dir, "runtime"), { recursive: true, force: true });
684
727
  rmSync(join(dir, "runner.pid"), { force: true });
685
- return launchRunner(dir, true);
728
+ return launchRunner(dir, true, recoveryToken);
686
729
  }
687
730
 
688
731
  function acquireRecoveryLock(dir) {
@@ -707,32 +750,102 @@ function acquireJobTransitionLock(dir) {
707
750
  }
708
751
 
709
752
  function acquirePidLock(file, { allowHandoff = false } = {}) {
710
- for (let attempt = 0; attempt < 2; attempt += 1) {
753
+ for (let attempt = 0; attempt < 3; attempt += 1) {
754
+ const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
711
755
  try {
712
- writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
756
+ createExclusiveFileSync(file, `${JSON.stringify(owner)}
757
+ `, { mode: 0o600 });
713
758
  return {
714
759
  ...(allowHandoff ? {
715
760
  handoff(pid) {
716
- if (Number.isInteger(pid) && pid > 0) writeFileSync(file, `${pid}\n`, { mode: 0o600 });
761
+ if (!Number.isInteger(pid) || pid <= 0) return;
762
+ const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
763
+ replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}
764
+ `);
717
765
  },
718
766
  } : {}),
719
- release() { rmSync(file, { force: true }); },
767
+ token: owner.token,
768
+ release() { removePidLockOwnedBy(file, owner.token); },
720
769
  };
721
770
  } catch (error) {
722
771
  if (error?.code !== "EEXIST") throw error;
723
- let owner = 0;
724
- try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
725
- const age = Date.now() - safeMtime(file);
726
- const ownerAlive = owner > 0 && isPidAlive(owner);
727
- const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
772
+ const snapshot = readPidLockSnapshot(file);
773
+ if (!snapshot) continue;
774
+ const age = Date.now() - snapshot.info.mtimeMs;
775
+ const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
776
+ const definitelyStale = !snapshot.owner
777
+ ? age >= 60_000
778
+ : identity.reclaimable === true;
728
779
  if (!definitelyStale) return null;
729
- rmSync(file, { force: true });
780
+ removePidLockSnapshot(file, snapshot);
730
781
  }
731
782
  }
732
783
  return null;
733
784
  }
734
785
 
735
- function launchRunner(dir, recover = false) {
786
+ function pidLockOwner(pid, startedAtMs) {
787
+ return {
788
+ pid,
789
+ token: randomBytes(16).toString("hex"),
790
+ startedAt: new Date().toISOString(),
791
+ processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
792
+ };
793
+ }
794
+
795
+ function readPidLockOwner(file) {
796
+ return readPidLockSnapshot(file)?.owner || null;
797
+ }
798
+
799
+ function readPidLockSnapshot(file) {
800
+ let info;
801
+ try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
802
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
803
+ let owner = null;
804
+ try {
805
+ const text = readBoundedFile(file, 1024).toString("utf8").trim();
806
+ if (/^\d+$/.test(text)) owner = { pid: Number(text), startedAt: new Date(info.mtimeMs).toISOString() };
807
+ else {
808
+ const parsed = JSON.parse(text);
809
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
810
+ }
811
+ } catch (error) {
812
+ if (error?.code === "ENOENT") return null;
813
+ }
814
+ return { owner, info: pidLockIdentity(info) };
815
+ }
816
+
817
+ function removePidLockOwnedBy(file, token) {
818
+ const snapshot = readPidLockSnapshot(file);
819
+ if (!snapshot || snapshot.owner?.token !== token) return false;
820
+ return removePidLockSnapshot(file, snapshot);
821
+ }
822
+
823
+ function removePidLockSnapshot(file, snapshot) {
824
+ let current;
825
+ try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
826
+ if (current.isSymbolicLink() || !current.isFile()) return false;
827
+ if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
828
+ if (snapshot.owner?.token) {
829
+ const currentOwner = readPidLockSnapshot(file)?.owner;
830
+ if (currentOwner?.token !== snapshot.owner.token) return false;
831
+ }
832
+ try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
833
+ }
834
+
835
+ function pidLockIdentity(info) {
836
+ return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
837
+ }
838
+
839
+ function samePidLockIdentity(left, right) {
840
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
841
+ }
842
+
843
+ function replacePrivateTextFile(file, content) {
844
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
845
+ ownerOnlyFile(file);
846
+ }
847
+
848
+ function launchRunner(dir, recover = false, recoveryToken = "") {
736
849
  const args = [RUNNER_PATH, "--job-dir", dir];
737
850
  if (recover) args.push("--recover");
738
851
  const stdoutFile = join(dir, "runner.out.log");
@@ -749,6 +862,7 @@ function launchRunner(dir, recover = false) {
749
862
  detached: true,
750
863
  stdio: ["ignore", stdoutFd, stderrFd],
751
864
  windowsHide: true,
865
+ env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
752
866
  });
753
867
  } finally {
754
868
  if (stdoutFd !== undefined) closeSync(stdoutFd);
@@ -761,15 +875,30 @@ function launchRunner(dir, recover = false) {
761
875
  }
762
876
 
763
877
 
764
- function readRunnerPid(dir) {
878
+ function readRunnerOwner(dir, fallback = {}) {
765
879
  try {
766
- const value = Number.parseInt(readBoundedFile(join(dir, "runner.pid"), 64).toString("utf8").trim(), 10);
767
- return Number.isInteger(value) && value > 0 ? value : 0;
880
+ const text = readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8").trim();
881
+ if (/^\d+$/.test(text)) return { pid: Number(text), ...fallback };
882
+ const parsed = JSON.parse(text);
883
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
884
+ return { ...fallback, ...parsed };
768
885
  } catch {
769
- return 0;
886
+ return { ...fallback };
770
887
  }
771
888
  }
772
889
 
890
+ function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
891
+ const fallback = ownerOnly ? status : {
892
+ pid: Number(status?.runner_pid) || undefined,
893
+ processStartedAt: status?.runner_process_started_at,
894
+ startedAt: status?.started_at || status?.updated_at || status?.created_at,
895
+ };
896
+ const owner = readRunnerOwner(dir, fallback);
897
+ if (!owner.pid) return false;
898
+ const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
899
+ return identity.current || (identity.alive && !identity.reclaimable);
900
+ }
901
+
773
902
  function scrubFinishedPlan(dir, status) {
774
903
  if (PLAN_RETAINING_STATES.has(status.status)) return;
775
904
  rmSync(join(dir, "plan.json"), { force: true });
@@ -816,20 +945,30 @@ function publicStatus(status) {
816
945
 
817
946
  function atomicWriteJson(file, value, maxBytes) {
818
947
  ensureOwnerOnlyDir(dirname(file));
819
- const text = `${JSON.stringify(value, null, 2)}\n`;
948
+ const text = `${JSON.stringify(value, null, 2)}
949
+ `;
820
950
  if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
821
- const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
822
- writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
823
- replaceFileSync(temp, file);
951
+ replaceFileAtomicallySync(file, text, { mode: 0o600 });
824
952
  ownerOnlyFile(file);
825
953
  }
826
954
 
827
- function readJson(file, maxBytes) {
828
- try { return JSON.parse(readBoundedFile(file, maxBytes).toString("utf8")); } catch { return null; }
955
+ function readJson(file, maxBytes, label = "JSON") {
956
+ let buffer;
957
+ try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
958
+ if (error?.code === "ENOENT") return null;
959
+ throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
960
+ }
961
+ let text;
962
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
963
+ throw new Error(`${label} is not valid UTF-8`);
964
+ }
965
+ try { return JSON.parse(text); } catch {
966
+ throw new Error(`${label} is not valid JSON`);
967
+ }
829
968
  }
830
969
 
831
970
  function readRequiredJson(file, maxBytes, label) {
832
- const value = readJson(file, maxBytes);
971
+ const value = readJson(file, maxBytes, label);
833
972
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
834
973
  return value;
835
974
  }
@@ -900,15 +1039,7 @@ function resourceErrorClass(error) {
900
1039
  }
901
1040
 
902
1041
  function safeReadDir(dir) {
903
- try { return readdirSync(dir, { withFileTypes: true }); } catch { return []; }
904
- }
905
-
906
- function safeMtime(path) {
907
- try { return statSync(path).mtimeMs; } catch { return 0; }
908
- }
909
-
910
- function isPidAlive(pid) {
911
- try { process.kill(pid, 0); return true; } catch (error) { return error?.code === "EPERM"; }
1042
+ return readdirSync(dir, { withFileTypes: true });
912
1043
  }
913
1044
 
914
1045
  function boundedString(value, maxBytes, label) {
@@ -0,0 +1,143 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ const COMMAND_TIMEOUT_MS = 3000;
5
+ const COMMAND_OUTPUT_BYTES = 256 * 1024;
6
+ const START_TIME_TOLERANCE_MS = 15_000;
7
+
8
+ export function isPidAlive(pid) {
9
+ const parsed = normalizePid(pid);
10
+ if (!parsed) return false;
11
+ try {
12
+ process.kill(parsed, 0);
13
+ return true;
14
+ } catch (error) {
15
+ return error?.code === "EPERM";
16
+ }
17
+ }
18
+
19
+ export function currentProcessStartTimeMs() {
20
+ const value = Number(performance.timeOrigin);
21
+ return Number.isFinite(value) && value > 0 ? value : Date.now();
22
+ }
23
+
24
+ export function processStartTimeMs(pid) {
25
+ const parsed = normalizePid(pid);
26
+ if (!parsed) return null;
27
+ if (parsed === process.pid) return currentProcessStartTimeMs();
28
+ if (process.platform === "win32") {
29
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${parsed}").CreationDate.ToUniversalTime().ToString('o')`;
30
+ const result = runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command]);
31
+ return result.ok ? parseTime(result.stdout) : null;
32
+ }
33
+ const result = runBounded("ps", ["-p", String(parsed), "-o", "lstart="]);
34
+ return result.ok ? parseTime(result.stdout) : null;
35
+ }
36
+
37
+ export function processCommandLine(pid) {
38
+ const parsed = normalizePid(pid);
39
+ if (!parsed) return "";
40
+ if (process.platform === "win32") {
41
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${parsed}").CommandLine`;
42
+ const result = runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command]);
43
+ return result.ok ? result.stdout.trim() : "";
44
+ }
45
+ const result = runBounded("ps", ["-ww", "-p", String(parsed), "-o", "command="]);
46
+ return result.ok ? result.stdout.trim() : "";
47
+ }
48
+
49
+ export function splitProcessCommandLine(value) {
50
+ const args = [];
51
+ let current = "";
52
+ let quote = "";
53
+ const text = String(value || "");
54
+ for (let index = 0; index < text.length; index += 1) {
55
+ const character = text[index];
56
+ if (quote) {
57
+ if (character === quote) quote = "";
58
+ else if (character === "\\" && quote === '"' && ['"', "\\"].includes(text[index + 1])) {
59
+ current += text[index + 1];
60
+ index += 1;
61
+ } else current += character;
62
+ continue;
63
+ }
64
+ if (character === '"' || character === "'") {
65
+ quote = character;
66
+ continue;
67
+ }
68
+ if (/\s/.test(character)) {
69
+ if (current) {
70
+ args.push(current);
71
+ current = "";
72
+ }
73
+ continue;
74
+ }
75
+ if (character === "\\" && /[\s'"\\]/.test(text[index + 1] || "")) {
76
+ current += text[index + 1];
77
+ index += 1;
78
+ continue;
79
+ }
80
+ current += character;
81
+ }
82
+ if (current) args.push(current);
83
+ return args;
84
+ }
85
+
86
+ export function inspectProcessInstance(owner, options = {}) {
87
+ const pid = normalizePid(owner?.pid);
88
+ if (!pid) return { current: false, alive: false, reclaimable: true, reason: "invalid_pid", pid: null };
89
+ const alive = (options.isAlive || isPidAlive)(pid);
90
+ if (!alive) return { current: false, alive: false, reclaimable: true, reason: "not_running", pid };
91
+
92
+ const now = Number.isFinite(options.now) ? Number(options.now) : Date.now();
93
+ const lockStartedAt = parseTime(owner?.startedAt);
94
+ if (!lockStartedAt) return { current: false, alive: true, reclaimable: false, reason: "invalid_lock_timestamp", pid };
95
+ if (lockStartedAt > now + START_TIME_TOLERANCE_MS) return { current: false, alive: true, reclaimable: false, reason: "future_lock_timestamp", pid };
96
+
97
+ const observedStart = (options.getProcessStartTime || processStartTimeMs)(pid);
98
+ const recordedStart = parseTime(owner?.processStartedAt);
99
+ if (Number.isFinite(observedStart) && observedStart > 0) {
100
+ if (recordedStart && Math.abs(observedStart - recordedStart) > START_TIME_TOLERANCE_MS) {
101
+ return { current: false, alive: true, reclaimable: true, reason: "pid_reused", pid, process_started_at: observedStart };
102
+ }
103
+ if (!recordedStart && lockStartedAt + START_TIME_TOLERANCE_MS < observedStart) {
104
+ return { current: false, alive: true, reclaimable: true, reason: "pid_reused", pid, process_started_at: observedStart };
105
+ }
106
+ }
107
+ const maxAgeMs = Number(options.maxAgeMs);
108
+ if (Number.isFinite(maxAgeMs) && maxAgeMs > 0 && now - lockStartedAt > maxAgeMs) {
109
+ return { current: false, alive: true, reclaimable: false, reason: "lock_expired", pid, age_ms: now - lockStartedAt };
110
+ }
111
+ return {
112
+ current: true,
113
+ alive: true,
114
+ reclaimable: false,
115
+ reason: "current_process",
116
+ pid,
117
+ age_ms: Math.max(0, now - lockStartedAt),
118
+ process_started_at: Number.isFinite(observedStart) ? observedStart : null,
119
+ };
120
+ }
121
+
122
+ function runBounded(command, args) {
123
+ const result = spawnSync(command, args, {
124
+ encoding: "utf8",
125
+ timeout: COMMAND_TIMEOUT_MS,
126
+ maxBuffer: COMMAND_OUTPUT_BYTES,
127
+ windowsHide: true,
128
+ env: process.platform === "win32" ? process.env : { ...process.env, LC_ALL: "C", LANG: "C" },
129
+ stdio: ["ignore", "pipe", "ignore"],
130
+ });
131
+ return { ok: !result.error && result.status === 0, stdout: String(result.stdout || "") };
132
+ }
133
+
134
+ function normalizePid(value) {
135
+ const parsed = Number(value);
136
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
137
+ }
138
+
139
+ function parseTime(value) {
140
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : null;
141
+ const parsed = Date.parse(String(value || "").trim());
142
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
143
+ }
@@ -3,15 +3,14 @@ import { rm } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
5
5
  import { generateSshKeyPair } from "./ssh-key.mjs";
6
- import { acquireStartupLock, loadState, saveState } from "./state.mjs";
6
+ import { acquireStartupLockWithWait, loadState, saveState } from "./state.mjs";
7
7
 
8
8
  export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
9
9
  const name = validateResourceName(rawName);
10
10
  if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
11
11
  const target = resolve(targetPath);
12
12
  const state = loadState(workspace, { stateDir });
13
- const lock = acquireStartupLock(state);
14
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
13
+ const lock = await acquireStartupLockWithWait(state, { operation: "generate-ssh-key" });
15
14
  let key = null;
16
15
  try {
17
16
  state.resources ||= {};
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
3
+ import { constants as fsConstants, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
4
4
  import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -108,6 +108,7 @@ export class LocalRuntime {
108
108
  policy: this.policy,
109
109
  resources,
110
110
  resourceStatePath,
111
+ stateRoot: browserStateRoot,
111
112
  logger: this.logger,
112
113
  recover: recoverJobs,
113
114
  });
@@ -1101,7 +1102,8 @@ function assertContainedPath(root, target) {
1101
1102
  }
1102
1103
 
1103
1104
  async function readBoundedFile(filePath, maxBytes, label) {
1104
- const handle = await open(filePath, "r");
1105
+ const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
1106
+ const handle = await open(filePath, flags);
1105
1107
  try {
1106
1108
  const info = await handle.stat();
1107
1109
  if (!info.isFile()) throw new Error(`${label} is not a regular file`);