machine-bridge-mcp 0.14.0 → 0.16.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +18 -8
  3. package/SECURITY.md +10 -3
  4. package/browser-extension/browser-operations.js +515 -0
  5. package/browser-extension/devtools-input.js +17 -2
  6. package/browser-extension/manifest.json +2 -2
  7. package/browser-extension/page-automation.js +245 -60
  8. package/browser-extension/pairing.js +1 -1
  9. package/browser-extension/service-worker.js +151 -354
  10. package/docs/ARCHITECTURE.md +6 -4
  11. package/docs/AUDIT.md +24 -1
  12. package/docs/LOCAL_AUTOMATION.md +10 -10
  13. package/docs/LOGGING.md +7 -1
  14. package/docs/OPERATIONS.md +13 -5
  15. package/docs/POLICY_REFERENCE.md +88 -0
  16. package/docs/TESTING.md +9 -2
  17. package/package.json +16 -3
  18. package/scripts/coverage-check.mjs +108 -0
  19. package/scripts/generate-policy-reference.mjs +95 -0
  20. package/src/local/agent-context.mjs +1 -103
  21. package/src/local/app-automation.mjs +7 -9
  22. package/src/local/browser-bridge.mjs +69 -143
  23. package/src/local/browser-extension-protocol.mjs +75 -0
  24. package/src/local/browser-pairing-store.mjs +83 -0
  25. package/src/local/call-registry.mjs +113 -0
  26. package/src/local/capability-ranking.mjs +103 -0
  27. package/src/local/cli-local-admin.mjs +308 -0
  28. package/src/local/cli-options.mjs +151 -0
  29. package/src/local/cli-policy.mjs +77 -0
  30. package/src/local/cli.mjs +16 -507
  31. package/src/local/errors.mjs +122 -0
  32. package/src/local/git-service.mjs +88 -0
  33. package/src/local/lifecycle.mjs +64 -0
  34. package/src/local/log.mjs +50 -19
  35. package/src/local/managed-job-plan.mjs +235 -0
  36. package/src/local/managed-jobs.mjs +16 -220
  37. package/src/local/observability.mjs +83 -0
  38. package/src/local/policy.mjs +148 -0
  39. package/src/local/process-execution.mjs +153 -0
  40. package/src/local/process-sessions.mjs +10 -20
  41. package/src/local/process-tracker.mjs +55 -0
  42. package/src/local/runtime.mjs +154 -672
  43. package/src/local/service.mjs +8 -3
  44. package/src/local/stdio.mjs +3 -11
  45. package/src/local/tool-executor.mjs +102 -0
  46. package/src/local/tools.mjs +21 -104
  47. package/src/local/workspace-file-service.mjs +451 -0
  48. package/src/shared/policy-contract.json +54 -0
  49. package/src/shared/tool-catalog.json +11 -11
  50. package/src/worker/index.ts +69 -524
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { commandMatchText, recommendTools, relevanceScore } from "./capability-ranking.mjs";
2
3
  import { constants as fsConstants } from "node:fs";
3
4
  import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
4
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -24,36 +25,6 @@ const MAX_COMMANDS = 128;
24
25
  const MAX_COMMAND_ARGV = 128;
25
26
  const MAX_COMMAND_ARGUMENT_BYTES = 256 * 1024;
26
27
  const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
27
- const TOKEN_STOP_WORDS = new Set(["the", "and", "for", "with", "from", "this", "that", "use", "using", "into", "of", "to", "a", "an", "in", "on", "is", "are", "or", "及", "和", "的", "了", "在", "用", "使用", "进行", "根据"]);
28
- const ENGLISH_TOKEN_CANONICAL = new Map([
29
- ["created", "create"], ["creating", "create"], ["creation", "create"], ["creator", "create"],
30
- ["improved", "improve"], ["improving", "improve"], ["improvement", "improve"],
31
- ["installed", "install"], ["installing", "install"], ["installation", "install"], ["installer", "install"],
32
- ["searched", "search"], ["searching", "search"], ["finder", "find"],
33
- ["documentation", "docs"], ["document", "docs"], ["documents", "docs"],
34
- ["validated", "verify"], ["validate", "verify"], ["validation", "verify"], ["verification", "verify"],
35
- ["factcheck", "verify"], ["fact-check", "verify"], ["testing", "test"], ["tests", "test"],
36
- ["building", "build"], ["built", "build"], ["deployment", "deploy"], ["deployed", "deploy"],
37
- ]);
38
- const HAN_TOKEN_ALIASES = Object.freeze([
39
- [/创建|新建|编写/u, ["create", "creator"]],
40
- [/改进|优化|更新|维护/u, ["improve", "update"]],
41
- [/技能/u, ["skill"]],
42
- [/安装/u, ["install", "installer"]],
43
- [/部署/u, ["deploy", "deployment"]],
44
- [/查找|搜索|检索/u, ["search", "find"]],
45
- [/最新|当前/u, ["latest", "current"]],
46
- [/官方/u, ["official"]],
47
- [/文档|资料/u, ["docs", "documentation"]],
48
- [/事实核查|核查|验证|校验/u, ["verify", "factcheck"]],
49
- [/测试/u, ["test"]],
50
- [/前端/u, ["frontend"]],
51
- [/设计/u, ["design"]],
52
- [/邮件|邮箱/u, ["email"]],
53
- [/浏览器|网页|网站/u, ["browser", "web"]],
54
- [/性能/u, ["performance"]],
55
- [/安全|漏洞/u, ["security", "audit"]],
56
- ]);
57
28
  const CONFIG_KEYS = new Set(["version", "builtin_instructions", "automatic_project_context", "model_instructions_file", "instruction_files", "instruction_max_bytes", "skill_roots", "commands"]);
58
29
  const COMMAND_KEYS = new Set(["description", "argv", "cwd", "timeout_seconds", "allow_extra_args"]);
59
30
 
@@ -760,79 +731,6 @@ function capabilityFingerprint(state, skills) {
760
731
  }));
761
732
  }
762
733
 
763
- function commandMatchText(command) {
764
- return `${command.name} ${command.description} ${command.argv.join(" ")} ${command.searchTerms || ""}`;
765
- }
766
-
767
- function relevanceScore(task, candidate, identity = "") {
768
- const taskTokens = tokenize(task);
769
- const candidateTokens = tokenize(candidate);
770
- if (!taskTokens.size || !candidateTokens.size) return 0;
771
- const identityTokens = tokenize(identity);
772
- let score = 0;
773
- for (const token of taskTokens) {
774
- if (candidateTokens.has(token)) score += token.length >= 6 ? 2 : 1;
775
- if (identityTokens.has(token)) score += token.length >= 6 ? 4 : 3;
776
- }
777
- const taskComparable = comparableText(task);
778
- const candidateComparable = comparableText(candidate);
779
- const identityComparable = comparableText(identity);
780
- if (candidateComparable.includes(taskComparable) || taskComparable.includes(candidateComparable)) score += 4;
781
- if (identityComparable.length >= 3 && ` ${taskComparable} `.includes(` ${identityComparable} `)) score += 12;
782
- return score;
783
- }
784
-
785
- function comparableText(value) {
786
- return String(value || "")
787
- .toLowerCase()
788
- .replace(/[^a-z0-9\p{Script=Han}]+/gu, " ")
789
- .trim()
790
- .replace(/\s+/g, " ");
791
- }
792
-
793
- function tokenize(value) {
794
- const text = String(value || "").toLowerCase();
795
- const tokens = new Set();
796
- for (const raw of text.match(/[a-z0-9_][a-z0-9_.-]{1,}/g) || []) {
797
- const token = raw.replace(/^[.-]+|[.-]+$/g, "");
798
- addToken(tokens, token);
799
- for (const part of token.split(/[._-]+/)) addToken(tokens, part);
800
- }
801
- for (const sequence of text.match(/[\p{Script=Han}]{1,}/gu) || []) {
802
- addToken(tokens, sequence);
803
- const minimumSize = sequence.length === 1 ? 1 : 2;
804
- for (let size = minimumSize; size <= Math.min(3, sequence.length); size += 1) {
805
- for (let index = 0; index + size <= sequence.length; index += 1) addToken(tokens, sequence.slice(index, index + size));
806
- }
807
- }
808
- for (const [pattern, aliases] of HAN_TOKEN_ALIASES) {
809
- if (!pattern.test(text)) continue;
810
- for (const alias of aliases) addToken(tokens, alias);
811
- }
812
- return tokens;
813
- }
814
-
815
- function addToken(tokens, raw) {
816
- const token = String(raw || "").replace(/^[.-]+|[.-]+$/g, "");
817
- if (token.length < 2 || TOKEN_STOP_WORDS.has(token)) return;
818
- tokens.add(token);
819
- const canonical = ENGLISH_TOKEN_CANONICAL.get(token);
820
- if (canonical) tokens.add(canonical);
821
- }
822
-
823
- function recommendTools(task, { commandsAvailable, commandRelevant, skillRelevant }) {
824
- const lower = task.toLowerCase();
825
- const tools = ["agent_context"];
826
- if (skillRelevant) tools.push("load_local_skill");
827
- if (commandRelevant) tools.push("run_local_command");
828
- if (/browser|chrome|edge|brave|网页|浏览器|表单|网站/.test(lower)) tools.push("browser_status", "browser_list_tabs", "browser_manage_tabs", "browser_inspect_page", "browser_wait", "browser_action", "browser_fill_form");
829
- if (/app|application|gui|window|应用|软件|窗口|界面/.test(lower)) tools.push("list_local_applications", "inspect_local_application", "operate_local_application");
830
- if (/git|commit|branch|diff|仓库|提交|分支/.test(lower)) tools.push("git_status", "git_diff");
831
- if (/test|build|lint|command|terminal|测试|构建|命令|终端/.test(lower)) tools.push(commandsAvailable ? "run_local_command" : "run_process");
832
- if (/file|code|source|edit|write|文件|代码|源码|修改|写入/.test(lower)) tools.push("read_file", "search_text", "edit_file", "apply_patch");
833
- return [...new Set(tools)];
834
- }
835
-
836
734
  function publicSkill(skill, displayPath) {
837
735
  return {
838
736
  id: skill.id,
@@ -1,6 +1,7 @@
1
1
  import { opendir, realpath } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, extname, join } from "node:path";
4
+ import { createToolAuthorizer } from "./policy.mjs";
4
5
 
5
6
  const MAX_APPLICATIONS = 1000;
6
7
  const MAX_APPLICATION_SCAN_ENTRIES = 20_000;
@@ -12,8 +13,9 @@ const APP_NAME_PATTERN = /^[^\0\r\n]{1,300}$/;
12
13
  const DEFAULT_APPLICATION_CACHE_MS = 30_000;
13
14
 
14
15
  export class AppAutomationManager {
15
- constructor({ policy, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null, applicationCacheMs = DEFAULT_APPLICATION_CACHE_MS, now = () => Date.now() }) {
16
+ constructor({ policy, authorizeTool = null, displayPath, runProcess, readResourceText, throwIfCancelled = () => {}, platform = process.platform, home = homedir(), applicationRoots = null, applicationCacheMs = DEFAULT_APPLICATION_CACHE_MS, now = () => Date.now() }) {
16
17
  this.policy = policy || {};
18
+ this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
17
19
  this.displayPath = displayPath;
18
20
  this.runProcess = runProcess;
19
21
  this.readResourceText = readResourceText;
@@ -41,6 +43,7 @@ export class AppAutomationManager {
41
43
  }
42
44
 
43
45
  async listApplications(args = {}, context = {}) {
46
+ this.authorizeTool("list_local_applications");
44
47
  this.throwIfCancelled(context);
45
48
  const query = String(args.query || "").trim().toLowerCase();
46
49
  const limit = clampInt(args.max_results, 200, 1, MAX_APPLICATIONS);
@@ -86,7 +89,7 @@ export class AppAutomationManager {
86
89
  }
87
90
 
88
91
  async openApplication(args = {}, context = {}) {
89
- this.assertFull("open_local_application");
92
+ this.authorizeTool("open_local_application");
90
93
  const application = requiredApplication(args.application);
91
94
  const target = optionalString(args.target, "target", 32768);
92
95
  this.throwIfCancelled(context);
@@ -113,7 +116,7 @@ export class AppAutomationManager {
113
116
  }
114
117
 
115
118
  async inspectApplication(args = {}, context = {}) {
116
- this.assertFull("inspect_local_application");
119
+ this.authorizeTool("inspect_local_application");
117
120
  assertMacAccessibility(this.platform);
118
121
  const application = requiredApplication(args.application);
119
122
  const processName = applicationProcessName(application);
@@ -136,7 +139,7 @@ export class AppAutomationManager {
136
139
  }
137
140
 
138
141
  async operateApplication(args = {}, context = {}) {
139
- this.assertFull("operate_local_application");
142
+ this.authorizeTool("operate_local_application");
140
143
  assertMacAccessibility(this.platform);
141
144
  const application = requiredApplication(args.application);
142
145
  const processName = applicationProcessName(application);
@@ -196,11 +199,6 @@ export class AppAutomationManager {
196
199
  return parsed;
197
200
  }
198
201
 
199
- assertFull(tool) {
200
- if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
201
- throw new Error(`${tool} requires the canonical full profile`);
202
- }
203
- }
204
202
  }
205
203
 
206
204
  async function listMacApplications(roots, context, throwIfCancelled) {
@@ -1,37 +1,38 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { existsSync } from "node:fs";
4
- import { mkdir } from "node:fs/promises";
5
- import { join, resolve } from "node:path";
3
+ import { resolve } from "node:path";
6
4
  import { WebSocket, WebSocketServer } from "ws";
7
- import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
8
- import { readBoundedRegularFileSync } from "./secure-file.mjs";
9
- import { assertStateMaintenanceAvailable, ownerOnlyFile, packageRoot } from "./state.mjs";
5
+ import { createToolAuthorizer } from "./policy.mjs";
6
+ import { assertStateMaintenanceAvailable, packageRoot } from "./state.mjs";
7
+ import {
8
+ BROWSER_EXTENSION_PROTOCOL, EXPECTED_EXTENSION_VERSION, MAX_BROWSER_MESSAGE_BYTES, REQUIRED_EXTENSION_CAPABILITIES,
9
+ closeProtocolSocket, normalizeCompatibleExtensionInfo, parseBrowserSocketMessage, parseExtensionHello, safeSocketSend,
10
+ } from "./browser-extension-protocol.mjs";
11
+ import {
12
+ DEFAULT_BROWSER_PORT, isAllowedExtensionOrigin, isAllowedLoopbackHost, loadOrCreatePairing,
13
+ pairingHtml, savePairing, securityHeaders, sendJson,
14
+ } from "./browser-pairing-store.mjs";
10
15
  import {
11
16
  clampInt, normalizeBrowserAction, normalizeBrowserSelector, normalizeBrowserWait, normalizeFormAction,
12
17
  normalizeInputMode, normalizeNavigationWait, normalizeTabCommand, optionalInteger, optionalString,
13
18
  validateNavigationUrl,
14
19
  } from "./browser-command.mjs";
15
20
 
16
- const DEFAULT_PORT = 39393;
17
21
  const MAX_PORT_ATTEMPTS = 10;
18
- const MAX_BROWSER_MESSAGE_BYTES = 8 * 1024 * 1024;
19
22
  const MAX_PENDING = 32;
20
23
  const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
21
24
  const MAX_FORM_FIELDS = 200;
22
25
  const MAX_FIELD_VALUE_CHARS = 128 * 1024;
23
26
  const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
24
- const PAIRING_FILE = "browser-bridge.json";
25
27
  const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
26
- const BROWSER_EXTENSION_PROTOCOL = 2;
27
28
  const EXTENSION_HANDSHAKE_MS = 3_000;
28
- const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
29
- "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
30
- ]);
29
+
30
+
31
31
 
32
32
  export class BrowserBridgeManager {
33
- constructor({ policy, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
33
+ constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
34
34
  this.policy = policy || {};
35
+ this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
35
36
  this.stateRoot = stateRoot ? resolve(stateRoot) : "";
36
37
  this.runProcess = runProcess;
37
38
  this.readResourceText = readResourceText;
@@ -48,6 +49,7 @@ export class BrowserBridgeManager {
48
49
  this.extensionInfo = null;
49
50
  this.proxyExtensionInfo = null;
50
51
  this.proxyExtensionReloadRequired = false;
52
+ this.extensionReloadRequiredFlag = false;
51
53
  this.recoveryTimer = null;
52
54
  this.stopping = false;
53
55
  this.pending = new Map();
@@ -58,6 +60,7 @@ export class BrowserBridgeManager {
58
60
  }
59
61
 
60
62
  async status(context = {}) {
63
+ this.authorizeTool("browser_status");
61
64
  await this.ensureStarted(context);
62
65
  return {
63
66
  available: true,
@@ -66,12 +69,17 @@ export class BrowserBridgeManager {
66
69
  endpoint: `ws://127.0.0.1:${this.port}/extension`,
67
70
  pairing_url: `http://127.0.0.1:${this.port}/pair`,
68
71
  extension_path: this.extensionPath,
72
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
69
73
  extension_protocol: this.extensionStatusInfo()?.protocol || null,
70
74
  extension_version: this.extensionStatusInfo()?.version || "",
71
75
  extension_capabilities: this.extensionStatusInfo()?.capabilities || [],
72
76
  extension_reload_required: this.extensionReloadRequired(),
73
77
  supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
74
78
  controls_existing_profile: true,
79
+ controls_extension_profile: true,
80
+ launches_browser_process: false,
81
+ launches_separate_automation_profile: false,
82
+ profile_identity_verifiable: false,
75
83
  uses_existing_tabs_and_login_state: true,
76
84
  source_access: true,
77
85
  semantic_snapshot_refs: true,
@@ -93,7 +101,7 @@ export class BrowserBridgeManager {
93
101
  }
94
102
 
95
103
  async pair(args = {}, context = {}) {
96
- this.assertFull("pair_browser_extension");
104
+ this.authorizeTool("pair_browser_extension");
97
105
  const status = await this.status(context);
98
106
  if (args.open !== false) {
99
107
  const command = process.platform === "darwin"
@@ -116,6 +124,7 @@ export class BrowserBridgeManager {
116
124
  }
117
125
 
118
126
  async listTabs(args = {}, context = {}) {
127
+ this.authorizeTool("browser_list_tabs");
119
128
  const response = await this.request("list_tabs", {
120
129
  currentWindow: args.current_window === true,
121
130
  includePinned: args.include_pinned !== false,
@@ -124,16 +133,19 @@ export class BrowserBridgeManager {
124
133
  }
125
134
 
126
135
  async manageTabs(args = {}, context = {}) {
136
+ this.authorizeTool("browser_manage_tabs");
127
137
  return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
128
138
  }
129
139
 
130
140
  async wait(args = {}, context = {}) {
141
+ this.authorizeTool("browser_wait");
131
142
  const params = normalizeBrowserWait(args);
132
143
  const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
133
144
  return this.request("wait", params, conditionTimeoutSeconds + 5, context);
134
145
  }
135
146
 
136
147
  async getSource(args = {}, context = {}) {
148
+ this.authorizeTool("browser_get_source");
137
149
  const response = await this.request("get_source", {
138
150
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
139
151
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
@@ -144,6 +156,7 @@ export class BrowserBridgeManager {
144
156
  }
145
157
 
146
158
  async inspectPage(args = {}, context = {}) {
159
+ this.authorizeTool("browser_inspect_page");
147
160
  return this.request("inspect_page", {
148
161
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
149
162
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
@@ -154,7 +167,7 @@ export class BrowserBridgeManager {
154
167
  }
155
168
 
156
169
  async act(args = {}, context = {}) {
157
- this.assertFull("browser_action");
170
+ this.authorizeTool("browser_action");
158
171
  const action = normalizeBrowserAction(args.action);
159
172
  const rawUrl = optionalString(args.url, "url", 32768);
160
173
  const payload = {
@@ -191,7 +204,7 @@ export class BrowserBridgeManager {
191
204
  }
192
205
 
193
206
  async fillForm(args = {}, context = {}) {
194
- this.assertFull("browser_fill_form");
207
+ this.authorizeTool("browser_fill_form");
195
208
  if (!Array.isArray(args.fields) || !args.fields.length) throw new Error("fields must be a non-empty array");
196
209
  if (args.fields.length > MAX_FORM_FIELDS) throw new Error(`fields contains more than ${MAX_FORM_FIELDS} entries`);
197
210
  const fields = [];
@@ -233,7 +246,7 @@ export class BrowserBridgeManager {
233
246
  }
234
247
 
235
248
  async uploadFiles(args = {}, context = {}) {
236
- this.assertFull("browser_upload_files");
249
+ this.authorizeTool("browser_upload_files");
237
250
  if (!Array.isArray(args.resources) || !args.resources.length || args.resources.length > 8) throw new Error("resources must contain 1 to 8 registered resource names");
238
251
  const filenames = optionalStringArray(args.filenames, "filenames", 8, 255);
239
252
  const mimeTypes = optionalStringArray(args.mime_types, "mime_types", 8, 200);
@@ -265,6 +278,7 @@ export class BrowserBridgeManager {
265
278
  }
266
279
 
267
280
  async screenshot(args = {}, context = {}) {
281
+ this.authorizeTool("browser_screenshot");
268
282
  const result = await this.request("screenshot", {
269
283
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
270
284
  format: args.format === "jpeg" ? "jpeg" : "png",
@@ -287,7 +301,6 @@ export class BrowserBridgeManager {
287
301
  }
288
302
 
289
303
  async request(method, params, timeoutSeconds, context = {}) {
290
- this.assertFull(method);
291
304
  await this.ensureStarted(context);
292
305
  this.throwIfCancelled(context);
293
306
  const transport = this.server ? this.socket : this.upstream;
@@ -363,7 +376,7 @@ export class BrowserBridgeManager {
363
376
  const protocol = String(request.headers["sec-websocket-protocol"] || "");
364
377
  const origin = String(request.headers.origin || "");
365
378
  let role = "";
366
- if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && origin.startsWith("chrome-extension://")) role = "extension";
379
+ if (url.pathname === "/extension" && protocol === `mbm.${this.token}` && isAllowedExtensionOrigin(origin)) role = "extension";
367
380
  if (url.pathname === "/runtime" && protocol === `mbm-runtime.${this.token}` && !origin) role = "runtime";
368
381
  if (!role) {
369
382
  socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
@@ -472,7 +485,10 @@ export class BrowserBridgeManager {
472
485
  this.pendingExtensionSocket = ws;
473
486
  this.broadcastRuntimeStatus(this.extensionConnected());
474
487
  ws.extensionReady = false;
475
- ws.handshakeTimer = setTimeout(() => closeProtocolSocket(ws, 1002, "extension hello required; reload the extension"), EXTENSION_HANDSHAKE_MS);
488
+ ws.handshakeTimer = setTimeout(() => {
489
+ this.markExtensionReloadRequired();
490
+ closeProtocolSocket(ws, 1002, "extension hello required; reload the extension");
491
+ }, EXTENSION_HANDSHAKE_MS);
476
492
  ws.handshakeTimer.unref?.();
477
493
  ws.on("message", (data) => this.handleExtensionMessage(ws, data));
478
494
  ws.on("close", () => {
@@ -497,22 +513,30 @@ export class BrowserBridgeManager {
497
513
  if (this.socket !== socket && this.pendingExtensionSocket !== socket) return;
498
514
  const parsed = parseBrowserSocketMessage(data);
499
515
  if (!parsed.ok) {
516
+ this.markExtensionReloadRequired();
500
517
  closeProtocolSocket(socket, parsed.code, parsed.reason);
501
518
  return;
502
519
  }
503
520
  const message = parsed.message;
504
521
  if (message.type === "hello") {
505
522
  if (socket.extensionReady) {
523
+ this.markExtensionReloadRequired();
506
524
  closeProtocolSocket(socket, 1002, "duplicate extension hello");
507
525
  return;
508
526
  }
509
527
  let info;
510
528
  try { info = parseExtensionHello(message); }
511
529
  catch (error) {
530
+ this.markExtensionReloadRequired();
512
531
  closeProtocolSocket(socket, 1002, String(error?.message || error).slice(0, 120));
513
532
  return;
514
533
  }
515
534
  clearTimeout(socket.handshakeTimer);
535
+ if (!safeSocketSend(socket, { type: "hello_ack", role: "extension", protocol: BROWSER_EXTENSION_PROTOCOL })) {
536
+ closeProtocolSocket(socket, 1011, "extension acknowledgement failed");
537
+ return;
538
+ }
539
+ this.extensionReloadRequiredFlag = false;
516
540
  socket.extensionReady = true;
517
541
  if (this.pendingExtensionSocket === socket) this.pendingExtensionSocket = null;
518
542
  const previous = this.socket;
@@ -527,11 +551,13 @@ export class BrowserBridgeManager {
527
551
  return;
528
552
  }
529
553
  if (!socket.extensionReady) {
554
+ this.markExtensionReloadRequired();
530
555
  closeProtocolSocket(socket, 1002, "extension hello required; reload the extension");
531
556
  return;
532
557
  }
533
558
  if (message.type === "ping") return;
534
559
  if (message.type !== "response" || typeof message.id !== "string") {
560
+ this.markExtensionReloadRequired();
535
561
  closeProtocolSocket(socket, 1002, "invalid extension protocol message");
536
562
  return;
537
563
  }
@@ -654,7 +680,21 @@ export class BrowserBridgeManager {
654
680
  return;
655
681
  }
656
682
  if (url.pathname === "/healthz") {
657
- sendJson(response, { ok: true, connected: this.extensionConnected(), broker: "machine-bridge-browser" });
683
+ const extension = this.extensionStatusInfo();
684
+ sendJson(response, {
685
+ ok: true,
686
+ connected: this.extensionConnected(),
687
+ broker: "machine-bridge-browser",
688
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
689
+ extension_protocol: extension?.protocol || null,
690
+ extension_version: extension?.version || "",
691
+ extension_capabilities: extension?.capabilities || [],
692
+ extension_reload_required: this.extensionReloadRequired(),
693
+ controls_existing_profile: true,
694
+ controls_extension_profile: true,
695
+ machine_bridge_launches_browser: false,
696
+ profile_identity_verifiable: false,
697
+ });
658
698
  return;
659
699
  }
660
700
  if (url.pathname === "/pair") {
@@ -711,6 +751,7 @@ export class BrowserBridgeManager {
711
751
  this.proxyExtensionConnected = false;
712
752
  this.proxyExtensionInfo = null;
713
753
  this.proxyExtensionReloadRequired = false;
754
+ this.extensionReloadRequiredFlag = false;
714
755
  this.runtimeClients.clear();
715
756
  for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
716
757
  this.proxyRoutes.clear();
@@ -720,6 +761,11 @@ export class BrowserBridgeManager {
720
761
  this.server = null;
721
762
  }
722
763
 
764
+ markExtensionReloadRequired() {
765
+ this.extensionReloadRequiredFlag = true;
766
+ this.broadcastRuntimeStatus(this.extensionConnected());
767
+ }
768
+
723
769
  extensionConnected() {
724
770
  return this.server
725
771
  ? this.socket?.readyState === 1 && Boolean(this.extensionInfo)
@@ -732,130 +778,10 @@ export class BrowserBridgeManager {
732
778
 
733
779
  extensionReloadRequired() {
734
780
  return this.server
735
- ? this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected()
781
+ ? this.extensionReloadRequiredFlag || (this.pendingExtensionSocket?.readyState === 1 && !this.extensionConnected())
736
782
  : this.proxyExtensionReloadRequired;
737
783
  }
738
784
 
739
- assertFull(tool) {
740
- if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
741
- throw new Error(`${tool} requires the canonical full profile`);
742
- }
743
- }
744
- }
745
-
746
- function normalizeCompatibleExtensionInfo(value) {
747
- const info = normalizeExtensionInfo(value);
748
- if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL) return null;
749
- if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
750
- return info;
751
- }
752
-
753
- function parseExtensionHello(message) {
754
- if (message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
755
- throw new Error(`extension protocol mismatch; expected ${BROWSER_EXTENSION_PROTOCOL}; reload the extension`);
756
- }
757
- const info = normalizeExtensionInfo(message);
758
- if (!info) throw new Error("invalid extension hello; reload the extension");
759
- const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
760
- if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
761
- return info;
762
- }
763
-
764
- function normalizeExtensionInfo(value) {
765
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
766
- const protocol = Number(value.protocol);
767
- const version = typeof value.version === "string" && value.version.length <= 100 ? value.version : "";
768
- const capabilities = Array.isArray(value.capabilities)
769
- ? [...new Set(value.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
770
- : [];
771
- if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
772
- return { protocol, version, capabilities };
773
- }
774
-
775
- async function loadOrCreatePairing(stateRoot) {
776
- if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
777
- assertStateMaintenanceAvailable(stateRoot);
778
- await mkdir(stateRoot, { recursive: true, mode: 0o700 });
779
- const file = join(stateRoot, PAIRING_FILE);
780
- for (let attempt = 0; attempt < 2; attempt += 1) {
781
- if (existsSync(file)) {
782
- ownerOnlyFile(file);
783
- let parsed;
784
- try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); } catch { throw new Error("browser pairing state is not valid bounded JSON"); }
785
- if (!/^[A-Za-z0-9_-]{32,100}$/.test(parsed.token) || !Number.isInteger(parsed.port) || parsed.port < 1024 || parsed.port > 65535) {
786
- throw new Error("browser pairing state is invalid");
787
- }
788
- return parsed;
789
- }
790
- const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
791
- try {
792
- createExclusiveFileSync(file, `${JSON.stringify(value, null, 2)}
793
- `, { mode: 0o600 });
794
- ownerOnlyFile(file);
795
- return value;
796
- } catch (error) {
797
- if (error?.code !== "EEXIST") throw error;
798
- }
799
- }
800
- throw new Error("browser pairing state could not be initialized");
801
- }
802
-
803
- async function savePairing(stateRoot, value) {
804
- assertStateMaintenanceAvailable(stateRoot);
805
- const file = join(stateRoot, PAIRING_FILE);
806
- replaceFileAtomicallySync(file, `${JSON.stringify(value, null, 2)}
807
- `, { mode: 0o600 });
808
- ownerOnlyFile(file);
809
- }
810
-
811
- function pairingHtml(port, token) {
812
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="machine-bridge-browser-pair" content="1"><meta name="machine-bridge-browser-port" content="${port}"><meta name="machine-bridge-browser-token" content="${token}"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Machine Bridge browser pairing</title></head><body><h1>Machine Bridge browser pairing</h1><p>The installed extension reads pairing material from this loopback-only page and stores it in browser-local extension storage. It is not sent to any website.</p><p id="status">Waiting for the Machine Bridge extension.</p></body></html>`;
813
- }
814
-
815
- function isAllowedLoopbackHost(host, port) {
816
- const normalized = String(host || "").toLowerCase();
817
- return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}` || normalized === `[::1]:${port}`;
818
- }
819
-
820
- function securityHeaders(contentType) {
821
- return {
822
- "content-type": contentType,
823
- "cache-control": "no-store",
824
- "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
825
- "x-content-type-options": "nosniff",
826
- "referrer-policy": "no-referrer",
827
- };
828
- }
829
-
830
- function parseBrowserSocketMessage(data) {
831
- if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return { ok: false, code: 1009, reason: "message too large" };
832
- let text;
833
- try { text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(data)); }
834
- catch { return { ok: false, code: 1007, reason: "invalid UTF-8" }; }
835
- let message;
836
- try { message = JSON.parse(text); }
837
- catch { return { ok: false, code: 1007, reason: "invalid JSON" }; }
838
- if (!message || typeof message !== "object" || Array.isArray(message)) return { ok: false, code: 1002, reason: "invalid protocol message" };
839
- return { ok: true, message };
840
- }
841
-
842
- function closeProtocolSocket(socket, code, reason) {
843
- try { socket.close(code, reason); } catch {}
844
- }
845
-
846
- function safeSocketSend(socket, value) {
847
- if (!socket || socket.readyState !== 1) return false;
848
- try {
849
- socket.send(typeof value === "string" ? value : JSON.stringify(value));
850
- return true;
851
- } catch {
852
- return false;
853
- }
854
- }
855
-
856
- function sendJson(response, value) {
857
- response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
858
- response.end(`${JSON.stringify(value)}\n`);
859
785
  }
860
786
 
861
787
  function normalizeUploadFilename(value, { derived = false } = {}) {
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { packageRoot } from "./state.mjs";
4
+
5
+ export const BROWSER_EXTENSION_PROTOCOL = 3;
6
+ export const EXPECTED_EXTENSION_VERSION = extensionVersion();
7
+ export const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
8
+ "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
9
+ ]);
10
+ export const MAX_BROWSER_MESSAGE_BYTES = 8 * 1024 * 1024;
11
+
12
+ export function normalizeCompatibleExtensionInfo(value) {
13
+ const info = normalizeExtensionInfo(value);
14
+ if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION) return null;
15
+ if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
16
+ return info;
17
+ }
18
+
19
+ export function parseExtensionHello(message) {
20
+ if (message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
21
+ throw new Error(`extension protocol mismatch; expected ${BROWSER_EXTENSION_PROTOCOL}; reload the extension`);
22
+ }
23
+ const info = normalizeExtensionInfo(message);
24
+ if (!info) throw new Error("invalid extension hello; reload the extension");
25
+ if (info.version !== EXPECTED_EXTENSION_VERSION) {
26
+ throw new Error(`extension version mismatch; expected ${EXPECTED_EXTENSION_VERSION}; reload the extension`);
27
+ }
28
+ const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
29
+ if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
30
+ return info;
31
+ }
32
+
33
+ export function parseBrowserSocketMessage(data) {
34
+ if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return { ok: false, code: 1009, reason: "message too large" };
35
+ let text;
36
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(data)); }
37
+ catch { return { ok: false, code: 1007, reason: "invalid UTF-8" }; }
38
+ let message;
39
+ try { message = JSON.parse(text); }
40
+ catch { return { ok: false, code: 1007, reason: "invalid JSON" }; }
41
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
42
+ return { ok: false, code: 1002, reason: "invalid protocol message" };
43
+ }
44
+ return { ok: true, message };
45
+ }
46
+
47
+ export function closeProtocolSocket(socket, code, reason) {
48
+ try { socket.close(code, reason); } catch {}
49
+ }
50
+
51
+ export function safeSocketSend(socket, value) {
52
+ if (!socket || socket.readyState !== 1) return false;
53
+ try {
54
+ socket.send(typeof value === "string" ? value : JSON.stringify(value));
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function normalizeExtensionInfo(value) {
62
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
63
+ const protocol = Number(value.protocol);
64
+ const version = typeof value.version === "string" && value.version.length <= 100 ? value.version : "";
65
+ const capabilities = Array.isArray(value.capabilities)
66
+ ? [...new Set(value.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
67
+ : [];
68
+ if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
69
+ return { protocol, version, capabilities };
70
+ }
71
+
72
+ function extensionVersion() {
73
+ const manifest = JSON.parse(readFileSync(resolve(packageRoot, "browser-extension", "manifest.json"), "utf8"));
74
+ return String(manifest.version_name || manifest.version || "");
75
+ }