machine-bridge-mcp 0.15.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +12 -2
  3. package/SECURITY.md +7 -0
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +5 -3
  6. package/docs/AUDIT.md +12 -1
  7. package/docs/LOGGING.md +7 -1
  8. package/docs/OPERATIONS.md +9 -2
  9. package/docs/POLICY_REFERENCE.md +88 -0
  10. package/docs/TESTING.md +7 -0
  11. package/package.json +14 -3
  12. package/scripts/coverage-check.mjs +108 -0
  13. package/scripts/generate-policy-reference.mjs +95 -0
  14. package/src/local/agent-context.mjs +1 -103
  15. package/src/local/app-automation.mjs +7 -9
  16. package/src/local/browser-bridge.mjs +26 -156
  17. package/src/local/browser-extension-protocol.mjs +75 -0
  18. package/src/local/browser-pairing-store.mjs +83 -0
  19. package/src/local/call-registry.mjs +113 -0
  20. package/src/local/capability-ranking.mjs +103 -0
  21. package/src/local/cli-local-admin.mjs +308 -0
  22. package/src/local/cli-options.mjs +151 -0
  23. package/src/local/cli-policy.mjs +77 -0
  24. package/src/local/cli.mjs +16 -521
  25. package/src/local/errors.mjs +122 -0
  26. package/src/local/git-service.mjs +88 -0
  27. package/src/local/lifecycle.mjs +64 -0
  28. package/src/local/log.mjs +50 -19
  29. package/src/local/managed-job-plan.mjs +235 -0
  30. package/src/local/managed-jobs.mjs +16 -220
  31. package/src/local/observability.mjs +83 -0
  32. package/src/local/policy.mjs +148 -0
  33. package/src/local/process-execution.mjs +153 -0
  34. package/src/local/process-sessions.mjs +10 -20
  35. package/src/local/process-tracker.mjs +55 -0
  36. package/src/local/runtime.mjs +154 -672
  37. package/src/local/service.mjs +1 -0
  38. package/src/local/stdio.mjs +3 -11
  39. package/src/local/tool-executor.mjs +102 -0
  40. package/src/local/tools.mjs +21 -104
  41. package/src/local/workspace-file-service.mjs +451 -0
  42. package/src/shared/policy-contract.json +54 -0
  43. package/src/shared/tool-catalog.json +4 -4
  44. 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,40 +1,38 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { existsSync, readFileSync } 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 = 3;
27
28
  const EXTENSION_HANDSHAKE_MS = 3_000;
28
- const EXTENSION_MANIFEST = JSON.parse(readFileSync(resolve(packageRoot, "browser-extension", "manifest.json"), "utf8"));
29
- const EXPECTED_EXTENSION_VERSION = String(EXTENSION_MANIFEST.version_name || EXTENSION_MANIFEST.version || "");
30
- if (!EXPECTED_EXTENSION_VERSION) throw new Error("packaged browser extension version is missing");
31
- const REQUIRED_EXTENSION_CAPABILITIES = Object.freeze([
32
- "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
33
- ]);
29
+
30
+
34
31
 
35
32
  export class BrowserBridgeManager {
36
- constructor({ policy, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
33
+ constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
37
34
  this.policy = policy || {};
35
+ this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
38
36
  this.stateRoot = stateRoot ? resolve(stateRoot) : "";
39
37
  this.runProcess = runProcess;
40
38
  this.readResourceText = readResourceText;
@@ -62,6 +60,7 @@ export class BrowserBridgeManager {
62
60
  }
63
61
 
64
62
  async status(context = {}) {
63
+ this.authorizeTool("browser_status");
65
64
  await this.ensureStarted(context);
66
65
  return {
67
66
  available: true,
@@ -102,7 +101,7 @@ export class BrowserBridgeManager {
102
101
  }
103
102
 
104
103
  async pair(args = {}, context = {}) {
105
- this.assertFull("pair_browser_extension");
104
+ this.authorizeTool("pair_browser_extension");
106
105
  const status = await this.status(context);
107
106
  if (args.open !== false) {
108
107
  const command = process.platform === "darwin"
@@ -125,6 +124,7 @@ export class BrowserBridgeManager {
125
124
  }
126
125
 
127
126
  async listTabs(args = {}, context = {}) {
127
+ this.authorizeTool("browser_list_tabs");
128
128
  const response = await this.request("list_tabs", {
129
129
  currentWindow: args.current_window === true,
130
130
  includePinned: args.include_pinned !== false,
@@ -133,16 +133,19 @@ export class BrowserBridgeManager {
133
133
  }
134
134
 
135
135
  async manageTabs(args = {}, context = {}) {
136
+ this.authorizeTool("browser_manage_tabs");
136
137
  return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
137
138
  }
138
139
 
139
140
  async wait(args = {}, context = {}) {
141
+ this.authorizeTool("browser_wait");
140
142
  const params = normalizeBrowserWait(args);
141
143
  const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
142
144
  return this.request("wait", params, conditionTimeoutSeconds + 5, context);
143
145
  }
144
146
 
145
147
  async getSource(args = {}, context = {}) {
148
+ this.authorizeTool("browser_get_source");
146
149
  const response = await this.request("get_source", {
147
150
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
148
151
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
@@ -153,6 +156,7 @@ export class BrowserBridgeManager {
153
156
  }
154
157
 
155
158
  async inspectPage(args = {}, context = {}) {
159
+ this.authorizeTool("browser_inspect_page");
156
160
  return this.request("inspect_page", {
157
161
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
158
162
  frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
@@ -163,7 +167,7 @@ export class BrowserBridgeManager {
163
167
  }
164
168
 
165
169
  async act(args = {}, context = {}) {
166
- this.assertFull("browser_action");
170
+ this.authorizeTool("browser_action");
167
171
  const action = normalizeBrowserAction(args.action);
168
172
  const rawUrl = optionalString(args.url, "url", 32768);
169
173
  const payload = {
@@ -200,7 +204,7 @@ export class BrowserBridgeManager {
200
204
  }
201
205
 
202
206
  async fillForm(args = {}, context = {}) {
203
- this.assertFull("browser_fill_form");
207
+ this.authorizeTool("browser_fill_form");
204
208
  if (!Array.isArray(args.fields) || !args.fields.length) throw new Error("fields must be a non-empty array");
205
209
  if (args.fields.length > MAX_FORM_FIELDS) throw new Error(`fields contains more than ${MAX_FORM_FIELDS} entries`);
206
210
  const fields = [];
@@ -242,7 +246,7 @@ export class BrowserBridgeManager {
242
246
  }
243
247
 
244
248
  async uploadFiles(args = {}, context = {}) {
245
- this.assertFull("browser_upload_files");
249
+ this.authorizeTool("browser_upload_files");
246
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");
247
251
  const filenames = optionalStringArray(args.filenames, "filenames", 8, 255);
248
252
  const mimeTypes = optionalStringArray(args.mime_types, "mime_types", 8, 200);
@@ -274,6 +278,7 @@ export class BrowserBridgeManager {
274
278
  }
275
279
 
276
280
  async screenshot(args = {}, context = {}) {
281
+ this.authorizeTool("browser_screenshot");
277
282
  const result = await this.request("screenshot", {
278
283
  tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
279
284
  format: args.format === "jpeg" ? "jpeg" : "png",
@@ -296,7 +301,6 @@ export class BrowserBridgeManager {
296
301
  }
297
302
 
298
303
  async request(method, params, timeoutSeconds, context = {}) {
299
- this.assertFull(method);
300
304
  await this.ensureStarted(context);
301
305
  this.throwIfCancelled(context);
302
306
  const transport = this.server ? this.socket : this.upstream;
@@ -778,140 +782,6 @@ export class BrowserBridgeManager {
778
782
  : this.proxyExtensionReloadRequired;
779
783
  }
780
784
 
781
- assertFull(tool) {
782
- if (this.policy.profile !== "full" || this.policy.execMode !== "shell" || this.policy.unrestrictedPaths !== true) {
783
- throw new Error(`${tool} requires the canonical full profile`);
784
- }
785
- }
786
- }
787
-
788
- function normalizeCompatibleExtensionInfo(value) {
789
- const info = normalizeExtensionInfo(value);
790
- if (!info || info.protocol !== BROWSER_EXTENSION_PROTOCOL || info.version !== EXPECTED_EXTENSION_VERSION) return null;
791
- if (REQUIRED_EXTENSION_CAPABILITIES.some((capability) => !info.capabilities.includes(capability))) return null;
792
- return info;
793
- }
794
-
795
- function parseExtensionHello(message) {
796
- if (message.role !== "extension" || message.protocol !== BROWSER_EXTENSION_PROTOCOL) {
797
- throw new Error(`extension protocol mismatch; expected ${BROWSER_EXTENSION_PROTOCOL}; reload the extension`);
798
- }
799
- const info = normalizeExtensionInfo(message);
800
- if (!info) throw new Error("invalid extension hello; reload the extension");
801
- if (info.version !== EXPECTED_EXTENSION_VERSION) throw new Error(`extension version mismatch; expected ${EXPECTED_EXTENSION_VERSION}; reload the extension`);
802
- const missing = REQUIRED_EXTENSION_CAPABILITIES.filter((capability) => !info.capabilities.includes(capability));
803
- if (missing.length) throw new Error(`extension capability mismatch; reload the extension (${missing.join(",")})`);
804
- return info;
805
- }
806
-
807
- function normalizeExtensionInfo(value) {
808
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
809
- const protocol = Number(value.protocol);
810
- const version = typeof value.version === "string" && value.version.length <= 100 ? value.version : "";
811
- const capabilities = Array.isArray(value.capabilities)
812
- ? [...new Set(value.capabilities.filter((entry) => typeof entry === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(entry)))].slice(0, 32)
813
- : [];
814
- if (!Number.isInteger(protocol) || protocol < 1 || !version) return null;
815
- return { protocol, version, capabilities };
816
- }
817
-
818
- async function loadOrCreatePairing(stateRoot) {
819
- if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
820
- assertStateMaintenanceAvailable(stateRoot);
821
- await mkdir(stateRoot, { recursive: true, mode: 0o700 });
822
- const file = join(stateRoot, PAIRING_FILE);
823
- for (let attempt = 0; attempt < 2; attempt += 1) {
824
- if (existsSync(file)) {
825
- ownerOnlyFile(file);
826
- let parsed;
827
- try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); } catch { throw new Error("browser pairing state is not valid bounded JSON"); }
828
- if (!/^[A-Za-z0-9_-]{32,100}$/.test(parsed.token) || !Number.isInteger(parsed.port) || parsed.port < 1024 || parsed.port > 65535) {
829
- throw new Error("browser pairing state is invalid");
830
- }
831
- return parsed;
832
- }
833
- const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_PORT };
834
- try {
835
- createExclusiveFileSync(file, `${JSON.stringify(value, null, 2)}
836
- `, { mode: 0o600 });
837
- ownerOnlyFile(file);
838
- return value;
839
- } catch (error) {
840
- if (error?.code !== "EEXIST") throw error;
841
- }
842
- }
843
- throw new Error("browser pairing state could not be initialized");
844
- }
845
-
846
- async function savePairing(stateRoot, value) {
847
- assertStateMaintenanceAvailable(stateRoot);
848
- const file = join(stateRoot, PAIRING_FILE);
849
- replaceFileAtomicallySync(file, `${JSON.stringify(value, null, 2)}
850
- `, { mode: 0o600 });
851
- ownerOnlyFile(file);
852
- }
853
-
854
- function pairingHtml(port, token) {
855
- 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>Expected extension build: <strong>${EXPECTED_EXTENSION_VERSION}</strong>. Reload the unpacked extension after every Machine Bridge upgrade.</p><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>`;
856
- }
857
-
858
- function isAllowedExtensionOrigin(origin) {
859
- let parsed;
860
- try { parsed = new URL(String(origin || "")); } catch { return false; }
861
- return parsed.protocol === "chrome-extension:"
862
- && /^[a-p]{32}$/.test(parsed.hostname)
863
- && !parsed.username
864
- && !parsed.password
865
- && !parsed.port
866
- && (parsed.pathname === "" || parsed.pathname === "/")
867
- && !parsed.search
868
- && !parsed.hash;
869
- }
870
-
871
- function isAllowedLoopbackHost(host, port) {
872
- const normalized = String(host || "").toLowerCase();
873
- return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}` || normalized === `[::1]:${port}`;
874
- }
875
-
876
- function securityHeaders(contentType) {
877
- return {
878
- "content-type": contentType,
879
- "cache-control": "no-store",
880
- "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
881
- "x-content-type-options": "nosniff",
882
- "referrer-policy": "no-referrer",
883
- };
884
- }
885
-
886
- function parseBrowserSocketMessage(data) {
887
- if (Buffer.byteLength(data) > MAX_BROWSER_MESSAGE_BYTES) return { ok: false, code: 1009, reason: "message too large" };
888
- let text;
889
- try { text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(data)); }
890
- catch { return { ok: false, code: 1007, reason: "invalid UTF-8" }; }
891
- let message;
892
- try { message = JSON.parse(text); }
893
- catch { return { ok: false, code: 1007, reason: "invalid JSON" }; }
894
- if (!message || typeof message !== "object" || Array.isArray(message)) return { ok: false, code: 1002, reason: "invalid protocol message" };
895
- return { ok: true, message };
896
- }
897
-
898
- function closeProtocolSocket(socket, code, reason) {
899
- try { socket.close(code, reason); } catch {}
900
- }
901
-
902
- function safeSocketSend(socket, value) {
903
- if (!socket || socket.readyState !== 1) return false;
904
- try {
905
- socket.send(typeof value === "string" ? value : JSON.stringify(value));
906
- return true;
907
- } catch {
908
- return false;
909
- }
910
- }
911
-
912
- function sendJson(response, value) {
913
- response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
914
- response.end(`${JSON.stringify(value)}\n`);
915
785
  }
916
786
 
917
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
+ }
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
6
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
7
+ import { assertStateMaintenanceAvailable, ownerOnlyFile } from "./state.mjs";
8
+ import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
9
+
10
+ export const DEFAULT_BROWSER_PORT = 39393;
11
+ const PAIRING_FILE = "browser-bridge.json";
12
+
13
+ export async function loadOrCreatePairing(stateRoot) {
14
+ if (!stateRoot) return { token: randomBytes(32).toString("base64url"), port: DEFAULT_BROWSER_PORT };
15
+ assertStateMaintenanceAvailable(stateRoot);
16
+ await mkdir(stateRoot, { recursive: true, mode: 0o700 });
17
+ const file = join(stateRoot, PAIRING_FILE);
18
+ for (let attempt = 0; attempt < 2; attempt += 1) {
19
+ if (existsSync(file)) {
20
+ ownerOnlyFile(file);
21
+ let parsed;
22
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, 64 * 1024).toString("utf8")); }
23
+ catch { throw new Error("browser pairing state is not valid bounded JSON"); }
24
+ if (!/^[A-Za-z0-9_-]{32,100}$/.test(parsed.token) || !Number.isInteger(parsed.port) || parsed.port < 1024 || parsed.port > 65535) {
25
+ throw new Error("browser pairing state is invalid");
26
+ }
27
+ return parsed;
28
+ }
29
+ const value = { token: randomBytes(32).toString("base64url"), port: DEFAULT_BROWSER_PORT };
30
+ try {
31
+ createExclusiveFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
32
+ ownerOnlyFile(file);
33
+ return value;
34
+ } catch (error) {
35
+ if (error?.code !== "EEXIST") throw error;
36
+ }
37
+ }
38
+ throw new Error("browser pairing state could not be initialized");
39
+ }
40
+
41
+ export async function savePairing(stateRoot, value) {
42
+ assertStateMaintenanceAvailable(stateRoot);
43
+ const file = join(stateRoot, PAIRING_FILE);
44
+ replaceFileAtomicallySync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
45
+ ownerOnlyFile(file);
46
+ }
47
+
48
+ export function pairingHtml(port, token) {
49
+ 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>Expected extension build: <strong>${EXPECTED_EXTENSION_VERSION}</strong>. Reload the unpacked extension after every Machine Bridge upgrade.</p><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>`;
50
+ }
51
+
52
+ export function isAllowedExtensionOrigin(origin) {
53
+ let parsed;
54
+ try { parsed = new URL(String(origin || "")); } catch { return false; }
55
+ return parsed.protocol === "chrome-extension:"
56
+ && /^[a-p]{32}$/.test(parsed.hostname)
57
+ && !parsed.username
58
+ && !parsed.password
59
+ && !parsed.port
60
+ && (parsed.pathname === "" || parsed.pathname === "/")
61
+ && !parsed.search
62
+ && !parsed.hash;
63
+ }
64
+
65
+ export function isAllowedLoopbackHost(host, port) {
66
+ const normalized = String(host || "").toLowerCase();
67
+ return normalized === `127.0.0.1:${port}` || normalized === `localhost:${port}` || normalized === `[::1]:${port}`;
68
+ }
69
+
70
+ export function securityHeaders(contentType) {
71
+ return {
72
+ "content-type": contentType,
73
+ "cache-control": "no-store",
74
+ "content-security-policy": "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
75
+ "x-content-type-options": "nosniff",
76
+ "referrer-policy": "no-referrer",
77
+ };
78
+ }
79
+
80
+ export function sendJson(response, value) {
81
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
82
+ response.end(`${JSON.stringify(value)}\n`);
83
+ }