machine-bridge-mcp 1.2.7 → 1.2.9

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +37 -19
  3. package/README.md +120 -410
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +13 -4
  7. package/docs/AUDIT.md +22 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/OVERVIEW.md +113 -0
  10. package/docs/PROJECT_STANDARDS.md +9 -5
  11. package/docs/RELEASING.md +78 -33
  12. package/docs/TESTING.md +14 -7
  13. package/docs/THREAT_MODEL.md +142 -0
  14. package/package.json +18 -6
  15. package/scripts/check-plan.mjs +91 -0
  16. package/scripts/coverage-check.mjs +22 -0
  17. package/scripts/github-push.mjs +49 -0
  18. package/scripts/github-release.mjs +15 -8
  19. package/scripts/local-release-acceptance.mjs +186 -0
  20. package/scripts/release-acceptance.mjs +181 -0
  21. package/scripts/release-impact-check.mjs +19 -3
  22. package/scripts/release-state.mjs +1 -1
  23. package/scripts/run-checks.mjs +29 -0
  24. package/scripts/start-release-candidate.mjs +113 -0
  25. package/src/local/agent-context-projection.mjs +158 -0
  26. package/src/local/agent-context.mjs +23 -332
  27. package/src/local/agent-skill-discovery.mjs +230 -0
  28. package/src/local/agent-text-file.mjs +41 -0
  29. package/src/local/browser-bridge-http.mjs +48 -0
  30. package/src/local/browser-bridge.mjs +48 -222
  31. package/src/local/browser-broker-routes.mjs +136 -0
  32. package/src/local/browser-broker-server.mjs +59 -0
  33. package/src/local/browser-request-registry.mjs +67 -0
  34. package/src/local/managed-job-lock.mjs +99 -0
  35. package/src/local/managed-job-projection.mjs +68 -0
  36. package/src/local/managed-job-runner.mjs +73 -0
  37. package/src/local/managed-job-storage.mjs +93 -0
  38. package/src/local/managed-jobs.mjs +12 -297
  39. package/src/local/runtime-paths.mjs +107 -0
  40. package/src/local/runtime-relay.mjs +73 -0
  41. package/src/local/runtime-tool-handlers.mjs +66 -0
  42. package/src/local/runtime.mjs +22 -204
  43. package/src/local/windows-launcher.mjs +57 -0
  44. package/src/local/windows-service.mjs +7 -56
  45. package/src/worker/index.ts +9 -118
  46. package/src/worker/mcp-jsonrpc.ts +94 -0
  47. package/src/worker/oauth-authorization-page.ts +70 -0
  48. package/src/worker/oauth-controller.ts +9 -58
  49. package/src/worker/websocket-protocol.ts +24 -0
  50. package/tsconfig.local.json +6 -1
@@ -0,0 +1,99 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { lstatSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
5
+ import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
6
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
7
+ import { ownerOnlyFile } from "./state.mjs";
8
+
9
+ export function acquireRecoveryLock(dir) {
10
+ return acquirePidLock(join(dir, "recovery.lock"), { allowHandoff: true });
11
+ }
12
+
13
+ export function acquireJobTransitionLock(dir) {
14
+ return acquirePidLock(join(dir, "transition.lock"));
15
+ }
16
+
17
+ function acquirePidLock(file, { allowHandoff = false } = {}) {
18
+ for (let attempt = 0; attempt < 3; attempt += 1) {
19
+ const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
20
+ try {
21
+ createExclusiveFileSync(file, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
22
+ return {
23
+ ...(allowHandoff ? {
24
+ handoff(pid) {
25
+ if (!Number.isInteger(pid) || pid <= 0) return;
26
+ const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
27
+ replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}\n`);
28
+ },
29
+ } : {}),
30
+ token: owner.token,
31
+ release() { removePidLockOwnedBy(file, owner.token); },
32
+ };
33
+ } catch (error) {
34
+ if (error?.code !== "EEXIST") throw error;
35
+ const snapshot = readPidLockSnapshot(file);
36
+ if (!snapshot) continue;
37
+ const age = Date.now() - snapshot.info.mtimeMs;
38
+ const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
39
+ const definitelyStale = !snapshot.owner ? age >= 60_000 : identity.reclaimable === true;
40
+ if (!definitelyStale) return null;
41
+ removePidLockSnapshot(file, snapshot);
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function pidLockOwner(pid, startedAtMs) {
48
+ return {
49
+ pid,
50
+ token: randomBytes(16).toString("hex"),
51
+ startedAt: new Date().toISOString(),
52
+ processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
53
+ };
54
+ }
55
+
56
+ function readPidLockSnapshot(file) {
57
+ let info;
58
+ try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
59
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
60
+ let owner = null;
61
+ try {
62
+ const parsed = JSON.parse(readBoundedRegularFileSync(file, 1024).toString("utf8").trim());
63
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
64
+ } catch (error) {
65
+ if (error?.code === "ENOENT") return null;
66
+ }
67
+ return { owner, info: pidLockIdentity(info) };
68
+ }
69
+
70
+ function removePidLockOwnedBy(file, token) {
71
+ const snapshot = readPidLockSnapshot(file);
72
+ if (!snapshot || snapshot.owner?.token !== token) return false;
73
+ return removePidLockSnapshot(file, snapshot);
74
+ }
75
+
76
+ function removePidLockSnapshot(file, snapshot) {
77
+ let current;
78
+ try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
79
+ if (current.isSymbolicLink() || !current.isFile()) return false;
80
+ if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
81
+ if (snapshot.owner?.token) {
82
+ const currentOwner = readPidLockSnapshot(file)?.owner;
83
+ if (currentOwner?.token !== snapshot.owner.token) return false;
84
+ }
85
+ try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
86
+ }
87
+
88
+ function pidLockIdentity(info) {
89
+ return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
90
+ }
91
+
92
+ function samePidLockIdentity(left, right) {
93
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
94
+ }
95
+
96
+ function replacePrivateTextFile(file, content) {
97
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
98
+ ownerOnlyFile(file);
99
+ }
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Return the review-safe job plan projection. Resource paths and values are absent by contract.
5
+ * @param {{
6
+ * version?: unknown,
7
+ * name?: unknown,
8
+ * workspace?: unknown,
9
+ * full_env?: unknown,
10
+ * resources?: Record<string, {kind?: unknown, size?: unknown, mode?: unknown, allowInsecurePermissions?: unknown}>,
11
+ * temporary_files?: unknown[],
12
+ * steps?: unknown[],
13
+ * finally_steps?: unknown[]
14
+ * }} plan
15
+ */
16
+ export function reviewablePlan(plan) {
17
+ return {
18
+ version: plan.version,
19
+ name: plan.name,
20
+ workspace: plan.workspace,
21
+ full_env: plan.full_env === true,
22
+ resources: Object.fromEntries(Object.entries(plan.resources || {}).map(([name, value]) => [name, {
23
+ kind: value.kind,
24
+ size: value.size ?? null,
25
+ mode: value.mode ?? null,
26
+ allow_insecure_permissions: value.allowInsecurePermissions === true,
27
+ }])),
28
+ temporary_files: plan.temporary_files || [],
29
+ steps: plan.steps || [],
30
+ finally_steps: plan.finally_steps || [],
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Return the stable public status shape without runner identity or internal paths.
36
+ * @param {{
37
+ * job_id?: unknown,
38
+ * name?: unknown,
39
+ * status?: unknown,
40
+ * created_at?: unknown,
41
+ * started_at?: unknown,
42
+ * finished_at?: unknown,
43
+ * current_phase?: unknown,
44
+ * current_step?: unknown,
45
+ * approval?: unknown,
46
+ * plan_sha256?: unknown,
47
+ * cleanup_guarantee?: unknown,
48
+ * error_class?: unknown,
49
+ * recovery_attempts?: unknown
50
+ * }} status
51
+ */
52
+ export function publicStatus(status) {
53
+ return {
54
+ job_id: status.job_id,
55
+ name: status.name,
56
+ status: status.status,
57
+ created_at: status.created_at,
58
+ started_at: status.started_at ?? null,
59
+ finished_at: status.finished_at ?? null,
60
+ current_phase: status.current_phase ?? null,
61
+ current_step: status.current_step ?? null,
62
+ approval: status.approval ?? null,
63
+ plan_sha256: status.plan_sha256 ?? null,
64
+ cleanup_guarantee: status.cleanup_guarantee ?? "best-effort-finally-and-recovery",
65
+ error_class: status.error_class ?? null,
66
+ recovery_attempts: Number(status.recovery_attempts || 0),
67
+ };
68
+ }
@@ -0,0 +1,73 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { inspectProcessInstance } from "./process-identity.mjs";
6
+ import { classifyOperationalError } from "./log.mjs";
7
+ import { ownerOnlyFile } from "./state.mjs";
8
+ import { openPrivateAppendFile, readBoundedFile, trimDiagnosticFile } from "./managed-job-storage.mjs";
9
+
10
+ const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
11
+
12
+ export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
13
+ const args = [RUNNER_PATH, "--job-dir", dir];
14
+ if (recover) args.push("--recover");
15
+ const stdoutFile = join(dir, "runner.out.log");
16
+ const stderrFile = join(dir, "runner.err.log");
17
+ trimDiagnosticFile(stdoutFile);
18
+ trimDiagnosticFile(stderrFile);
19
+ let stdoutFd;
20
+ let stderrFd;
21
+ let child;
22
+ try {
23
+ stdoutFd = openPrivateAppendFile(stdoutFile);
24
+ stderrFd = openPrivateAppendFile(stderrFile);
25
+ const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
26
+ child = spawnProcess(process.execPath, args, {
27
+ detached: true,
28
+ stdio: ["ignore", stdoutFd, stderrFd],
29
+ windowsHide: true,
30
+ shell: false,
31
+ env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
32
+ });
33
+ } finally {
34
+ if (stdoutFd !== undefined) closeSync(stdoutFd);
35
+ if (stderrFd !== undefined) closeSync(stderrFd);
36
+ }
37
+ ownerOnlyFile(stdoutFile);
38
+ ownerOnlyFile(stderrFile);
39
+ const logger = options.logger || console;
40
+ child.once?.("error", (error) => {
41
+ logger.error?.("managed job runner process reported an asynchronous failure", {
42
+ job_id: basename(dir),
43
+ recovery: recover,
44
+ error_class: classifyOperationalError(error),
45
+ });
46
+ });
47
+ const pid = Number(child.pid);
48
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
49
+ child.unref();
50
+ return pid;
51
+ }
52
+
53
+ export function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
54
+ const fallback = ownerOnly ? status : {
55
+ pid: Number(status?.runner_pid) || undefined,
56
+ processStartedAt: status?.runner_process_started_at,
57
+ startedAt: status?.started_at || status?.updated_at || status?.created_at,
58
+ };
59
+ const owner = readRunnerOwner(dir, fallback);
60
+ if (!owner.pid) return false;
61
+ const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
62
+ return identity.current || (identity.alive && !identity.reclaimable);
63
+ }
64
+
65
+ function readRunnerOwner(dir, fallback = {}) {
66
+ try {
67
+ const parsed = JSON.parse(readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8"));
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
69
+ return { ...fallback, ...parsed };
70
+ } catch {
71
+ return { ...fallback };
72
+ }
73
+ }
@@ -0,0 +1,93 @@
1
+ import { closeSync, constants as fsConstants, ftruncateSync, readSync, readdirSync, writeSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
4
+ import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
5
+ import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
6
+
7
+ export function atomicWriteJson(file, value, maxBytes) {
8
+ ensureOwnerOnlyDir(dirname(file));
9
+ const text = `${JSON.stringify(value, null, 2)}\n`;
10
+ if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
11
+ replaceFileAtomicallySync(file, text, { mode: 0o600 });
12
+ ownerOnlyFile(file);
13
+ }
14
+
15
+ export function readJson(file, maxBytes, label = "JSON") {
16
+ let buffer;
17
+ try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
18
+ if (error?.code === "ENOENT") return null;
19
+ throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
20
+ }
21
+ let text;
22
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
23
+ throw new Error(`${label} is not valid UTF-8`);
24
+ }
25
+ try { return JSON.parse(text); } catch {
26
+ throw new Error(`${label} is not valid JSON`);
27
+ }
28
+ }
29
+
30
+ export function readRequiredJson(file, maxBytes, label) {
31
+ const value = readJson(file, maxBytes, label);
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
33
+ return value;
34
+ }
35
+
36
+ export function readBoundedFile(file, maxBytes) {
37
+ return readBoundedRegularFileSync(file, maxBytes);
38
+ }
39
+
40
+ export function openPrivateAppendFile(file) {
41
+ return openRegularFileSync(
42
+ file,
43
+ Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
44
+ { label: "runner diagnostic path", mode: 0o600, chmod: 0o600 },
45
+ ).fd;
46
+ }
47
+
48
+ export function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
49
+ let fd;
50
+ try {
51
+ const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
52
+ label: "runner diagnostic path",
53
+ chmod: 0o600,
54
+ });
55
+ fd = opened.fd;
56
+ if (opened.info.size <= maxBytes) return;
57
+ const length = Math.min(keepBytes, opened.info.size);
58
+ const buffer = Buffer.alloc(length);
59
+ let offset = 0;
60
+ while (offset < length) {
61
+ const count = readSync(fd, buffer, offset, length - offset, opened.info.size - length + offset);
62
+ if (!count) break;
63
+ offset += count;
64
+ }
65
+ let tail = buffer.subarray(0, offset);
66
+ const newline = tail.indexOf(0x0a);
67
+ if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
68
+ ftruncateSync(fd, 0);
69
+ if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
70
+ } catch (error) {
71
+ if (error?.code !== "ENOENT") throw error;
72
+ } finally {
73
+ if (fd !== undefined) {
74
+ try { closeSync(fd); } catch {
75
+ // Descriptor close is best effort after the trim result is already determined.
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ export function resourceErrorClass(error) {
82
+ const message = String(error?.message || error || "");
83
+ if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
84
+ if (/not found|ENOENT/i.test(message)) return "not_found";
85
+ if (/symbolic link/i.test(message)) return "symbolic_link_denied";
86
+ if (/readable by group|permissions/i.test(message)) return "insecure_permissions";
87
+ if (/exceeds/i.test(message)) return "size_limit";
88
+ return "resource_unavailable";
89
+ }
90
+
91
+ export function safeReadDir(dir) {
92
+ return readdirSync(dir, { withFileTypes: true });
93
+ }
@@ -1,25 +1,25 @@
1
- import { spawn } from "node:child_process";
2
1
  import { createHash, randomBytes } from "node:crypto";
3
- import { closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
4
- import { basename, dirname, join, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
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";
9
- import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
2
+ import { existsSync, lstatSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir } from "./state.mjs";
10
5
  import { createToolAuthorizer } from "./policy.mjs";
11
6
  import { BridgeError } from "./errors.mjs";
12
7
  import { inspectResourceFile, normalizeResourceRegistry, validatePlan } from "./managed-job-plan.mjs";
13
8
  export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
14
9
  import { clampInteger } from "./numbers.mjs";
15
- import { classifyOperationalError } from "./log.mjs";
10
+ import { acquireJobTransitionLock, acquireRecoveryLock } from "./managed-job-lock.mjs";
11
+ import { publicStatus, reviewablePlan } from "./managed-job-projection.mjs";
12
+ import {
13
+ atomicWriteJson, readBoundedFile, readJson, readRequiredJson, resourceErrorClass, safeReadDir,
14
+ } from "./managed-job-storage.mjs";
15
+ import { launchRunner, runnerProcessIsCurrent } from "./managed-job-runner.mjs";
16
+ export { launchRunner } from "./managed-job-runner.mjs";
16
17
 
17
18
  const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
18
19
  const MAX_JOBS = 50;
19
20
  const JOB_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
20
21
  const MAX_PLAN_BYTES = 1024 * 1024;
21
22
  const MAX_RECOVERY_ATTEMPTS = 3;
22
- const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
23
23
  const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted"]);
24
24
  const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
25
25
 
@@ -393,8 +393,8 @@ export class ManagedJobManager {
393
393
  continue;
394
394
  }
395
395
  if (!status) {
396
- const runner = readRunnerOwner(dir, { startedAt: new Date(mtime).toISOString() });
397
- if (!runnerProcessIsCurrent(runner, dir, { ownerOnly: true }) && Date.now() - mtime > 60_000) {
396
+ const fallbackOwner = { startedAt: new Date(mtime).toISOString() };
397
+ if (!runnerProcessIsCurrent(fallbackOwner, dir, { ownerOnly: true }) && Date.now() - mtime > 60_000) {
398
398
  rmSync(dir, { recursive: true, force: true });
399
399
  continue;
400
400
  }
@@ -526,10 +526,6 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recov
526
526
  return launchRunner(dir, true, recoveryToken, { logger });
527
527
  }
528
528
 
529
- function acquireRecoveryLock(dir) {
530
- return acquirePidLock(join(dir, "recovery.lock"), { allowHandoff: true });
531
- }
532
-
533
529
  function planSha256(plan) {
534
530
  return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
535
531
  }
@@ -543,162 +539,6 @@ function assertPlanIntegrity(plan, status) {
543
539
  return actual;
544
540
  }
545
541
 
546
- function acquireJobTransitionLock(dir) {
547
- return acquirePidLock(join(dir, "transition.lock"));
548
- }
549
-
550
- function acquirePidLock(file, { allowHandoff = false } = {}) {
551
- for (let attempt = 0; attempt < 3; attempt += 1) {
552
- const owner = pidLockOwner(process.pid, currentProcessStartTimeMs());
553
- try {
554
- createExclusiveFileSync(file, `${JSON.stringify(owner)}
555
- `, { mode: 0o600 });
556
- return {
557
- ...(allowHandoff ? {
558
- handoff(pid) {
559
- if (!Number.isInteger(pid) || pid <= 0) return;
560
- const nextOwner = { ...pidLockOwner(pid, processStartTimeMs(pid)), token: owner.token };
561
- replacePrivateTextFile(file, `${JSON.stringify(nextOwner)}
562
- `);
563
- },
564
- } : {}),
565
- token: owner.token,
566
- release() { removePidLockOwnedBy(file, owner.token); },
567
- };
568
- } catch (error) {
569
- if (error?.code !== "EEXIST") throw error;
570
- const snapshot = readPidLockSnapshot(file);
571
- if (!snapshot) continue;
572
- const age = Date.now() - snapshot.info.mtimeMs;
573
- const identity = snapshot.owner ? inspectProcessInstance(snapshot.owner, { maxAgeMs: 5 * 60_000 }) : null;
574
- const definitelyStale = !snapshot.owner
575
- ? age >= 60_000
576
- : identity.reclaimable === true;
577
- if (!definitelyStale) return null;
578
- removePidLockSnapshot(file, snapshot);
579
- }
580
- }
581
- return null;
582
- }
583
-
584
- function pidLockOwner(pid, startedAtMs) {
585
- return {
586
- pid,
587
- token: randomBytes(16).toString("hex"),
588
- startedAt: new Date().toISOString(),
589
- processStartedAt: Number.isFinite(startedAtMs) && startedAtMs > 0 ? new Date(startedAtMs).toISOString() : null,
590
- };
591
- }
592
-
593
- function readPidLockSnapshot(file) {
594
- let info;
595
- try { info = lstatSync(file); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
596
- if (info.isSymbolicLink() || !info.isFile()) throw new Error("job lock must be a regular non-symbolic-link file");
597
- let owner = null;
598
- try {
599
- const parsed = JSON.parse(readBoundedFile(file, 1024).toString("utf8").trim());
600
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) owner = parsed;
601
- } catch (error) {
602
- if (error?.code === "ENOENT") return null;
603
- }
604
- return { owner, info: pidLockIdentity(info) };
605
- }
606
-
607
- function removePidLockOwnedBy(file, token) {
608
- const snapshot = readPidLockSnapshot(file);
609
- if (!snapshot || snapshot.owner?.token !== token) return false;
610
- return removePidLockSnapshot(file, snapshot);
611
- }
612
-
613
- function removePidLockSnapshot(file, snapshot) {
614
- let current;
615
- try { current = lstatSync(file); } catch (error) { return error?.code === "ENOENT"; }
616
- if (current.isSymbolicLink() || !current.isFile()) return false;
617
- if (!samePidLockIdentity(snapshot.info, pidLockIdentity(current))) return false;
618
- if (snapshot.owner?.token) {
619
- const currentOwner = readPidLockSnapshot(file)?.owner;
620
- if (currentOwner?.token !== snapshot.owner.token) return false;
621
- }
622
- try { rmSync(file); return true; } catch (error) { return error?.code === "ENOENT"; }
623
- }
624
-
625
- function pidLockIdentity(info) {
626
- return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs) };
627
- }
628
-
629
- function samePidLockIdentity(left, right) {
630
- return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
631
- }
632
-
633
- function replacePrivateTextFile(file, content) {
634
- replaceFileAtomicallySync(file, content, { mode: 0o600 });
635
- ownerOnlyFile(file);
636
- }
637
-
638
- export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
639
- const args = [RUNNER_PATH, "--job-dir", dir];
640
- if (recover) args.push("--recover");
641
- const stdoutFile = join(dir, "runner.out.log");
642
- const stderrFile = join(dir, "runner.err.log");
643
- trimDiagnosticFile(stdoutFile);
644
- trimDiagnosticFile(stderrFile);
645
- let stdoutFd;
646
- let stderrFd;
647
- let child;
648
- try {
649
- stdoutFd = openPrivateAppendFile(stdoutFile);
650
- stderrFd = openPrivateAppendFile(stderrFile);
651
- const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
652
- child = spawnProcess(process.execPath, args, {
653
- detached: true,
654
- stdio: ["ignore", stdoutFd, stderrFd],
655
- windowsHide: true,
656
- shell: false,
657
- env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
658
- });
659
- } finally {
660
- if (stdoutFd !== undefined) closeSync(stdoutFd);
661
- if (stderrFd !== undefined) closeSync(stderrFd);
662
- }
663
- ownerOnlyFile(stdoutFile);
664
- ownerOnlyFile(stderrFile);
665
- const logger = options.logger || console;
666
- child.once?.("error", (error) => {
667
- logger.error?.("managed job runner process reported an asynchronous failure", {
668
- job_id: basename(dir),
669
- recovery: recover,
670
- error_class: classifyOperationalError(error),
671
- });
672
- });
673
- const pid = Number(child.pid);
674
- if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
675
- child.unref();
676
- return pid;
677
- }
678
-
679
-
680
- function readRunnerOwner(dir, fallback = {}) {
681
- try {
682
- const parsed = JSON.parse(readBoundedFile(join(dir, "runner.pid"), 1024).toString("utf8"));
683
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ...fallback };
684
- return { ...fallback, ...parsed };
685
- } catch {
686
- return { ...fallback };
687
- }
688
- }
689
-
690
- function runnerProcessIsCurrent(status, dir, { ownerOnly = false } = {}) {
691
- const fallback = ownerOnly ? status : {
692
- pid: Number(status?.runner_pid) || undefined,
693
- processStartedAt: status?.runner_process_started_at,
694
- startedAt: status?.started_at || status?.updated_at || status?.created_at,
695
- };
696
- const owner = readRunnerOwner(dir, fallback);
697
- if (!owner.pid) return false;
698
- const identity = inspectProcessInstance(owner, { maxAgeMs: Number.POSITIVE_INFINITY });
699
- return identity.current || (identity.alive && !identity.reclaimable);
700
- }
701
-
702
542
  function scrubFinishedPlan(dir, status) {
703
543
  if (PLAN_RETAINING_STATES.has(status.status)) return;
704
544
  const safeRm = (path) => {
@@ -713,128 +553,3 @@ function scrubFinishedPlan(dir, status) {
713
553
  safeRm(join(dir, "recovery.lock"));
714
554
  safeRm(join(dir, "transition.lock"));
715
555
  }
716
-
717
- function reviewablePlan(plan) {
718
- return {
719
- version: plan.version,
720
- name: plan.name,
721
- workspace: plan.workspace,
722
- full_env: plan.full_env === true,
723
- resources: Object.fromEntries(Object.entries(plan.resources || {}).map(([name, value]) => [name, {
724
- kind: value.kind,
725
- size: value.size ?? null,
726
- mode: value.mode ?? null,
727
- allow_insecure_permissions: value.allowInsecurePermissions === true,
728
- }])),
729
- temporary_files: plan.temporary_files || [],
730
- steps: plan.steps || [],
731
- finally_steps: plan.finally_steps || [],
732
- };
733
- }
734
-
735
- function publicStatus(status) {
736
- return {
737
- job_id: status.job_id,
738
- name: status.name,
739
- status: status.status,
740
- created_at: status.created_at,
741
- started_at: status.started_at ?? null,
742
- finished_at: status.finished_at ?? null,
743
- current_phase: status.current_phase ?? null,
744
- current_step: status.current_step ?? null,
745
- approval: status.approval ?? null,
746
- plan_sha256: status.plan_sha256 ?? null,
747
- cleanup_guarantee: status.cleanup_guarantee ?? "best-effort-finally-and-recovery",
748
- error_class: status.error_class ?? null,
749
- recovery_attempts: Number(status.recovery_attempts || 0),
750
- };
751
- }
752
-
753
- function atomicWriteJson(file, value, maxBytes) {
754
- ensureOwnerOnlyDir(dirname(file));
755
- const text = `${JSON.stringify(value, null, 2)}
756
- `;
757
- if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
758
- replaceFileAtomicallySync(file, text, { mode: 0o600 });
759
- ownerOnlyFile(file);
760
- }
761
-
762
- function readJson(file, maxBytes, label = "JSON") {
763
- let buffer;
764
- try { buffer = readBoundedFile(file, maxBytes); } catch (error) {
765
- if (error?.code === "ENOENT") return null;
766
- throw new Error(`${label} is unavailable (${resourceErrorClass(error)})`);
767
- }
768
- let text;
769
- try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
770
- throw new Error(`${label} is not valid UTF-8`);
771
- }
772
- try { return JSON.parse(text); } catch {
773
- throw new Error(`${label} is not valid JSON`);
774
- }
775
- }
776
-
777
- function readRequiredJson(file, maxBytes, label) {
778
- const value = readJson(file, maxBytes, label);
779
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is unavailable or invalid`);
780
- return value;
781
- }
782
-
783
- function readBoundedFile(file, maxBytes) {
784
- return readBoundedRegularFileSync(file, maxBytes);
785
- }
786
-
787
- function openPrivateAppendFile(file) {
788
- return openRegularFileSync(
789
- file,
790
- Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
791
- { label: "runner diagnostic path", mode: 0o600, chmod: 0o600 },
792
- ).fd;
793
- }
794
-
795
- function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
796
- let fd;
797
- try {
798
- const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
799
- label: "runner diagnostic path",
800
- chmod: 0o600,
801
- });
802
- fd = opened.fd;
803
- if (opened.info.size <= maxBytes) return;
804
- const length = Math.min(keepBytes, opened.info.size);
805
- const buffer = Buffer.alloc(length);
806
- let offset = 0;
807
- while (offset < length) {
808
- const count = readSync(fd, buffer, offset, length - offset, opened.info.size - length + offset);
809
- if (!count) break;
810
- offset += count;
811
- }
812
- let tail = buffer.subarray(0, offset);
813
- const newline = tail.indexOf(0x0a);
814
- if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
815
- ftruncateSync(fd, 0);
816
- if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
817
- } catch (error) {
818
- if (error?.code !== "ENOENT") throw error;
819
- } finally {
820
- if (fd !== undefined) {
821
- try { closeSync(fd); } catch {
822
- // Descriptor close is best effort after the trim result is already determined.
823
- }
824
- }
825
- }
826
- }
827
-
828
- function resourceErrorClass(error) {
829
- const message = String(error?.message || error || "");
830
- if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
831
- if (/not found|ENOENT/i.test(message)) return "not_found";
832
- if (/symbolic link/i.test(message)) return "symbolic_link_denied";
833
- if (/readable by group|permissions/i.test(message)) return "insecure_permissions";
834
- if (/exceeds/i.test(message)) return "size_limit";
835
- return "resource_unavailable";
836
- }
837
-
838
- function safeReadDir(dir) {
839
- return readdirSync(dir, { withFileTypes: true });
840
- }