machine-bridge-mcp 0.6.0 → 0.7.1

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,9 +1,10 @@
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, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, 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
6
  import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
+ import { replaceFileSync } from "./atomic-fs.mjs";
7
8
 
8
9
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
9
10
  const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
@@ -94,36 +95,44 @@ export class ManagedJobManager {
94
95
  approve(args = {}, { localOperator = false } = {}) {
95
96
  if (!localOperator) this.assertEnabled("approve_job");
96
97
  const dir = this.jobDir(args.job_id);
97
- const statusFile = join(dir, "status.json");
98
- const status = readRequiredJson(statusFile, 256 * 1024, "job status");
99
- if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
100
- readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
101
- status.status = "queued";
102
- status.updated_at = new Date().toISOString();
103
- status.approved_at = status.updated_at;
104
- status.approval = localOperator ? "local-operator" : "mcp";
105
- status.cleanup_guarantee = "best-effort-finally-and-recovery";
106
- atomicWriteJson(statusFile, status, 256 * 1024);
98
+ const transition = acquireJobTransitionLock(dir);
99
+ if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
107
100
  try {
108
- launchRunner(dir);
109
- } catch (error) {
110
- failRunnerLaunch(dir, status, error);
111
- throw error;
101
+ const statusFile = join(dir, "status.json");
102
+ const status = readRequiredJson(statusFile, 256 * 1024, "job status");
103
+ if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
104
+ const plan = readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
105
+ assertPlanIntegrity(plan, status);
106
+ status.status = "queued";
107
+ status.updated_at = new Date().toISOString();
108
+ status.approved_at = status.updated_at;
109
+ status.approval = localOperator ? "local-operator" : "mcp";
110
+ status.cleanup_guarantee = "best-effort-finally-and-recovery";
111
+ atomicWriteJson(statusFile, status, 256 * 1024);
112
+ try {
113
+ launchRunner(dir);
114
+ } catch (error) {
115
+ failRunnerLaunch(dir, status, error);
116
+ throw error;
117
+ }
118
+ return {
119
+ accepted: true,
120
+ job_id: status.job_id,
121
+ name: status.name,
122
+ status: "queued",
123
+ detached: true,
124
+ continues_without_mcp_connection: true,
125
+ approval: status.approval,
126
+ plan_sha256: status.plan_sha256,
127
+ cleanup: {
128
+ resource_copies: "best-effort",
129
+ finally_steps: "best-effort-if-declared",
130
+ restart_recovery: "best-effort-on-next-runtime-or-cli-start",
131
+ },
132
+ };
133
+ } finally {
134
+ transition.release();
112
135
  }
113
- return {
114
- accepted: true,
115
- job_id: status.job_id,
116
- name: status.name,
117
- status: "queued",
118
- detached: true,
119
- continues_without_mcp_connection: true,
120
- approval: status.approval,
121
- cleanup: {
122
- resource_copies: "best-effort",
123
- finally_steps: "best-effort-if-declared",
124
- restart_recovery: "best-effort-on-next-runtime-or-cli-start",
125
- },
126
- };
127
136
  }
128
137
 
129
138
  createJob(args, { launch }) {
@@ -224,48 +233,55 @@ export class ManagedJobManager {
224
233
  this.reconcileStatus(dir);
225
234
  const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
226
235
  const plan = readJson(join(dir, "plan.json"), MAX_PLAN_BYTES);
236
+ if (plan) assertPlanIntegrity(plan, status);
227
237
  return {
228
238
  ...publicStatus(status),
239
+ plan_integrity_verified: Boolean(plan),
229
240
  ...(plan ? { review_plan: reviewablePlan(plan) } : {}),
230
241
  };
231
242
  }
232
243
 
233
244
  cancel(args = {}) {
234
245
  const dir = this.jobDir(args.job_id);
235
- this.reconcileStatus(dir);
236
- const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
237
- if (status.status === "staged") {
238
- const now = new Date().toISOString();
239
- status.status = "cancelled_before_start";
240
- status.updated_at = now;
241
- status.finished_at = now;
242
- status.error_class = "cancelled";
243
- status.cleanup_guarantee = "not-started";
244
- atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
245
- atomicWriteJson(join(dir, "result.json"), {
246
- job_id: status.job_id,
247
- name: status.name,
248
- status: status.status,
249
- steps: [],
250
- finally_steps: [],
251
- error_class: "cancelled",
252
- cleanup_error_class: null,
253
- finished_at: now,
254
- }, 4 * 1024 * 1024);
255
- scrubFinishedPlan(dir, status);
256
- return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
257
- }
258
- if (!ACTIVE_JOB_STATES.has(status.status)) {
259
- return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
246
+ const transition = acquireJobTransitionLock(dir);
247
+ if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
248
+ try {
249
+ this.reconcileStatus(dir);
250
+ const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
251
+ if (status.status === "staged") {
252
+ const now = new Date().toISOString();
253
+ status.status = "cancelled_before_start";
254
+ status.updated_at = now;
255
+ status.finished_at = now;
256
+ status.error_class = "cancelled";
257
+ status.cleanup_guarantee = "not-started";
258
+ atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
259
+ atomicWriteJson(join(dir, "result.json"), {
260
+ job_id: status.job_id,
261
+ name: status.name,
262
+ status: status.status,
263
+ steps: [],
264
+ finally_steps: [],
265
+ error_class: "cancelled",
266
+ cleanup_error_class: null,
267
+ finished_at: now,
268
+ }, 4 * 1024 * 1024);
269
+ scrubFinishedPlan(dir, status);
270
+ return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
271
+ }
272
+ if (!ACTIVE_JOB_STATES.has(status.status)) {
273
+ return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
274
+ }
275
+ writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
276
+ return {
277
+ ...publicStatus(status),
278
+ cancellation_requested: true,
279
+ cancellation_delivery: "runner-poll",
280
+ cleanup_will_run: true,
281
+ };
282
+ } finally {
283
+ transition.release();
260
284
  }
261
- writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
262
- return {
263
- ...publicStatus(status),
264
- cancellation_requested: true,
265
- cancellation_delivery: "runner-poll",
266
- cleanup_will_run: true,
267
- };
268
-
269
285
  }
270
286
 
271
287
  currentResources() {
@@ -342,6 +358,7 @@ export class ManagedJobManager {
342
358
  status.recovery_attempts = recoveryAttempts + 1;
343
359
  atomicWriteJson(file, status, 256 * 1024);
344
360
  rmSync(join(dir, "runtime"), { recursive: true, force: true });
361
+ rmSync(join(dir, "runner.pid"), { force: true });
345
362
  const runnerPid = launchRunner(dir, true);
346
363
  recoveryLock.handoff(runnerPid);
347
364
  handedOff = true;
@@ -444,6 +461,7 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
444
461
  return {
445
462
  kind: "file",
446
463
  path: canonical,
464
+ pathAliases: normalizeResourcePathAliases([path, canonical]),
447
465
  size: info.size,
448
466
  mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
449
467
  updatedAt: new Date().toISOString(),
@@ -452,15 +470,16 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
452
470
  };
453
471
  }
454
472
 
455
- export function publicResourceRegistry(resources = {}) {
473
+ export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
456
474
  const normalized = normalizeResourceRegistry(resources);
457
475
  return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
458
476
  kind: value.kind,
459
- path: value.path,
460
477
  size: value.size ?? null,
461
478
  mode: value.mode ?? null,
462
479
  updatedAt: value.updatedAt ?? null,
463
480
  allowInsecurePermissions: value.allowInsecurePermissions === true,
481
+ paths_exposed: includePaths,
482
+ ...(includePaths ? { path: value.path } : {}),
464
483
  }]));
465
484
  }
466
485
 
@@ -579,7 +598,11 @@ function referencedResources(steps, registry) {
579
598
  const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
580
599
  totalBytes += inspected.size;
581
600
  if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
582
- out[name] = { ...inspected, allowInsecurePermissions: resource.allowInsecurePermissions === true };
601
+ out[name] = {
602
+ ...inspected,
603
+ pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
604
+ allowInsecurePermissions: resource.allowInsecurePermissions === true,
605
+ };
583
606
  }
584
607
  return out;
585
608
  }
@@ -593,6 +616,7 @@ function normalizeResourceRegistry(resources) {
593
616
  out[name] = {
594
617
  kind: "file",
595
618
  path: resolve(rawValue.path),
619
+ pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
596
620
  size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
597
621
  mode: rawValue.mode ?? null,
598
622
  updatedAt: rawValue.updatedAt ?? null,
@@ -602,6 +626,17 @@ function normalizeResourceRegistry(resources) {
602
626
  return out;
603
627
  }
604
628
 
629
+ function normalizeResourcePathAliases(values) {
630
+ const aliases = [];
631
+ for (const value of values) {
632
+ if (typeof value !== "string" || !value || value.includes("\0") || value.length > 4096) continue;
633
+ const absolute = resolve(value);
634
+ if (!aliases.includes(absolute)) aliases.push(absolute);
635
+ if (aliases.length >= 8) break;
636
+ }
637
+ return aliases;
638
+ }
639
+
605
640
  function resolveJobCwd(value, workspace, unrestrictedPaths) {
606
641
  const raw = boundedString(value, 4096, "cwd");
607
642
  const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
@@ -655,7 +690,42 @@ function acquireRecoveryLock(dir) {
655
690
  let owner = 0;
656
691
  try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
657
692
  const age = Date.now() - safeMtime(file);
658
- if ((owner > 0 && isPidAlive(owner)) || age < 60_000) return null;
693
+ const ownerAlive = owner > 0 && isPidAlive(owner);
694
+ const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
695
+ if (!definitelyStale) return null;
696
+ rmSync(file, { force: true });
697
+ }
698
+ }
699
+ return null;
700
+ }
701
+
702
+ function planSha256(plan) {
703
+ return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
704
+ }
705
+
706
+ function assertPlanIntegrity(plan, status) {
707
+ const expected = String(status?.plan_sha256 || "");
708
+ const actual = planSha256(plan);
709
+ if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) {
710
+ throw new Error("managed job plan integrity check failed; inspect the plan and do not approve it");
711
+ }
712
+ return actual;
713
+ }
714
+
715
+ function acquireJobTransitionLock(dir) {
716
+ const file = join(dir, "transition.lock");
717
+ for (let attempt = 0; attempt < 2; attempt += 1) {
718
+ try {
719
+ writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
720
+ return { release() { rmSync(file, { force: true }); } };
721
+ } catch (error) {
722
+ 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);
728
+ if (!definitelyStale) return null;
659
729
  rmSync(file, { force: true });
660
730
  }
661
731
  }
@@ -669,18 +739,20 @@ function launchRunner(dir, recover = false) {
669
739
  const stderrFile = join(dir, "runner.err.log");
670
740
  trimDiagnosticFile(stdoutFile);
671
741
  trimDiagnosticFile(stderrFile);
672
- const stdoutFd = openSync(stdoutFile, "a", 0o600);
673
- const stderrFd = openSync(stderrFile, "a", 0o600);
742
+ let stdoutFd;
743
+ let stderrFd;
674
744
  let child;
675
745
  try {
746
+ stdoutFd = openPrivateAppendFile(stdoutFile);
747
+ stderrFd = openPrivateAppendFile(stderrFile);
676
748
  child = spawn(process.execPath, args, {
677
749
  detached: true,
678
750
  stdio: ["ignore", stdoutFd, stderrFd],
679
751
  windowsHide: true,
680
752
  });
681
753
  } finally {
682
- closeSync(stdoutFd);
683
- closeSync(stderrFd);
754
+ if (stdoutFd !== undefined) closeSync(stdoutFd);
755
+ if (stderrFd !== undefined) closeSync(stderrFd);
684
756
  }
685
757
  ownerOnlyFile(stdoutFile);
686
758
  ownerOnlyFile(stderrFile);
@@ -703,6 +775,7 @@ function scrubFinishedPlan(dir, status) {
703
775
  rmSync(join(dir, "plan.json"), { force: true });
704
776
  rmSync(join(dir, "runner.pid"), { force: true });
705
777
  rmSync(join(dir, "recovery.lock"), { force: true });
778
+ rmSync(join(dir, "transition.lock"), { force: true });
706
779
  }
707
780
 
708
781
  function reviewablePlan(plan) {
@@ -747,7 +820,7 @@ function atomicWriteJson(file, value, maxBytes) {
747
820
  if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
748
821
  const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
749
822
  writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
750
- renameSync(temp, file);
823
+ replaceFileSync(temp, file);
751
824
  ownerOnlyFile(file);
752
825
  }
753
826
 
@@ -785,10 +858,27 @@ function readBoundedFileWithInfo(file, maxBytes) {
785
858
  }
786
859
  }
787
860
 
861
+ function openPrivateAppendFile(file) {
862
+ if (existsSync(file)) {
863
+ const info = lstatSync(file);
864
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("runner diagnostic path must be a regular file and not a symbolic link");
865
+ }
866
+ const flags = Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | Number(fsConstants.O_NOFOLLOW || 0);
867
+ const fd = openSync(file, flags, 0o600);
868
+ try {
869
+ if (!fstatSync(fd).isFile()) throw new Error("runner diagnostic path is not a regular file");
870
+ chmodSync(file, 0o600);
871
+ return fd;
872
+ } catch (error) {
873
+ closeSync(fd);
874
+ throw error;
875
+ }
876
+ }
877
+
788
878
  function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
789
879
  let fd;
790
880
  try {
791
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
881
+ const flags = Number(fsConstants.O_RDWR) | Number(fsConstants.O_NOFOLLOW || 0);
792
882
  fd = openSync(file, flags);
793
883
  const info = fstatSync(fd);
794
884
  if (!info.isFile() || info.size <= maxBytes) return;
@@ -803,9 +893,9 @@ function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
803
893
  let tail = buffer.subarray(0, offset);
804
894
  const newline = tail.indexOf(0x0a);
805
895
  if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
806
- closeSync(fd);
807
- fd = undefined;
808
- writeFileSync(file, tail, { mode: 0o600 });
896
+ ftruncateSync(fd, 0);
897
+ if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
898
+ try { chmodSync(file, 0o600); } catch {}
809
899
  } catch {
810
900
  } finally {
811
901
  if (fd !== undefined) try { closeSync(fd); } catch {}
@@ -4,11 +4,11 @@ import { basename } from "node:path";
4
4
  import { executionEnv } from "./shell.mjs";
5
5
 
6
6
  export const MAX_COMMAND_BYTES = 64 * 1024;
7
- export const MAX_ARGV_ITEMS = 256;
8
- export const MAX_PROCESS_SESSIONS = 8;
9
- export const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
10
- export const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
11
- export const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
7
+ const MAX_ARGV_ITEMS = 256;
8
+ const MAX_PROCESS_SESSIONS = 8;
9
+ const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
10
+ const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
11
+ const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
12
12
 
13
13
  export class ProcessSessionManager {
14
14
  constructor({ workspace, policy, runtimeDir, activeProcesses, callProcesses, resolveCwd, displayPath, throwIfCancelled }) {
@@ -0,0 +1,66 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
5
+ import { generateSshKeyPair } from "./ssh-key.mjs";
6
+ import { acquireStartupLock, loadState, saveState } from "./state.mjs";
7
+
8
+ export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
9
+ const name = validateResourceName(rawName);
10
+ if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
11
+ const target = resolve(targetPath);
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");
15
+ let key = null;
16
+ try {
17
+ state.resources ||= {};
18
+ const existing = state.resources[name];
19
+ if (existing?.path && !samePathIdentity(existing.path, target)) {
20
+ throw new Error(`local resource ${name} is already registered to a different file; remove it first`);
21
+ }
22
+ if (!existing && Object.keys(state.resources).length >= 64) throw new Error("local resource registry limit reached (64)");
23
+ key = await generateSshKeyPair({
24
+ privateKeyPath: target,
25
+ type: "ed25519",
26
+ comment: comment || `machine-mcp:${name}`,
27
+ });
28
+ const inspected = inspectResourceFile(key.privateKeyPath);
29
+ state.resources[name] = inspected;
30
+ try {
31
+ saveState(state);
32
+ } catch (error) {
33
+ if (key.created) {
34
+ await rm(key.privateKeyPath, { force: true }).catch(() => {});
35
+ await rm(key.publicKeyPath, { force: true }).catch(() => {});
36
+ }
37
+ throw error;
38
+ }
39
+ return {
40
+ name,
41
+ created: key.created,
42
+ registered: true,
43
+ privateKeyPath: key.privateKeyPath,
44
+ publicKeyPath: key.publicKeyPath,
45
+ fingerprint: key.fingerprint,
46
+ keyType: key.publicKeyType,
47
+ privateMode: key.privateMode,
48
+ publicMode: key.publicMode,
49
+ privateKeyContentExposed: false,
50
+ availableToNewJobsImmediately: true,
51
+ };
52
+ } finally {
53
+ lock.release();
54
+ }
55
+ }
56
+
57
+ function samePathIdentity(left, right) {
58
+ const a = canonicalIfExisting(left);
59
+ const b = canonicalIfExisting(right);
60
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
61
+ }
62
+
63
+ function canonicalIfExisting(value) {
64
+ const absolute = resolve(String(value));
65
+ try { return realpathSync.native ? realpathSync.native(absolute) : realpathSync(absolute); } catch { return absolute; }
66
+ }