machine-bridge-mcp 0.7.1 → 0.8.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 +29 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +5 -5
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +63 -28
- package/docs/OPERATIONS.md +6 -0
- package/docs/PRIVACY.md +6 -0
- package/docs/TESTING.md +8 -2
- package/package.json +8 -4
- package/src/local/cli.mjs +339 -280
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +3 -18
- package/src/local/log.mjs +2 -0
- package/src/local/managed-jobs.mjs +57 -77
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +141 -188
- package/src/local/secure-file.mjs +27 -0
- package/src/local/ssh-key.mjs +37 -19
- package/src/local/stdio.mjs +86 -70
- package/src/worker/index.ts +11 -4
|
@@ -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 {
|
|
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
|
|
32
|
+
runtime = new LocalRuntime({
|
|
33
33
|
workspace: resolve(workspace),
|
|
34
34
|
policy: canonicalPolicy,
|
|
35
35
|
jobRoot,
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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) {
|