machine-bridge-mcp 0.6.2 → 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.
@@ -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;
@@ -13,10 +14,13 @@ const MAX_OUTPUT_BYTES = 64 * 1024;
13
14
  const MAX_JOB_CAPTURE_BYTES = 256 * 1024;
14
15
  const MAX_RESULT_BYTES = 4 * 1024 * 1024;
15
16
  const MAX_STATUS_BYTES = 256 * 1024;
17
+ const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
16
18
 
17
19
  const options = parseArgs(process.argv.slice(2));
18
- const jobDir = resolve(options.jobDir || "");
19
- if (!jobDir) throw new Error("--job-dir is required");
20
+ const jobDirInput = typeof options.jobDir === "string" ? options.jobDir.trim() : "";
21
+ if (!jobDirInput) throw new Error("--job-dir is required");
22
+ const jobDir = resolve(jobDirInput);
23
+ if (!JOB_ID.test(basename(jobDir))) throw new Error("--job-dir must name a managed job directory");
20
24
  const recover = options.recover === true;
21
25
  const planFile = join(jobDir, "plan.json");
22
26
  const statusFile = join(jobDir, "status.json");
@@ -43,17 +47,19 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
43
47
  process.on(signal, () => requestCancellation());
44
48
  }
45
49
 
46
- writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600 });
50
+ const initial = readJson(statusFile, MAX_STATUS_BYTES);
51
+ assertLaunchState(initial);
52
+ writeFileSync(runnerPidFile, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
47
53
  if (recover) rmSync(join(jobDir, "recovery.lock"), { force: true });
48
54
  try {
49
- await main();
55
+ const plan = readJson(planFile, 1024 * 1024);
56
+ assertPlanIntegrity(plan, initial);
57
+ await main(plan, initial);
50
58
  } catch (error) {
51
59
  recordFatalRunnerError(error);
52
60
  }
53
61
 
54
- async function main() {
55
- const plan = readJson(planFile, 1024 * 1024);
56
- const initial = readJson(statusFile, MAX_STATUS_BYTES);
62
+ async function main(plan, initial) {
57
63
  const status = {
58
64
  ...initial,
59
65
  runner_pid: process.pid,
@@ -137,6 +143,20 @@ async function main() {
137
143
  try { rmSync(cancelFile, { force: true }); } catch {}
138
144
  }
139
145
 
146
+ function assertLaunchState(status) {
147
+ if (!status || typeof status !== "object" || Array.isArray(status)) throw new Error("job status is unavailable or invalid");
148
+ const expected = recover ? "interrupted" : "queued";
149
+ if (status.status !== expected) throw new Error(`runner cannot start job in status: ${status.status}`);
150
+ if (status.approval === "pending-local-operator" || !status.approval) throw new Error("runner cannot start an unapproved managed job");
151
+ }
152
+
153
+ function assertPlanIntegrity(plan, status) {
154
+ if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("job plan is unavailable or invalid");
155
+ const expected = String(status?.plan_sha256 || "");
156
+ const actual = createHash("sha256").update(JSON.stringify(plan)).digest("hex");
157
+ if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error("managed job plan integrity check failed");
158
+ }
159
+
140
160
  function recordFatalRunnerError(error) {
141
161
  rmSync(runtimeDir, { recursive: true, force: true });
142
162
  const now = new Date().toISOString();
@@ -234,11 +254,12 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
234
254
  let stderrTruncated = 0;
235
255
  let timedOut = false;
236
256
  let closed = false;
257
+ let killTimer = null;
237
258
  const timer = setTimeout(() => {
238
259
  timedOut = true;
239
260
  terminateProcessTree(child, "SIGTERM");
240
- const kill = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
241
- kill.unref?.();
261
+ killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
262
+ killTimer.unref?.();
242
263
  }, timeoutMs);
243
264
  timer.unref?.();
244
265
  const cancellationPoll = setInterval(() => {
@@ -279,6 +300,7 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
279
300
  if (closed) return;
280
301
  closed = true;
281
302
  clearTimeout(timer);
303
+ if (killTimer) clearTimeout(killTimer);
282
304
  clearInterval(cancellationPoll);
283
305
  activeChild = null;
284
306
  activeChildCancellationAware = false;
@@ -293,6 +315,7 @@ function materializeResources(resources) {
293
315
  mkdirSync(resourcesDir, { recursive: true, mode: 0o700 });
294
316
  chmodSync(resourcesDir, 0o700);
295
317
  const paths = {};
318
+ const sourcePaths = {};
296
319
  const bytes = {};
297
320
  const redactions = {};
298
321
  for (const [name, resource] of Object.entries(resources)) {
@@ -303,6 +326,10 @@ function materializeResources(resources) {
303
326
  const target = join(resourcesDir, name);
304
327
  writeFileSync(target, data, { mode: 0o600, flag: "wx" });
305
328
  paths[name] = target;
329
+ sourcePaths[name] = [...new Set([
330
+ ...resourcePathVariants(resource.path),
331
+ ...(Array.isArray(resource.pathAliases) ? resource.pathAliases.flatMap(resourcePathVariants) : []),
332
+ ])].sort((left, right) => right.length - left.length);
306
333
  bytes[name] = data;
307
334
  const patterns = [];
308
335
  try {
@@ -316,7 +343,7 @@ function materializeResources(resources) {
316
343
  }
317
344
  redactions[name] = [...new Set(patterns.filter((value) => value.length > 0))].sort((a, b) => b.length - a.length);
318
345
  }
319
- return { paths, bytes, redactions };
346
+ return { paths, sourcePaths, bytes, redactions };
320
347
  }
321
348
 
322
349
  function materializeTemporaryFiles(files) {
@@ -366,15 +393,46 @@ function substitute(value, plan, context) {
366
393
  });
367
394
  }
368
395
 
396
+ function resourcePathVariants(value) {
397
+ const canonical = resolve(value);
398
+ const variants = new Set(pathTextVariants(canonical));
399
+ if (process.platform === "darwin" && canonical.startsWith("/private/")) {
400
+ for (const variant of pathTextVariants(canonical.slice("/private".length))) variants.add(variant);
401
+ }
402
+ return [...variants].sort((left, right) => right.length - left.length);
403
+ }
404
+
405
+ function pathTextVariants(value) {
406
+ const path = String(value);
407
+ return [...new Set([path, path.replaceAll("\\", "/"), path.replaceAll("/", "\\")])];
408
+ }
409
+
410
+ function replacePathText(text, value, replacement) {
411
+ let output = text;
412
+ for (const variant of pathTextVariants(value)) {
413
+ if (!variant) continue;
414
+ if (process.platform === "win32") output = output.replace(new RegExp(escapeRegExp(variant), "gi"), replacement);
415
+ else output = output.split(variant).join(replacement);
416
+ }
417
+ return output;
418
+ }
419
+
420
+ function escapeRegExp(value) {
421
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
422
+ }
423
+
369
424
  function redactOutput(buffer, context) {
370
425
  let text = new TextDecoder("utf-8").decode(buffer);
371
426
  for (const [name, path] of Object.entries(context.paths)) {
372
- text = text.split(path).join(`<resource:${name}>`);
427
+ text = replacePathText(text, path, `<resource:${name}>`);
428
+ }
429
+ for (const [name, paths] of Object.entries(context.sourcePaths || {})) {
430
+ for (const path of paths) text = replacePathText(text, path, `<resource-source:${name}>`);
373
431
  }
374
432
  for (const [name, path] of Object.entries(context.temporaryPaths)) {
375
- text = text.split(path).join(`<temp:${name}>`);
433
+ text = replacePathText(text, path, `<temp:${name}>`);
376
434
  }
377
- text = text.split(runtimeDir).join("<job-runtime>");
435
+ text = replacePathText(text, runtimeDir, "<job-runtime>");
378
436
  for (const [name, patterns] of Object.entries(context.redactions)) {
379
437
  for (const value of patterns) text = text.split(value).join(`<redacted-resource:${name}>`);
380
438
  }
@@ -429,23 +487,7 @@ function readJson(file, maxBytes) {
429
487
  }
430
488
 
431
489
  function readBoundedFile(file, maxBytes) {
432
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
433
- const fd = openSync(file, flags);
434
- try {
435
- const info = fstatSync(fd);
436
- if (!info.isFile()) throw new Error("path is not a regular file");
437
- if (info.size > maxBytes) throw new Error(`file exceeds ${maxBytes} bytes`);
438
- const buffer = Buffer.alloc(info.size);
439
- let offset = 0;
440
- while (offset < buffer.length) {
441
- const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
442
- if (!count) break;
443
- offset += count;
444
- }
445
- return buffer.subarray(0, offset);
446
- } finally {
447
- closeSync(fd);
448
- }
490
+ return readBoundedRegularFileSync(file, maxBytes);
449
491
  }
450
492
 
451
493
  function classifyError(error) {
package/src/local/log.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import process from "node:process";
2
+ import os from "node:os";
2
3
 
3
4
  const COLORS = {
4
5
  reset: "\x1b[0m",
@@ -15,8 +16,16 @@ const MAX_LOG_ARRAY_ITEMS = 32;
15
16
  const MAX_LOG_OBJECT_KEYS = 48;
16
17
  const LEVEL_RANK = Object.freeze({ debug: 10, info: 20, success: 20, warn: 30, error: 40 });
17
18
  const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
19
+ const LOCAL_PATH_KEY = /(path|paths|cwd|workspace|directory|(?:^|[_-])dir(?:$|[_-])|root|home)/i;
18
20
  const SECRET_VALUE = /\b(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
19
21
  const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
22
+ const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
23
+ const AWS_ACCESS_KEY = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g;
24
+ const GITHUB_TOKEN = /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g;
25
+ const API_SECRET = /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g;
26
+ const PRIVATE_KEY_HEADER = /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g;
27
+ const HOME_PATHS = [...new Set([process.env.HOME, process.env.USERPROFILE, safeHomeDirectory()].filter(value => typeof value === "string" && value.length > 1))]
28
+ .sort((left, right) => right.length - left.length);
20
29
 
21
30
  export function createLogger(options = {}) {
22
31
  const quiet = Boolean(options.quiet);
@@ -62,6 +71,8 @@ export function classifyOperationalError(error) {
62
71
  const message = error instanceof Error ? error.message : String(error ?? "");
63
72
  if (/cancel/i.test(message)) return "cancelled";
64
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";
65
76
  if (/outside the configured workspace/i.test(message)) return "path_boundary";
66
77
  if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
67
78
  if (/not found|ENOENT/i.test(message)) return "not_found";
@@ -86,8 +97,9 @@ export function formatFields(fields) {
86
97
  }
87
98
  }
88
99
 
89
- export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
100
+ function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
90
101
  if (SENSITIVE_KEY.test(key)) return "<redacted>";
102
+ if (LOCAL_PATH_KEY.test(key)) return "<local-path>";
91
103
  if (value instanceof Error) return sanitizeLogText(value.message || value.name);
92
104
  if (typeof value === "string") return sanitizeLogText(value);
93
105
  if (typeof value === "bigint") return value.toString();
@@ -106,9 +118,19 @@ export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth =
106
118
  export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
107
119
  let raw;
108
120
  try { raw = String(value ?? ""); } catch { raw = "<unprintable>"; }
109
- const sanitized = raw
121
+ let sanitized = raw
110
122
  .replace(SECRET_VALUE, "<redacted-secret>")
111
123
  .replace(BEARER_VALUE, "Bearer <redacted>")
124
+ .replace(AWS_ACCESS_KEY, "<redacted-cloud-key>")
125
+ .replace(GITHUB_TOKEN, "<redacted-access-token>")
126
+ .replace(API_SECRET, "<redacted-api-secret>")
127
+ .replace(PRIVATE_KEY_HEADER, "<redacted-private-key-header>")
128
+ .replace(EMAIL_VALUE, "<redacted-email>");
129
+ for (const home of HOME_PATHS) sanitized = sanitized.split(home).join("<home>");
130
+ sanitized = sanitized
131
+ .replace(/\/(?:Users|home)\/[^/\s"'<>]+(?=\/|$)/g, "<home>")
132
+ .replace(/\b[A-Za-z]:\\Users\\[^\\\s"'<>]+(?=\\|$)/g, "<home>")
133
+ .replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, "")
112
134
  .replace(/[\r\n\t]/g, match => match === "\t" ? "\\t" : "\\n")
113
135
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "?");
114
136
  if (!Number.isFinite(Number(maxChars)) || Number(maxChars) <= 0) return "";
@@ -116,6 +138,10 @@ export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
116
138
  return sanitized.length > limit ? `${sanitized.slice(0, limit - 1)}…` : sanitized;
117
139
  }
118
140
 
141
+ function safeHomeDirectory() {
142
+ try { return os.homedir(); } catch { return ""; }
143
+ }
144
+
119
145
  function shouldUseColor(options) {
120
146
  if (options.color === false || process.env.NO_COLOR) return false;
121
147
  if (options.color === true) return true;