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.
@@ -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) throw new Error(`application action value exceeds ${MAX_TEXT_CHARS} characters`);
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 { closeSync, existsSync, fsyncSync, openSync, unlinkSync, writeFileSync } from "node:fs";
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 { replaceFileSync } from "./atomic-fs.mjs";
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: String(args.filenames?.[files.length] || resource.path.split(/[\\/]/).pop() || name).slice(0, 255),
205
- mime: String(args.mime_types?.[files.length] || "application/octet-stream").slice(0, 200),
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
- let message;
360
- try { message = JSON.parse(Buffer.from(data).toString("utf8")); } catch { return; }
361
- if (!settled && message?.type === "hello" && message?.role === "runtime") {
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 (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return;
420
- let message;
421
- try { message = JSON.parse(Buffer.from(data).toString("utf8")); } catch { return; }
422
- if (!message || message.type !== "response" || typeof message.id !== "string") return;
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
- if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return;
440
- let message;
441
- try { message = JSON.parse(Buffer.from(data).toString("utf8")); } catch { return; }
442
- if (message?.type === "ping") return;
443
- if (message?.type === "cancel" && typeof message.id === "string") {
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 (!message || message.type !== "request" || typeof message.id !== "string" || typeof message.method !== "string") return;
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 (!message || typeof message !== "object") return;
488
- if (message.type === "status") {
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
- fd = openSync(file, "wx", 0o600);
606
- writeFileSync(fd, `${JSON.stringify(value, null, 2)}
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
- const temp = `${file}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
624
- let fd;
625
- try {
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`);