machine-bridge-mcp 0.11.0 → 0.12.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 +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +6 -6
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +15 -7
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +15 -3
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +218 -130
- package/src/local/daemon-process.mjs +199 -0
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +115 -36
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const roots = ["bin", "src/local", "scripts", "tests", "browser-extension"];
|
|
10
|
+
const files = roots.flatMap((entry) => collect(join(root, entry)))
|
|
11
|
+
.filter((file) => [".js", ".mjs"].includes(extname(file)))
|
|
12
|
+
.sort();
|
|
13
|
+
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const result = spawnSync(process.execPath, ["--check", file], {
|
|
16
|
+
cwd: root,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
if (result.error) throw result.error;
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
process.stderr.write(`syntax check failed: ${relative(root, file)}\n${result.stderr || result.stdout}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (process.platform !== "win32") {
|
|
29
|
+
const shell = spawnSync("sh", ["-n", join(root, "mbm")], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
34
|
+
});
|
|
35
|
+
if (shell.error) throw shell.error;
|
|
36
|
+
if (shell.status !== 0) {
|
|
37
|
+
process.stderr.write(`shell syntax check failed: mbm\n${shell.stderr || shell.stdout}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`syntax check ok (${files.length} JavaScript files${process.platform === "win32" ? "" : "; mbm shell wrapper"})`);
|
|
43
|
+
|
|
44
|
+
function collect(directory) {
|
|
45
|
+
const files = [];
|
|
46
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
47
|
+
const target = join(directory, entry.name);
|
|
48
|
+
if (entry.isDirectory()) files.push(...collect(target));
|
|
49
|
+
else if (entry.isFile()) files.push(target);
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
@@ -132,7 +132,9 @@ export class AppAutomationManager {
|
|
|
132
132
|
if (value !== null) throw new Error("value and value_resource are mutually exclusive");
|
|
133
133
|
value = await this.readResourceText(requiredResourceName(args.value_resource));
|
|
134
134
|
}
|
|
135
|
-
if (value !== null && value.length > MAX_TEXT_CHARS)
|
|
135
|
+
if (value !== null && (value.includes("\0") || value.length > MAX_TEXT_CHARS)) {
|
|
136
|
+
throw new Error(`application action value exceeds ${MAX_TEXT_CHARS} characters or contains a NUL byte`);
|
|
137
|
+
}
|
|
136
138
|
const payload = {
|
|
137
139
|
operation: "act",
|
|
138
140
|
application: processName,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
-
import {
|
|
7
|
+
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
8
8
|
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
9
|
-
import { ownerOnlyFile, packageRoot } from "./state.mjs";
|
|
9
|
+
import { assertStateMaintenanceAvailable, ownerOnlyFile, packageRoot } from "./state.mjs";
|
|
10
10
|
|
|
11
11
|
const DEFAULT_PORT = 39393;
|
|
12
12
|
const MAX_PORT_ATTEMPTS = 10;
|
|
@@ -200,9 +200,11 @@ export class BrowserBridgeManager {
|
|
|
200
200
|
const resource = this.readResourceBinary(name);
|
|
201
201
|
total += resource.buffer.length;
|
|
202
202
|
if (total > 5 * 1024 * 1024) throw new Error("browser upload resources exceed 5 MiB total");
|
|
203
|
+
const suppliedFilename = filenames[files.length];
|
|
204
|
+
const derivedFilename = resource.path.split(/[\\/]/).pop() || name;
|
|
203
205
|
files.push({
|
|
204
|
-
filename:
|
|
205
|
-
mime:
|
|
206
|
+
filename: normalizeUploadFilename(suppliedFilename || derivedFilename, { derived: !suppliedFilename }),
|
|
207
|
+
mime: normalizeMimeType(mimeTypes[files.length] || "application/octet-stream"),
|
|
206
208
|
data: resource.buffer.toString("base64"),
|
|
207
209
|
});
|
|
208
210
|
}
|
|
@@ -271,6 +273,7 @@ export class BrowserBridgeManager {
|
|
|
271
273
|
|
|
272
274
|
async ensureStarted(context = {}) {
|
|
273
275
|
this.throwIfCancelled(context);
|
|
276
|
+
if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
|
|
274
277
|
this.stopping = false;
|
|
275
278
|
if (this.server || this.upstream?.readyState === 1) return;
|
|
276
279
|
if (!this.startPromise) this.startPromise = this.start();
|
|
@@ -356,15 +359,23 @@ export class BrowserBridgeManager {
|
|
|
356
359
|
resolvePromise(ok);
|
|
357
360
|
};
|
|
358
361
|
ws.on("message", (data) => {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
363
|
+
if (!parsed.ok) {
|
|
364
|
+
closeProtocolSocket(ws, parsed.code, parsed.reason);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const message = parsed.message;
|
|
368
|
+
if (!settled) {
|
|
369
|
+
if (message.type !== "hello" || message.role !== "runtime") {
|
|
370
|
+
closeProtocolSocket(ws, 1002, "runtime hello required");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
362
373
|
this.upstream = ws;
|
|
363
374
|
this.proxyExtensionConnected = message.extension_connected === true;
|
|
364
375
|
finish(true);
|
|
365
376
|
return;
|
|
366
377
|
}
|
|
367
|
-
this.handleUpstreamMessage(message);
|
|
378
|
+
if (!this.handleUpstreamMessage(message)) closeProtocolSocket(ws, 1002, "invalid broker protocol message");
|
|
368
379
|
});
|
|
369
380
|
ws.once("error", () => finish(false));
|
|
370
381
|
ws.once("close", () => {
|
|
@@ -398,7 +409,7 @@ export class BrowserBridgeManager {
|
|
|
398
409
|
}
|
|
399
410
|
if (this.socket && this.socket.readyState === 1) this.socket.close(4001, "superseded");
|
|
400
411
|
this.socket = ws;
|
|
401
|
-
ws.on("message", (data) => this.handleExtensionMessage(data));
|
|
412
|
+
ws.on("message", (data) => this.handleExtensionMessage(ws, data));
|
|
402
413
|
ws.on("close", () => {
|
|
403
414
|
if (this.socket !== ws) return;
|
|
404
415
|
this.socket = null;
|
|
@@ -415,11 +426,18 @@ export class BrowserBridgeManager {
|
|
|
415
426
|
this.broadcastRuntimeStatus(true);
|
|
416
427
|
}
|
|
417
428
|
|
|
418
|
-
handleExtensionMessage(data) {
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
429
|
+
handleExtensionMessage(socket, data) {
|
|
430
|
+
if (this.socket !== socket) return;
|
|
431
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
432
|
+
if (!parsed.ok) {
|
|
433
|
+
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const message = parsed.message;
|
|
437
|
+
if (message.type !== "response" || typeof message.id !== "string") {
|
|
438
|
+
closeProtocolSocket(socket, 1002, "invalid extension protocol message");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
423
441
|
const pending = this.pending.get(message.id);
|
|
424
442
|
if (pending) {
|
|
425
443
|
clearTimeout(pending.timeout);
|
|
@@ -436,11 +454,14 @@ export class BrowserBridgeManager {
|
|
|
436
454
|
}
|
|
437
455
|
|
|
438
456
|
handleRuntimeClientMessage(socket, data) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
457
|
+
const parsed = parseBrowserSocketMessage(data);
|
|
458
|
+
if (!parsed.ok) {
|
|
459
|
+
closeProtocolSocket(socket, parsed.code, parsed.reason);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const message = parsed.message;
|
|
463
|
+
if (message.type === "ping") return;
|
|
464
|
+
if (message.type === "cancel" && typeof message.id === "string") {
|
|
444
465
|
for (const [routedId, route] of this.proxyRoutes) {
|
|
445
466
|
if (route.socket !== socket || route.id !== message.id) continue;
|
|
446
467
|
clearTimeout(route.timeout);
|
|
@@ -449,7 +470,10 @@ export class BrowserBridgeManager {
|
|
|
449
470
|
}
|
|
450
471
|
return;
|
|
451
472
|
}
|
|
452
|
-
if (
|
|
473
|
+
if (message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") {
|
|
474
|
+
closeProtocolSocket(socket, 1002, "invalid runtime protocol message");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
453
477
|
if (!this.socket || this.socket.readyState !== 1) {
|
|
454
478
|
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser extension is not connected" });
|
|
455
479
|
return;
|
|
@@ -469,13 +493,13 @@ export class BrowserBridgeManager {
|
|
|
469
493
|
const route = this.proxyRoutes.get(routedId);
|
|
470
494
|
if (!route) return;
|
|
471
495
|
this.proxyRoutes.delete(routedId);
|
|
472
|
-
try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
|
|
473
496
|
safeSocketSend(socket, { type: "response", id: message.id, ok: false, error: "browser broker request timed out" });
|
|
497
|
+
try { this.socket?.send(JSON.stringify({ type: "cancel", id: routedId })); } catch {}
|
|
474
498
|
}, timeoutMs);
|
|
475
499
|
timeout.unref?.();
|
|
476
500
|
this.proxyRoutes.set(routedId, { socket, id: message.id, timeout });
|
|
477
501
|
try {
|
|
478
|
-
this.socket.send(JSON.stringify({ ...message, id: routedId }));
|
|
502
|
+
this.socket.send(JSON.stringify({ ...message, id: routedId, timeout_ms: timeoutMs }));
|
|
479
503
|
} catch {
|
|
480
504
|
clearTimeout(timeout);
|
|
481
505
|
this.proxyRoutes.delete(routedId);
|
|
@@ -484,19 +508,19 @@ export class BrowserBridgeManager {
|
|
|
484
508
|
}
|
|
485
509
|
|
|
486
510
|
handleUpstreamMessage(message) {
|
|
487
|
-
if (
|
|
488
|
-
|
|
489
|
-
this.proxyExtensionConnected = message.extension_connected === true;
|
|
511
|
+
if (message.type === "status" && typeof message.extension_connected === "boolean") {
|
|
512
|
+
this.proxyExtensionConnected = message.extension_connected;
|
|
490
513
|
if (!this.proxyExtensionConnected) this.rejectPending("browser extension disconnected");
|
|
491
|
-
return;
|
|
514
|
+
return true;
|
|
492
515
|
}
|
|
493
|
-
if (message.type !== "response" || typeof message.id !== "string") return;
|
|
516
|
+
if (message.type !== "response" || typeof message.id !== "string") return false;
|
|
494
517
|
const pending = this.pending.get(message.id);
|
|
495
|
-
if (!pending) return;
|
|
518
|
+
if (!pending) return true;
|
|
496
519
|
clearTimeout(pending.timeout);
|
|
497
520
|
this.pending.delete(message.id);
|
|
498
521
|
if (message.ok === false) pending.reject(new Error(String(message.error || "browser operation failed").slice(0, 2000)));
|
|
499
522
|
else pending.resolve(message.result);
|
|
523
|
+
return true;
|
|
500
524
|
}
|
|
501
525
|
|
|
502
526
|
broadcastRuntimeStatus(connected) {
|
|
@@ -587,6 +611,7 @@ export class BrowserBridgeManager {
|
|
|
587
611
|
|
|
588
612
|
async function loadOrCreatePairing(stateRoot) {
|
|
589
613
|
if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
|
|
614
|
+
assertStateMaintenanceAvailable(stateRoot);
|
|
590
615
|
await mkdir(stateRoot, { recursive: true, mode: 0o700 });
|
|
591
616
|
const file = join(stateRoot, PAIRING_FILE);
|
|
592
617
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
@@ -600,18 +625,12 @@ async function loadOrCreatePairing(stateRoot) {
|
|
|
600
625
|
return parsed;
|
|
601
626
|
}
|
|
602
627
|
const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
|
|
603
|
-
let fd;
|
|
604
628
|
try {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
`, "utf8");
|
|
608
|
-
fsyncSync(fd);
|
|
609
|
-
closeSync(fd);
|
|
610
|
-
fd = undefined;
|
|
629
|
+
createExclusiveFileSync(file, `${JSON.stringify(value, null, 2)}
|
|
630
|
+
`, { mode: 0o600 });
|
|
611
631
|
ownerOnlyFile(file);
|
|
612
632
|
return value;
|
|
613
633
|
} catch (error) {
|
|
614
|
-
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
615
634
|
if (error?.code !== "EEXIST") throw error;
|
|
616
635
|
}
|
|
617
636
|
}
|
|
@@ -619,23 +638,11 @@ async function loadOrCreatePairing(stateRoot) {
|
|
|
619
638
|
}
|
|
620
639
|
|
|
621
640
|
async function savePairing(stateRoot, value) {
|
|
641
|
+
assertStateMaintenanceAvailable(stateRoot);
|
|
622
642
|
const file = join(stateRoot, PAIRING_FILE);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
fd = openSync(temp, "wx", 0o600);
|
|
627
|
-
writeFileSync(fd, `${JSON.stringify(value, null, 2)}
|
|
628
|
-
`, "utf8");
|
|
629
|
-
fsyncSync(fd);
|
|
630
|
-
closeSync(fd);
|
|
631
|
-
fd = undefined;
|
|
632
|
-
replaceFileSync(temp, file);
|
|
633
|
-
ownerOnlyFile(file);
|
|
634
|
-
} catch (error) {
|
|
635
|
-
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
636
|
-
try { unlinkSync(temp); } catch {}
|
|
637
|
-
throw error;
|
|
638
|
-
}
|
|
643
|
+
replaceFileAtomicallySync(file, `${JSON.stringify(value, null, 2)}
|
|
644
|
+
`, { mode: 0o600 });
|
|
645
|
+
ownerOnlyFile(file);
|
|
639
646
|
}
|
|
640
647
|
|
|
641
648
|
function pairingHtml(port, token) {
|
|
@@ -657,6 +664,22 @@ function securityHeaders(contentType) {
|
|
|
657
664
|
};
|
|
658
665
|
}
|
|
659
666
|
|
|
667
|
+
function parseBrowserSocketMessage(data) {
|
|
668
|
+
if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return { ok: false, code: 1009, reason: "message too large" };
|
|
669
|
+
let text;
|
|
670
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(data)); }
|
|
671
|
+
catch { return { ok: false, code: 1007, reason: "invalid UTF-8" }; }
|
|
672
|
+
let message;
|
|
673
|
+
try { message = JSON.parse(text); }
|
|
674
|
+
catch { return { ok: false, code: 1007, reason: "invalid JSON" }; }
|
|
675
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) return { ok: false, code: 1002, reason: "invalid protocol message" };
|
|
676
|
+
return { ok: true, message };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function closeProtocolSocket(socket, code, reason) {
|
|
680
|
+
try { socket.close(code, reason); } catch {}
|
|
681
|
+
}
|
|
682
|
+
|
|
660
683
|
function safeSocketSend(socket, value) {
|
|
661
684
|
if (!socket || socket.readyState !== 1) return false;
|
|
662
685
|
try {
|
|
@@ -708,6 +731,24 @@ function validateNavigationUrl(value) {
|
|
|
708
731
|
return parsed.href;
|
|
709
732
|
}
|
|
710
733
|
|
|
734
|
+
function normalizeUploadFilename(value, { derived = false } = {}) {
|
|
735
|
+
let name = String(value || "");
|
|
736
|
+
if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
|
|
737
|
+
if (!name || name === "." || name === ".." || name.length > 255 || /[\u0000-\u001f\u007f/\\]/.test(name)) {
|
|
738
|
+
if (derived) return "upload.bin";
|
|
739
|
+
throw new Error("filenames entries must be safe single-component filenames of at most 255 characters");
|
|
740
|
+
}
|
|
741
|
+
return name;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizeMimeType(value) {
|
|
745
|
+
const mime = String(value || "").trim().toLowerCase();
|
|
746
|
+
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 200) {
|
|
747
|
+
throw new Error("mime_types entries must be valid media types");
|
|
748
|
+
}
|
|
749
|
+
return mime;
|
|
750
|
+
}
|
|
751
|
+
|
|
711
752
|
function optionalStringArray(value, label, maxItems, maxLength) {
|
|
712
753
|
if (value === undefined || value === null) return [];
|
|
713
754
|
if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${label} must be an array with at most ${maxItems} entries`);
|