machine-bridge-mcp 0.11.1 → 0.12.1

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.
@@ -12,16 +12,23 @@ const findings = [];
12
12
  for (const relativePath of candidates) {
13
13
  if (relativePath === ".privacy-denylist") continue;
14
14
  scanDenylistPath(relativePath, denylist, findings);
15
+ scanSensitivePath(relativePath, findings);
15
16
  const fullPath = path.join(root, relativePath);
16
17
  let info;
17
- try { info = lstatSync(fullPath); } catch { continue; }
18
+ try { info = lstatSync(fullPath); } catch {
19
+ findings.push({ path: relativePath, line: 1, rule: "publication file metadata could not be read" });
20
+ continue;
21
+ }
18
22
  if (info.isSymbolicLink()) {
19
23
  findings.push({ path: relativePath, line: 1, rule: "symbolic link in publication surface" });
20
24
  continue;
21
25
  }
22
26
  if (!info.isFile()) continue;
23
27
  let buffer;
24
- try { buffer = readFileSync(fullPath); } catch { continue; }
28
+ try { buffer = readFileSync(fullPath); } catch {
29
+ findings.push({ path: relativePath, line: 1, rule: "publication file content could not be read" });
30
+ continue;
31
+ }
25
32
  if (buffer.length > 5 * 1024 * 1024) {
26
33
  findings.push({ path: relativePath, line: 1, rule: "file exceeds privacy scanner size limit and requires manual review" });
27
34
  continue;
@@ -36,6 +43,7 @@ for (const relativePath of candidates) {
36
43
  continue;
37
44
  }
38
45
  if (relativePath !== selfPath) scanBuiltIn(relativePath, text, findings);
46
+ if (path.basename(relativePath).toLowerCase() === ".npmrc") scanNpmrc(relativePath, text, findings);
39
47
  scanDenylist(relativePath, text, denylist, findings);
40
48
  }
41
49
 
@@ -65,7 +73,9 @@ function collectCandidateFiles(directory) {
65
73
  const relative = stack.pop();
66
74
  const absolute = path.join(directory, relative);
67
75
  let entries;
68
- try { entries = readdirSync(absolute, { withFileTypes: true }); } catch { continue; }
76
+ try { entries = readdirSync(absolute, { withFileTypes: true }); } catch {
77
+ throw new Error("privacy fallback traversal could not read a repository directory");
78
+ }
69
79
  for (const entry of entries) {
70
80
  if (excluded.has(entry.name) || entry.name === ".privacy-denylist") continue;
71
81
  const child = relative ? path.join(relative, entry.name) : entry.name;
@@ -92,10 +102,17 @@ function escapeRegExp(value) {
92
102
 
93
103
  function scanBuiltIn(relativePath, text, out) {
94
104
  const rules = [
95
- ["private key material", /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g],
105
+ ["private key material", /-----BEGIN\s+(?:(?:OPENSSH|RSA|EC|DSA)\s+|ENCRYPTED\s+)?PRIVATE\s+KEY-----/g],
96
106
  ["AWS access key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
97
107
  ["GitHub access token", /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g],
108
+ ["GitLab access token", /\bglpat-[A-Za-z0-9_-]{20,}\b/g],
109
+ ["npm access token", /\bnpm_[A-Za-z0-9]{30,}\b/g],
110
+ ["Slack access token", /\bxox[aboprs]-[A-Za-z0-9-]{10,}\b/g],
111
+ ["Google API key", /\bAIza[A-Za-z0-9_-]{30,}\b/g],
112
+ ["live payment API key", /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g],
98
113
  ["API secret token", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g],
114
+ ["JWT-like bearer token", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g],
115
+ ["URL with embedded credentials", /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi],
99
116
  ["absolute macOS/Linux home path", /(?:^|[\s"'=(:,])\/(?:Users|home)\/([^/\s"'<>]+)\//gm],
100
117
  ["absolute Windows home path", /(?:^|[\s"'=(:,])\b[A-Za-z]:\\Users\\([^\\\s"'<>]+)\\/gm],
101
118
  ];
@@ -105,6 +122,11 @@ function scanBuiltIn(relativePath, text, out) {
105
122
  out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule });
106
123
  }
107
124
  }
125
+ const emailLike = /\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b/gi;
126
+ for (const match of text.matchAll(emailLike)) {
127
+ if (isReservedExampleHost(match[1]) || String(match[1]).toLowerCase().endsWith(".noreply.github.com")) continue;
128
+ out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule: "non-example email address" });
129
+ }
108
130
  const sshLike = /\b[A-Za-z_][A-Za-z0-9._-]*@([A-Za-z0-9.-]+)\b/g;
109
131
  for (const match of text.matchAll(sshLike)) {
110
132
  if (isReservedExampleHost(match[1]) || !hasNearbySshContext(text, match.index || 0)) continue;
@@ -112,6 +134,40 @@ function scanBuiltIn(relativePath, text, out) {
112
134
  }
113
135
  }
114
136
 
137
+
138
+ function scanSensitivePath(relativePath, out) {
139
+ const normalized = String(relativePath).split(path.sep).join("/");
140
+ const base = path.posix.basename(normalized).toLowerCase();
141
+ const sensitive = base === ".dev.vars"
142
+ || /^\.env(?:\.|$)/.test(base)
143
+ || /\.(?:pem|p12|pfx|sqlite|sqlite3|db|log)$/i.test(base)
144
+ || /^(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base)
145
+ || /^(?:credentials?|secrets?|cookies?|sessions?|tokens?)(?:\.|$)/i.test(base);
146
+ if (sensitive) out.push({ path: relativePath, line: 1, rule: "credential- or private-data-shaped publication filename" });
147
+ }
148
+
149
+ function scanNpmrc(relativePath, text, out) {
150
+ const forbiddenKey = /(?:^|:)(?:_auth|_authtoken|_password|username|email|certfile|keyfile|cafile)$/i;
151
+ const lines = text.split(/\r?\n/);
152
+ for (let index = 0; index < lines.length; index += 1) {
153
+ const line = lines[index].trim();
154
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
155
+ const separator = line.indexOf("=");
156
+ if (separator < 1) {
157
+ out.push({ path: relativePath, line: index + 1, rule: "unparseable tracked .npmrc line requires manual review" });
158
+ continue;
159
+ }
160
+ const key = line.slice(0, separator).trim().toLowerCase();
161
+ const value = line.slice(separator + 1).trim();
162
+ if (forbiddenKey.test(key) || /(?:^|[:/])(?:_auth|_authtoken|_password|username|email|certfile|keyfile|cafile)$/i.test(key)) {
163
+ out.push({ path: relativePath, line: index + 1, rule: "tracked .npmrc contains authentication or identity configuration" });
164
+ }
165
+ if (/\$\{[^}]+\}/.test(value) || /https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(value)) {
166
+ out.push({ path: relativePath, line: index + 1, rule: "tracked .npmrc contains environment interpolation or embedded credentials" });
167
+ }
168
+ }
169
+ }
170
+
115
171
  function scanDenylistPath(relativePath, entries, out) {
116
172
  const lower = relativePath.toLocaleLowerCase("en-US");
117
173
  for (const entry of entries) {
@@ -133,7 +189,9 @@ function scanDenylist(relativePath, text, entries, out) {
133
189
  function loadDenylist(file) {
134
190
  if (!existsSync(file)) return [];
135
191
  let text;
136
- try { text = readFileSync(file, "utf8"); } catch { return []; }
192
+ try { text = readFileSync(file, "utf8"); } catch {
193
+ throw new Error("local privacy denylist exists but could not be read");
194
+ }
137
195
  return [...new Set(text.split(/\r?\n/)
138
196
  .map((line) => line.trim())
139
197
  .filter((line) => line && !line.startsWith("#") && line.length >= 3)
@@ -163,6 +221,9 @@ function isReservedExampleHost(host) {
163
221
  || value === "127.0.0.1"
164
222
  || value === "::1"
165
223
  || value === "example"
224
+ || value === "example.com"
225
+ || value === "example.net"
226
+ || value === "example.org"
166
227
  || value.endsWith(".example")
167
228
  || value.endsWith(".example.com")
168
229
  || value.endsWith(".example.net")
@@ -0,0 +1,10 @@
1
+ export function tagSyncError({ scope = "local", tag, head, commit }) {
2
+ const label = scope === "remote" ? "remote tag" : "local tag";
3
+ if (!commit) {
4
+ return `${label} ${tag} is missing; run npm run release:publish before npm publish`;
5
+ }
6
+ if (commit !== head) {
7
+ return `${label} ${tag} points to ${commit}, not HEAD ${head}`;
8
+ }
9
+ return "";
10
+ }
@@ -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
+ }
@@ -112,12 +112,12 @@ export class AgentContextManager {
112
112
  const discovered = await this.discoverSkills(state, { maxResults: MAX_SKILL_RESULTS }, context);
113
113
  const maxSkills = clampInt(args.max_skills, 10, 1, 50);
114
114
  const skillMatches = discovered.skills
115
- .map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`) }))
115
+ .map((skill) => ({ skill, score: relevanceScore(task, `${skill.name} ${skill.description}`, skill.name) }))
116
116
  .filter((item) => item.score > 0)
117
117
  .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
118
118
  .slice(0, maxSkills);
119
119
  const commandMatches = [...state.commands.values()]
120
- .map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}`) }))
120
+ .map((command) => ({ command, score: relevanceScore(task, `${command.name} ${command.description} ${command.argv.join(" ")}`, command.name) }))
121
121
  .filter((item) => item.score > 0)
122
122
  .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name))
123
123
  .slice(0, 20);
@@ -718,7 +718,7 @@ function capabilityFingerprint(state, skills) {
718
718
  }));
719
719
  }
720
720
 
721
- function relevanceScore(task, candidate) {
721
+ function relevanceScore(task, candidate, identity = "") {
722
722
  const taskTokens = tokenize(task);
723
723
  const candidateTokens = tokenize(candidate);
724
724
  if (!taskTokens.size || !candidateTokens.size) return 0;
@@ -726,18 +726,31 @@ function relevanceScore(task, candidate) {
726
726
  for (const token of taskTokens) {
727
727
  if (candidateTokens.has(token)) score += token.length >= 6 ? 2 : 1;
728
728
  }
729
- const taskLower = task.toLowerCase();
730
- const candidateLower = candidate.toLowerCase();
731
- if (candidateLower.includes(taskLower) || taskLower.includes(candidateLower)) score += 4;
729
+ const taskComparable = comparableText(task);
730
+ const candidateComparable = comparableText(candidate);
731
+ const identityComparable = comparableText(identity);
732
+ if (candidateComparable.includes(taskComparable) || taskComparable.includes(candidateComparable)) score += 4;
733
+ if (identityComparable.length >= 3 && ` ${taskComparable} `.includes(` ${identityComparable} `)) score += 12;
732
734
  return score;
733
735
  }
734
736
 
737
+ function comparableText(value) {
738
+ return String(value || "")
739
+ .toLowerCase()
740
+ .replace(/[^a-z0-9\p{Script=Han}]+/gu, " ")
741
+ .trim()
742
+ .replace(/\s+/g, " ");
743
+ }
744
+
735
745
  function tokenize(value) {
736
746
  const text = String(value || "").toLowerCase();
737
747
  const tokens = new Set();
738
748
  for (const raw of text.match(/[a-z0-9_][a-z0-9_.-]{1,}/g) || []) {
739
749
  const token = raw.replace(/^[.-]+|[.-]+$/g, "");
740
750
  if (token.length >= 2 && !TOKEN_STOP_WORDS.has(token)) tokens.add(token);
751
+ for (const part of token.split(/[._-]+/)) {
752
+ if (part.length >= 2 && !TOKEN_STOP_WORDS.has(part)) tokens.add(part);
753
+ }
741
754
  }
742
755
  for (const sequence of text.match(/[\p{Script=Han}]{1,}/gu) || []) {
743
756
  if (!TOKEN_STOP_WORDS.has(sequence)) tokens.add(sequence);
@@ -109,6 +109,7 @@ export class AppAutomationManager {
109
109
  maxDepth: clampInt(args.max_depth, 6, 1, MAX_UI_DEPTH),
110
110
  maxElements: clampInt(args.max_elements, 200, 1, MAX_UI_ELEMENTS),
111
111
  includeValues: args.include_values === true,
112
+ includeMenus: args.include_menus === true,
112
113
  };
113
114
  const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
114
115
  return {
@@ -132,7 +133,9 @@ export class AppAutomationManager {
132
133
  if (value !== null) throw new Error("value and value_resource are mutually exclusive");
133
134
  value = await this.readResourceText(requiredResourceName(args.value_resource));
134
135
  }
135
- if (value !== null && value.length > MAX_TEXT_CHARS) throw new Error(`application action value exceeds ${MAX_TEXT_CHARS} characters`);
136
+ if (value !== null && (value.includes("\0") || value.length > MAX_TEXT_CHARS)) {
137
+ throw new Error(`application action value exceeds ${MAX_TEXT_CHARS} characters or contains a NUL byte`);
138
+ }
136
139
  const payload = {
137
140
  operation: "act",
138
141
  application: processName,
@@ -141,6 +144,7 @@ export class AppAutomationManager {
141
144
  value,
142
145
  maxDepth: clampInt(args.max_depth, 8, 1, MAX_UI_DEPTH),
143
146
  maxElements: MAX_UI_ELEMENTS,
147
+ includeMenus: args.include_menus === true,
144
148
  };
145
149
  const result = await this.runJxa(payload, clampInt(args.timeout_seconds, 30, 1, 120), context);
146
150
  return {
@@ -166,9 +170,11 @@ export class AppAutomationManager {
166
170
  undefined,
167
171
  input,
168
172
  );
173
+ const output = result.stdout.trim();
174
+ if (!output) throw new Error("macOS accessibility helper returned no JSON output");
169
175
  let parsed;
170
176
  try {
171
- parsed = JSON.parse(result.stdout.trim() || "{}");
177
+ parsed = JSON.parse(output);
172
178
  } catch {
173
179
  throw new Error("macOS accessibility helper returned invalid JSON");
174
180
  }
@@ -335,7 +341,7 @@ function describe(element, index, includeValues) {
335
341
  return item;
336
342
  }
337
343
  function childrenOf(element) { return safe(() => element.uiElements(), []); }
338
- function flatten(root, maxDepth, maxElements, includeValues) {
344
+ function flatten(root, maxDepth, maxElements, includeValues, includeMenus) {
339
345
  const output = [];
340
346
  const elements = [];
341
347
  const stack = [{ element: root, depth: 0 }];
@@ -344,10 +350,12 @@ function flatten(root, maxDepth, maxElements, includeValues) {
344
350
  const children = childrenOf(current.element);
345
351
  for (let i = children.length - 1; i >= 0; i--) {
346
352
  const child = children[i];
353
+ const item = describe(child, output.length, includeValues);
347
354
  elements.push(child);
348
- output.push(describe(child, output.length, includeValues));
355
+ output.push(item);
349
356
  if (output.length >= maxElements) break;
350
- if (current.depth + 1 < maxDepth) stack.push({ element: child, depth: current.depth + 1 });
357
+ const isMenuTree = String(item.role || '').startsWith('AXMenu');
358
+ if (current.depth + 1 < maxDepth && (includeMenus || !isMenuTree)) stack.push({ element: child, depth: current.depth + 1 });
351
359
  }
352
360
  }
353
361
  return { output, elements, truncated: stack.length > 0 || output.length >= maxElements };
@@ -364,14 +372,14 @@ function main() {
364
372
  const process = se.applicationProcesses.byName(payload.application);
365
373
  if (!safe(() => process.exists(), false)) throw new Error('application process not found or Accessibility access denied');
366
374
  if (payload.operation === 'inspect') {
367
- const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true);
368
- return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated };
375
+ const flattened = flatten(process, payload.maxDepth, payload.maxElements, payload.includeValues === true, payload.includeMenus === true);
376
+ return { frontmost: safe(() => process.frontmost(), false), elements: flattened.output, truncated: flattened.truncated, menus_included: payload.includeMenus === true };
369
377
  }
370
378
  if (payload.action === 'activate') {
371
379
  process.frontmost = true;
372
380
  return { ok: true, matched: 1 };
373
381
  }
374
- const flattened = flatten(process, payload.maxDepth, payload.maxElements, true);
382
+ const flattened = flatten(process, payload.maxDepth, payload.maxElements, true, payload.includeMenus === true);
375
383
  const matchesList = [];
376
384
  for (let i = 0; i < flattened.output.length; i++) if (matches(flattened.output[i], payload.selector)) matchesList.push(i);
377
385
  const chosen = payload.selector.index !== undefined ? matchesList[payload.selector.index] : matchesList[0];
@@ -396,5 +404,5 @@ function main() {
396
404
  }
397
405
  return { ok: true, matched: matchesList.length, selected_index: chosen, element: describe(element, chosen, false) };
398
406
  }
399
- try { console.log(JSON.stringify(main())); } catch (error) { console.log(JSON.stringify({ error: String(error.message || error) })); }
407
+ (() => { try { return JSON.stringify(main()); } catch (error) { return JSON.stringify({ error: String(error.message || error) }); } })()
400
408
  `;
@@ -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`);