machine-bridge-mcp 0.15.0 → 0.16.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.
- package/CHANGELOG.md +27 -0
- package/README.md +12 -2
- package/SECURITY.md +7 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +5 -3
- package/docs/AUDIT.md +12 -1
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +9 -2
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +7 -0
- package/package.json +14 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -11
- package/src/local/browser-bridge.mjs +26 -156
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +89 -0
- package/src/local/cli.mjs +16 -521
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +1 -0
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +4 -4
- 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.
|
|
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.
|
|
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.
|
|
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) {
|
|
@@ -217,8 +215,6 @@ async function listWindowsApplications(configuredRoots, home, context, throwIfCa
|
|
|
217
215
|
const roots = configuredRoots || [
|
|
218
216
|
join(process.env.APPDATA || home, "Microsoft", "Windows", "Start Menu", "Programs"),
|
|
219
217
|
process.env.ProgramData ? join(process.env.ProgramData, "Microsoft", "Windows", "Start Menu", "Programs") : "",
|
|
220
|
-
process.env.ProgramFiles,
|
|
221
|
-
process.env["ProgramFiles(x86)"],
|
|
222
218
|
].filter(Boolean);
|
|
223
219
|
return listExecutableRoots(roots, new Set([".exe", ".lnk"]), context, throwIfCancelled);
|
|
224
220
|
}
|
|
@@ -1,40 +1,38 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|