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.
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +6 -6
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +15 -7
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +15 -3
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +218 -130
- package/src/local/daemon-process.mjs +199 -0
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +115 -36
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
|
358
|
-
if ((
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 <
|
|
753
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
754
|
+
const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
|
|
711
755
|
try {
|
|
712
|
-
|
|
756
|
+
createExclusiveFileSync(file, `${JSON.stringify(owner)}
|
|
757
|
+
`, { mode: 0o600 });
|
|
713
758
|
return {
|
|
714
759
|
...(allowHandoff ? {
|
|
715
760
|
handoff(pid) {
|
|
716
|
-
if (Number.isInteger(pid)
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
const age = Date.now() -
|
|
726
|
-
const
|
|
727
|
-
const definitelyStale =
|
|
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
|
-
|
|
780
|
+
removePidLockSnapshot(file, snapshot);
|
|
730
781
|
}
|
|
731
782
|
}
|
|
732
783
|
return null;
|
|
733
784
|
}
|
|
734
785
|
|
|
735
|
-
function
|
|
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
|
|
878
|
+
function readRunnerOwner(dir, fallback = {}) {
|
|
765
879
|
try {
|
|
766
|
-
const
|
|
767
|
-
|
|
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
|
|
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)}
|
|
948
|
+
const text = `${JSON.stringify(value, null, 2)}
|
|
949
|
+
`;
|
|
820
950
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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 ||= {};
|
package/src/local/runtime.mjs
CHANGED
|
@@ -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
|
|
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`);
|