machine-bridge-mcp 0.7.1 → 0.8.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.
@@ -2,7 +2,7 @@ import { chmod, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import process from "node:process";
5
- import { LocalDaemon } from "./daemon.mjs";
5
+ import { LocalRuntime } from "./runtime.mjs";
6
6
  import { generateSshKeyPair } from "./ssh-key.mjs";
7
7
  import { run } from "./shell.mjs";
8
8
  import { allToolNames, assertCanonicalFullPolicy, policyProfile } from "./tools.mjs";
@@ -29,7 +29,7 @@ export async function runFullAccessTest({ workspace, policy = policyProfile("ful
29
29
  let runtime;
30
30
 
31
31
  try {
32
- runtime = new LocalDaemon({
32
+ runtime = new LocalRuntime({
33
33
  workspace: resolve(workspace),
34
34
  policy: canonicalPolicy,
35
35
  jobRoot,
@@ -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, mkdirSync, openSync, readSync, rmSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { executionEnv } from "./shell.mjs";
6
6
  import { terminateProcessTree } from "./process-sessions.mjs";
7
7
  import { replaceFileSync } from "./atomic-fs.mjs";
8
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
8
9
 
9
10
  const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
10
11
  const TEMP_TOKEN = /\{\{temp:([a-z][a-z0-9._-]{0,63})\}\}/g;
@@ -486,23 +487,7 @@ function readJson(file, maxBytes) {
486
487
  }
487
488
 
488
489
  function readBoundedFile(file, maxBytes) {
489
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
490
- const fd = openSync(file, flags);
491
- try {
492
- const info = fstatSync(fd);
493
- if (!info.isFile()) throw new Error("path is not a regular file");
494
- if (info.size > maxBytes) throw new Error(`file exceeds ${maxBytes} bytes`);
495
- const buffer = Buffer.alloc(info.size);
496
- let offset = 0;
497
- while (offset < buffer.length) {
498
- const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
499
- if (!count) break;
500
- offset += count;
501
- }
502
- return buffer.subarray(0, offset);
503
- } finally {
504
- closeSync(fd);
505
- }
490
+ return readBoundedRegularFileSync(file, maxBytes);
506
491
  }
507
492
 
508
493
  function classifyError(error) {
package/src/local/log.mjs CHANGED
@@ -71,6 +71,8 @@ export function classifyOperationalError(error) {
71
71
  const message = error instanceof Error ? error.message : String(error ?? "");
72
72
  if (/cancel/i.test(message)) return "cancelled";
73
73
  if (/timed out/i.test(message)) return "timeout";
74
+ if (/unauthorized|forbidden|authentication|\b401\b|\b403\b/i.test(message)) return "authentication_failed";
75
+ if (/ECONNRESET|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|EAI_AGAIN|socket hang up|network|websocket|TLS|SSL/i.test(message)) return "network_error";
74
76
  if (/outside the configured workspace/i.test(message)) return "path_boundary";
75
77
  if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
76
78
  if (/not found|ENOENT/i.test(message)) return "not_found";
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
7
7
  import { replaceFileSync } from "./atomic-fs.mjs";
8
+ import { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
8
9
 
9
10
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
10
11
  const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
@@ -327,39 +328,10 @@ export class ManagedJobManager {
327
328
  if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) return;
328
329
  const recoveryAttempts = Number(status.recovery_attempts || 0);
329
330
  if (recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
330
- const now = new Date().toISOString();
331
- status.status = "recovery_exhausted";
332
- status.updated_at = now;
333
- status.finished_at = now;
334
- status.error_class = "recovery_exhausted";
335
- status.current_phase = null;
336
- status.current_step = null;
337
- atomicWriteJson(file, status, 256 * 1024);
338
- atomicWriteJson(join(dir, "result.json"), {
339
- job_id: status.job_id,
340
- name: status.name,
341
- status: status.status,
342
- recovered: true,
343
- steps: [],
344
- finally_steps: [],
345
- error_class: "recovery_exhausted",
346
- cleanup_error_class: "recovery_exhausted",
347
- recovery_attempts: recoveryAttempts,
348
- finished_at: now,
349
- }, 4 * 1024 * 1024);
350
- rmSync(join(dir, "runtime"), { recursive: true, force: true });
351
- scrubFinishedPlan(dir, status);
331
+ markRecoveryExhausted(dir, file, status, recoveryAttempts);
352
332
  return;
353
333
  }
354
- status.status = "interrupted";
355
- status.updated_at = new Date().toISOString();
356
- status.finished_at = status.updated_at;
357
- status.error_class = "runner_interrupted";
358
- status.recovery_attempts = recoveryAttempts + 1;
359
- atomicWriteJson(file, status, 256 * 1024);
360
- rmSync(join(dir, "runtime"), { recursive: true, force: true });
361
- rmSync(join(dir, "runner.pid"), { force: true });
362
- const runnerPid = launchRunner(dir, true);
334
+ const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts);
363
335
  recoveryLock.handoff(runnerPid);
364
336
  handedOff = true;
365
337
  } finally {
@@ -454,7 +426,7 @@ export function validateResourceName(value) {
454
426
  export function inspectResourceFile(inputPath, { allowInsecurePermissions = false, includeHash = false } = {}) {
455
427
  const path = resolve(String(inputPath || ""));
456
428
  const canonical = realpathFile(path);
457
- const { buffer: content, info } = readBoundedFileWithInfo(canonical, MAX_RESOURCE_BYTES);
429
+ const { buffer: content, info } = readBoundedRegularFileWithInfoSync(canonical, MAX_RESOURCE_BYTES);
458
430
  if (process.platform !== "win32" && !allowInsecurePermissions && (info.mode & 0o077) !== 0) {
459
431
  throw new Error("resource file is readable by group or others; restrict permissions or use --allow-insecure-permissions");
460
432
  }
@@ -674,29 +646,47 @@ function failRunnerLaunch(dir, status, error) {
674
646
  scrubFinishedPlan(dir, failed);
675
647
  }
676
648
 
649
+ function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
650
+ const now = new Date().toISOString();
651
+ Object.assign(status, {
652
+ status: "recovery_exhausted",
653
+ updated_at: now,
654
+ finished_at: now,
655
+ error_class: "recovery_exhausted",
656
+ current_phase: null,
657
+ current_step: null,
658
+ });
659
+ atomicWriteJson(statusFile, status, 256 * 1024);
660
+ atomicWriteJson(join(dir, "result.json"), {
661
+ job_id: status.job_id,
662
+ name: status.name,
663
+ status: status.status,
664
+ recovered: true,
665
+ steps: [],
666
+ finally_steps: [],
667
+ error_class: "recovery_exhausted",
668
+ cleanup_error_class: "recovery_exhausted",
669
+ recovery_attempts: recoveryAttempts,
670
+ finished_at: now,
671
+ }, 4 * 1024 * 1024);
672
+ rmSync(join(dir, "runtime"), { recursive: true, force: true });
673
+ scrubFinishedPlan(dir, status);
674
+ }
675
+
676
+ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts) {
677
+ status.status = "interrupted";
678
+ status.updated_at = new Date().toISOString();
679
+ status.finished_at = status.updated_at;
680
+ status.error_class = "runner_interrupted";
681
+ status.recovery_attempts = recoveryAttempts + 1;
682
+ atomicWriteJson(statusFile, status, 256 * 1024);
683
+ rmSync(join(dir, "runtime"), { recursive: true, force: true });
684
+ rmSync(join(dir, "runner.pid"), { force: true });
685
+ return launchRunner(dir, true);
686
+ }
687
+
677
688
  function acquireRecoveryLock(dir) {
678
- const file = join(dir, "recovery.lock");
679
- for (let attempt = 0; attempt < 2; attempt += 1) {
680
- try {
681
- writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
682
- return {
683
- handoff(pid) {
684
- if (Number.isInteger(pid) && pid > 0) writeFileSync(file, `${pid}\n`, { mode: 0o600 });
685
- },
686
- release() { rmSync(file, { force: true }); },
687
- };
688
- } catch (error) {
689
- if (error?.code !== "EEXIST") throw error;
690
- let owner = 0;
691
- try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
692
- const age = Date.now() - safeMtime(file);
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;
689
+ return acquirePidLock(join(dir, "recovery.lock"), { allowHandoff: true });
700
690
  }
701
691
 
702
692
  function planSha256(plan) {
@@ -713,11 +703,21 @@ function assertPlanIntegrity(plan, status) {
713
703
  }
714
704
 
715
705
  function acquireJobTransitionLock(dir) {
716
- const file = join(dir, "transition.lock");
706
+ return acquirePidLock(join(dir, "transition.lock"));
707
+ }
708
+
709
+ function acquirePidLock(file, { allowHandoff = false } = {}) {
717
710
  for (let attempt = 0; attempt < 2; attempt += 1) {
718
711
  try {
719
712
  writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
720
- return { release() { rmSync(file, { force: true }); } };
713
+ return {
714
+ ...(allowHandoff ? {
715
+ handoff(pid) {
716
+ if (Number.isInteger(pid) && pid > 0) writeFileSync(file, `${pid}\n`, { mode: 0o600 });
717
+ },
718
+ } : {}),
719
+ release() { rmSync(file, { force: true }); },
720
+ };
721
721
  } catch (error) {
722
722
  if (error?.code !== "EEXIST") throw error;
723
723
  let owner = 0;
@@ -835,27 +835,7 @@ function readRequiredJson(file, maxBytes, label) {
835
835
  }
836
836
 
837
837
  function readBoundedFile(file, maxBytes) {
838
- return readBoundedFileWithInfo(file, maxBytes).buffer;
839
- }
840
-
841
- function readBoundedFileWithInfo(file, maxBytes) {
842
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
843
- const fd = openSync(file, flags);
844
- try {
845
- const info = fstatSync(fd);
846
- if (!info.isFile()) throw new Error("path is not a regular file");
847
- if (info.size > maxBytes) throw new Error(`file exceeds ${maxBytes} bytes`);
848
- const buffer = Buffer.alloc(info.size);
849
- let offset = 0;
850
- while (offset < buffer.length) {
851
- const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
852
- if (!count) break;
853
- offset += count;
854
- }
855
- return { buffer: buffer.subarray(0, offset), info };
856
- } finally {
857
- closeSync(fd);
858
- }
838
+ return readBoundedRegularFileSync(file, maxBytes);
859
839
  }
860
840
 
861
841
  function openPrivateAppendFile(file) {