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.
- package/CHANGELOG.md +39 -0
- package/README.md +18 -8
- package/SECURITY.md +10 -3
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +17 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +245 -60
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +151 -354
- package/docs/ARCHITECTURE.md +6 -4
- package/docs/AUDIT.md +24 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +13 -5
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +9 -2
- package/package.json +16 -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 -9
- package/src/local/browser-bridge.mjs +69 -143
- 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 +77 -0
- package/src/local/cli.mjs +16 -507
- 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 +8 -3
- 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 +11 -11
- 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) {
|
|
@@ -1,37 +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 = 2;
|
|
27
28
|
const EXTENSION_HANDSHAKE_MS = 3_000;
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
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
|
+
}
|