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.
- package/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +31 -0
- package/README.md +20 -17
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +65 -26
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +15 -6
- package/docs/PRIVACY.md +43 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +23 -1
- package/package.json +35 -8
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/cli.mjs +369 -272
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +73 -31
- package/src/local/log.mjs +28 -2
- package/src/local/managed-jobs.mjs +194 -125
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +188 -198
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +41 -21
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +157 -81
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +45 -21
- package/wrangler.jsonc +1 -1
|
@@ -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;
|
|
@@ -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
|
|
19
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
433
|
+
text = replacePathText(text, path, `<temp:${name}>`);
|
|
376
434
|
}
|
|
377
|
-
text = text
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|