machine-bridge-mcp 1.1.5 → 1.2.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 +21 -0
- package/CODE_OF_CONDUCT.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/GOVERNANCE.md +50 -0
- package/README.md +26 -0
- package/SUPPORT.md +31 -0
- package/browser-extension/browser-operations.js +10 -4
- package/browser-extension/devtools-input.js +2 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +1 -1
- package/docs/ARCHITECTURE.md +14 -17
- package/docs/AUDIT.md +28 -0
- package/docs/ENGINEERING.md +8 -2
- package/docs/PROJECT_STANDARDS.md +4 -2
- package/docs/TESTING.md +9 -3
- package/docs/UPGRADING.md +32 -0
- package/package.json +12 -4
- package/scripts/coverage-check.mjs +27 -10
- package/src/local/account-access.mjs +7 -5
- package/src/local/agent-context.mjs +7 -148
- package/src/local/agent-contract.mjs +206 -0
- package/src/local/browser-bridge.mjs +30 -281
- package/src/local/browser-extension-protocol.mjs +19 -4
- package/src/local/browser-operation-service.mjs +325 -0
- package/src/local/call-registry.mjs +44 -1
- package/src/local/capability-ranking.mjs +13 -0
- package/src/local/cli-local-admin.mjs +74 -38
- package/src/local/cli-options.mjs +18 -18
- package/src/local/cli-policy.mjs +1 -1
- package/src/local/cli-service.mjs +132 -0
- package/src/local/cli.mjs +27 -109
- package/src/local/daemon-process.mjs +1 -1
- package/src/local/full-access-test.mjs +1 -1
- package/src/local/job-runner.mjs +9 -3
- package/src/local/managed-job-plan.mjs +3 -3
- package/src/local/monotonic-deadline.mjs +7 -0
- package/src/local/numbers.mjs +8 -0
- package/src/local/policy.mjs +97 -16
- package/src/local/project-metadata.mjs +7 -1
- package/src/local/project-package.mjs +1 -1
- package/src/local/records.mjs +6 -0
- package/src/local/resource-operations.mjs +1 -1
- package/src/local/runtime-capabilities.mjs +66 -0
- package/src/local/runtime-diagnostics.mjs +91 -0
- package/src/local/runtime-reporting.mjs +113 -0
- package/src/local/runtime.mjs +54 -191
- package/src/local/service-convergence.mjs +1 -1
- package/src/local/service.mjs +1 -1
- package/src/local/state.mjs +1 -1
- package/src/local/windows-service.mjs +1 -1
- package/src/local/worker-deployment.mjs +15 -10
- package/src/local/worker-health.mjs +8 -4
- package/src/worker/access.ts +8 -7
- package/src/worker/account-admin.ts +2 -2
- package/src/worker/authority.ts +3 -3
- package/src/worker/http.ts +2 -2
- package/src/worker/index.ts +29 -337
- package/src/worker/oauth-controller.ts +343 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/oauth-tokens.ts +2 -2
- package/src/worker/tool-catalog.ts +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.local.json +27 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampInt, normalizeBrowserAction, normalizeBrowserSelector, normalizeBrowserWait, normalizeFormAction,
|
|
3
|
+
normalizeInputMode, normalizeNavigationWait, normalizeTabCommand, optionalInteger, optionalString,
|
|
4
|
+
validateNavigationUrl,
|
|
5
|
+
} from "./browser-command.mjs";
|
|
6
|
+
|
|
7
|
+
const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const MAX_FORM_FIELDS = 200;
|
|
9
|
+
const MAX_FIELD_VALUE_CHARS = 128 * 1024;
|
|
10
|
+
const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
12
|
+
|
|
13
|
+
export class BrowserOperationService {
|
|
14
|
+
constructor({
|
|
15
|
+
authorizeTool,
|
|
16
|
+
ensureStarted,
|
|
17
|
+
request,
|
|
18
|
+
bridgeStatus,
|
|
19
|
+
extensionPath,
|
|
20
|
+
expectedExtensionVersion,
|
|
21
|
+
runProcess,
|
|
22
|
+
readResourceText,
|
|
23
|
+
readResourceBinary,
|
|
24
|
+
}) {
|
|
25
|
+
this.authorizeTool = authorizeTool;
|
|
26
|
+
this.ensureStarted = ensureStarted;
|
|
27
|
+
this.request = request;
|
|
28
|
+
this.bridgeStatus = bridgeStatus;
|
|
29
|
+
this.extensionPath = extensionPath;
|
|
30
|
+
this.expectedExtensionVersion = expectedExtensionVersion;
|
|
31
|
+
this.runProcess = runProcess;
|
|
32
|
+
this.readResourceText = readResourceText;
|
|
33
|
+
this.readResourceBinary = readResourceBinary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async status(context = {}) {
|
|
37
|
+
this.authorizeTool("browser_status");
|
|
38
|
+
await this.ensureStarted(context);
|
|
39
|
+
const bridge = this.bridgeStatus();
|
|
40
|
+
const extension = bridge.extensionInfo;
|
|
41
|
+
return {
|
|
42
|
+
available: true,
|
|
43
|
+
connected: bridge.extensionConnected,
|
|
44
|
+
broker_role: bridge.brokerRole,
|
|
45
|
+
endpoint: `ws://127.0.0.1:${bridge.port}/extension`,
|
|
46
|
+
pairing_url: `http://127.0.0.1:${bridge.port}/pair`,
|
|
47
|
+
extension_path: this.extensionPath,
|
|
48
|
+
expected_extension_version: this.expectedExtensionVersion,
|
|
49
|
+
extension_protocol: extension?.protocol || null,
|
|
50
|
+
extension_version: extension?.version || "",
|
|
51
|
+
extension_capabilities: extension?.capabilities || [],
|
|
52
|
+
extension_reload_required: bridge.extensionReloadRequired,
|
|
53
|
+
supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
|
|
54
|
+
controls_existing_profile: true,
|
|
55
|
+
controls_extension_profile: true,
|
|
56
|
+
launches_browser_process: false,
|
|
57
|
+
launches_separate_automation_profile: false,
|
|
58
|
+
profile_identity_verifiable: false,
|
|
59
|
+
uses_existing_tabs_and_login_state: true,
|
|
60
|
+
source_access: true,
|
|
61
|
+
semantic_snapshot_refs: true,
|
|
62
|
+
actionability_waits: true,
|
|
63
|
+
trusted_input: true,
|
|
64
|
+
input_modes: ["auto", "trusted", "dom"],
|
|
65
|
+
complex_form_fill: true,
|
|
66
|
+
tab_management: true,
|
|
67
|
+
explicit_waits: true,
|
|
68
|
+
screenshots: true,
|
|
69
|
+
restricted_pages: ["browser-internal pages", "extension stores", "some PDF/plugin viewers", "pages blocked by enterprise policy"],
|
|
70
|
+
security: {
|
|
71
|
+
loopback_only: true,
|
|
72
|
+
bearer_pairing_token: true,
|
|
73
|
+
arbitrary_extension_code_from_mcp: false,
|
|
74
|
+
resource_values_returned_to_model: false,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async pair(args = {}, context = {}) {
|
|
80
|
+
this.authorizeTool("pair_browser_extension");
|
|
81
|
+
const status = await this.status(context);
|
|
82
|
+
if (args.open !== false) {
|
|
83
|
+
const command = process.platform === "darwin"
|
|
84
|
+
? { cmd: "open", argv: [status.pairing_url] }
|
|
85
|
+
: process.platform === "win32"
|
|
86
|
+
? { cmd: "cmd.exe", argv: ["/d", "/s", "/c", "start", "", status.pairing_url] }
|
|
87
|
+
: { cmd: "xdg-open", argv: [status.pairing_url] };
|
|
88
|
+
await this.runProcess(command.cmd, command.argv, 30_000, false, 128 * 1024, context);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...status,
|
|
92
|
+
opened_pairing_page: args.open !== false,
|
|
93
|
+
setup_steps: [
|
|
94
|
+
"Open the browser extensions page and enable developer mode.",
|
|
95
|
+
"Load the unpacked extension from extension_path once.",
|
|
96
|
+
"Open pairing_url; the installed extension stores the loopback endpoint and token locally.",
|
|
97
|
+
"After upgrades, reload the unpacked extension and accept any newly requested browser permission.",
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async listTabs(args = {}, context = {}) {
|
|
103
|
+
this.authorizeTool("browser_list_tabs");
|
|
104
|
+
return this.request("list_tabs", {
|
|
105
|
+
currentWindow: args.current_window === true,
|
|
106
|
+
includePinned: args.include_pinned !== false,
|
|
107
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async manageTabs(args = {}, context = {}) {
|
|
111
|
+
this.authorizeTool("browser_manage_tabs");
|
|
112
|
+
return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async wait(args = {}, context = {}) {
|
|
116
|
+
this.authorizeTool("browser_wait");
|
|
117
|
+
const params = normalizeBrowserWait(args);
|
|
118
|
+
const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
|
|
119
|
+
return this.request("wait", params, conditionTimeoutSeconds + 5, context);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async getSource(args = {}, context = {}) {
|
|
123
|
+
this.authorizeTool("browser_get_source");
|
|
124
|
+
return this.request("get_source", {
|
|
125
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
126
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
127
|
+
allFrames: args.all_frames === true,
|
|
128
|
+
maxBytes: clampInt(args.max_bytes, 1024 * 1024, 1, MAX_SOURCE_BYTES),
|
|
129
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async inspectPage(args = {}, context = {}) {
|
|
133
|
+
this.authorizeTool("browser_inspect_page");
|
|
134
|
+
return this.request("inspect_page", {
|
|
135
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
136
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
137
|
+
allFrames: args.all_frames !== false,
|
|
138
|
+
maxElements: clampInt(args.max_elements, 300, 1, 1000),
|
|
139
|
+
includeValues: args.include_values === true,
|
|
140
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async act(args = {}, context = {}) {
|
|
144
|
+
this.authorizeTool("browser_action");
|
|
145
|
+
const action = normalizeBrowserAction(args.action);
|
|
146
|
+
const rawUrl = optionalString(args.url, "url", 32768);
|
|
147
|
+
const payload = {
|
|
148
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
149
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
150
|
+
action,
|
|
151
|
+
selector: normalizeBrowserSelector(args.selector, action),
|
|
152
|
+
url: action === "navigate" ? validateNavigationUrl(rawUrl) : "",
|
|
153
|
+
value: null,
|
|
154
|
+
key: optionalString(args.key, "key", 100),
|
|
155
|
+
waitFor: normalizeNavigationWait(args.wait_for),
|
|
156
|
+
inputMode: normalizeInputMode(args.input_mode),
|
|
157
|
+
elementTimeoutMs: clampInt(args.element_timeout_seconds, 10, 1, 60) * 1000,
|
|
158
|
+
};
|
|
159
|
+
if (action !== "navigate" && rawUrl) throw new Error("url is only valid for navigate");
|
|
160
|
+
if (action !== "press" && payload.key) throw new Error("key is only valid for press");
|
|
161
|
+
if (payload.inputMode === "trusted" && !["click", "double_click", "hover", "press", "type_text"].includes(action)) {
|
|
162
|
+
throw new Error("input_mode=trusted supports click, double_click, hover, press, and type_text only");
|
|
163
|
+
}
|
|
164
|
+
if (args.value !== undefined) payload.value = boundedValue(args.value, "value");
|
|
165
|
+
if (args.value_resource !== undefined) {
|
|
166
|
+
if (payload.value !== null) throw new Error("value and value_resource are mutually exclusive");
|
|
167
|
+
payload.value = boundedValue(await this.readResourceText(validateResource(args.value_resource)), "value_resource");
|
|
168
|
+
}
|
|
169
|
+
if (payload.value !== null && !["fill", "select", "press", "type_text"].includes(action)) {
|
|
170
|
+
throw new Error(`value is not valid for browser action '${action}'`);
|
|
171
|
+
}
|
|
172
|
+
const pageAction = !["navigate", "reload", "back", "forward"].includes(action);
|
|
173
|
+
const defaultTimeout = pageAction ? Math.min(120, Math.max(30, payload.elementTimeoutMs / 1000 + 5)) : 30;
|
|
174
|
+
const response = await this.request("action", payload, clampInt(args.timeout_seconds, defaultTimeout, 1, 120), context);
|
|
175
|
+
return {
|
|
176
|
+
...response,
|
|
177
|
+
value_source: args.value_resource !== undefined ? "local-resource" : payload.value === null ? "none" : "mcp-argument",
|
|
178
|
+
value_exposed: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async fillForm(args = {}, context = {}) {
|
|
183
|
+
this.authorizeTool("browser_fill_form");
|
|
184
|
+
if (!Array.isArray(args.fields) || !args.fields.length) throw new Error("fields must be a non-empty array");
|
|
185
|
+
if (args.fields.length > MAX_FORM_FIELDS) throw new Error(`fields contains more than ${MAX_FORM_FIELDS} entries`);
|
|
186
|
+
const fields = [];
|
|
187
|
+
let totalValueBytes = 0;
|
|
188
|
+
for (let index = 0; index < args.fields.length; index += 1) {
|
|
189
|
+
const input = args.fields[index];
|
|
190
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`fields[${index}] must be an object`);
|
|
191
|
+
const allowed = new Set(["selector", "value", "value_resource", "action", "sensitive"]);
|
|
192
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`unknown fields[${index}] property: ${key}`);
|
|
193
|
+
let value = input.value === undefined ? null : boundedValue(input.value, `fields[${index}].value`);
|
|
194
|
+
if (input.value_resource !== undefined) {
|
|
195
|
+
if (value !== null) throw new Error(`fields[${index}] value and value_resource are mutually exclusive`);
|
|
196
|
+
value = boundedValue(await this.readResourceText(validateResource(input.value_resource)), `fields[${index}].value_resource`);
|
|
197
|
+
}
|
|
198
|
+
if (value !== null) {
|
|
199
|
+
totalValueBytes += Buffer.byteLength(value);
|
|
200
|
+
if (totalValueBytes > MAX_FORM_VALUE_BYTES) throw new Error("form field values exceed 4 MiB total");
|
|
201
|
+
}
|
|
202
|
+
const action = input.action === undefined ? "fill" : normalizeFormAction(input.action);
|
|
203
|
+
if (value === null && !["check", "uncheck", "click"].includes(action)) throw new Error(`fields[${index}] requires value or value_resource`);
|
|
204
|
+
if (value !== null && !["fill", "select"].includes(action)) throw new Error(`fields[${index}] value is not valid for action '${action}'`);
|
|
205
|
+
fields.push({
|
|
206
|
+
selector: normalizeBrowserSelector(input.selector, action),
|
|
207
|
+
value,
|
|
208
|
+
action,
|
|
209
|
+
sensitive: input.sensitive === true || input.value_resource !== undefined,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
|
|
213
|
+
return this.request("fill_form", {
|
|
214
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
215
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
216
|
+
fields,
|
|
217
|
+
submit: args.submit === true,
|
|
218
|
+
submitSelector: args.submit_selector ? normalizeBrowserSelector(args.submit_selector, "click") : null,
|
|
219
|
+
waitFor: normalizeNavigationWait(args.wait_for),
|
|
220
|
+
elementTimeoutMs: elementTimeoutSeconds * 1000,
|
|
221
|
+
}, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async uploadFiles(args = {}, context = {}) {
|
|
225
|
+
this.authorizeTool("browser_upload_files");
|
|
226
|
+
if (!Array.isArray(args.resources) || !args.resources.length || args.resources.length > 8) {
|
|
227
|
+
throw new Error("resources must contain 1 to 8 registered resource names");
|
|
228
|
+
}
|
|
229
|
+
const filenames = optionalStringArray(args.filenames, "filenames", 8, 255);
|
|
230
|
+
const mimeTypes = optionalStringArray(args.mime_types, "mime_types", 8, 200);
|
|
231
|
+
if (filenames.length > args.resources.length || mimeTypes.length > args.resources.length) {
|
|
232
|
+
throw new Error("filenames and mime_types cannot contain more entries than resources");
|
|
233
|
+
}
|
|
234
|
+
const files = [];
|
|
235
|
+
let total = 0;
|
|
236
|
+
for (const raw of args.resources) {
|
|
237
|
+
const name = validateResource(raw);
|
|
238
|
+
const resource = this.readResourceBinary(name);
|
|
239
|
+
total += resource.buffer.length;
|
|
240
|
+
if (total > 5 * 1024 * 1024) throw new Error("browser upload resources exceed 5 MiB total");
|
|
241
|
+
const suppliedFilename = filenames[files.length];
|
|
242
|
+
const derivedFilename = resource.path.split(/[\\/]/).pop() || name;
|
|
243
|
+
files.push({
|
|
244
|
+
filename: normalizeUploadFilename(suppliedFilename || derivedFilename, { derived: !suppliedFilename }),
|
|
245
|
+
mime: normalizeMimeType(mimeTypes[files.length] || "application/octet-stream"),
|
|
246
|
+
data: resource.buffer.toString("base64"),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
|
|
250
|
+
const result = await this.request("upload_files", {
|
|
251
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
252
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
253
|
+
selector: normalizeBrowserSelector(args.selector, "fill"),
|
|
254
|
+
files,
|
|
255
|
+
elementTimeoutMs: elementTimeoutSeconds * 1000,
|
|
256
|
+
}, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
|
|
257
|
+
return { ...result, resource_names: args.resources.map(String), resource_contents_exposed: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async screenshot(args = {}, context = {}) {
|
|
261
|
+
this.authorizeTool("browser_screenshot");
|
|
262
|
+
const result = await this.request("screenshot", {
|
|
263
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
264
|
+
format: args.format === "jpeg" ? "jpeg" : "png",
|
|
265
|
+
quality: clampInt(args.quality, 90, 1, 100),
|
|
266
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
267
|
+
const data = String(result.data || "");
|
|
268
|
+
const match = /^data:(image\/(?:png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(data);
|
|
269
|
+
if (!match) throw new Error("browser extension returned an invalid screenshot");
|
|
270
|
+
return {
|
|
271
|
+
$mcp: {
|
|
272
|
+
content: [{ type: "image", data: match[2], mimeType: match[1] }],
|
|
273
|
+
structuredContent: {
|
|
274
|
+
tab_id: result.tab_id,
|
|
275
|
+
url: result.url,
|
|
276
|
+
title: result.title,
|
|
277
|
+
mime_type: match[1],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeUploadFilename(value, { derived = false } = {}) {
|
|
285
|
+
let name = String(value || "");
|
|
286
|
+
if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
|
|
287
|
+
if (!name || name === "." || name === ".." || name.length > 255 || /[\u0000-\u001f\u007f/\\]/.test(name)) {
|
|
288
|
+
if (derived) return "upload.bin";
|
|
289
|
+
throw new Error("filenames entries must be safe single-component filenames of at most 255 characters");
|
|
290
|
+
}
|
|
291
|
+
return name;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeMimeType(value) {
|
|
295
|
+
const mime = String(value || "").trim().toLowerCase();
|
|
296
|
+
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 200) {
|
|
297
|
+
throw new Error("mime_types entries must be valid media types");
|
|
298
|
+
}
|
|
299
|
+
return mime;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function optionalStringArray(value, label, maxItems, maxLength) {
|
|
303
|
+
if (value === undefined || value === null) return [];
|
|
304
|
+
if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${label} must be an array with at most ${maxItems} entries`);
|
|
305
|
+
return value.map((item, index) => {
|
|
306
|
+
if (typeof item !== "string" || item.includes("\0") || !item.length || item.length > maxLength) {
|
|
307
|
+
throw new Error(`${label}[${index}] must be a non-empty string of at most ${maxLength} characters`);
|
|
308
|
+
}
|
|
309
|
+
return item;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function boundedValue(value, label) {
|
|
314
|
+
const string = String(value);
|
|
315
|
+
if (string.includes("\0") || string.length > MAX_FIELD_VALUE_CHARS) {
|
|
316
|
+
throw new Error(`${label} exceeds the maximum length or contains a NUL byte`);
|
|
317
|
+
}
|
|
318
|
+
return string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateResource(value) {
|
|
322
|
+
const name = String(value || "").trim();
|
|
323
|
+
if (!RESOURCE_NAME.test(name)) throw new Error("value_resource is invalid");
|
|
324
|
+
return name;
|
|
325
|
+
}
|
|
@@ -1,24 +1,59 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
import { randomBytes } from "node:crypto";
|
|
2
4
|
import { performance } from "node:perf_hooks";
|
|
3
5
|
import { BridgeError } from "./errors.mjs";
|
|
4
6
|
|
|
7
|
+
/** @typedef {ReturnType<typeof setTimeout>} TimerHandle */
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* id: string,
|
|
11
|
+
* tool: string,
|
|
12
|
+
* origin: string,
|
|
13
|
+
* startedAt: number,
|
|
14
|
+
* deadlineAt: number | null,
|
|
15
|
+
* controller: AbortController,
|
|
16
|
+
* cancelReason: string,
|
|
17
|
+
* timer: TimerHandle | null,
|
|
18
|
+
* }} CallRecord
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* maximum?: unknown,
|
|
23
|
+
* now?: () => number,
|
|
24
|
+
* scheduler?: {setTimeout: typeof setTimeout, clearTimeout: typeof clearTimeout},
|
|
25
|
+
* onCancel?: (record: CallRecord) => void,
|
|
26
|
+
* onFinish?: (record: CallRecord) => void,
|
|
27
|
+
* }} CallRegistryOptions
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{callId?: unknown, tool?: unknown, origin?: unknown, timeoutMs?: unknown}} OpenCallInput
|
|
31
|
+
*/
|
|
32
|
+
/** @typedef {{signal?: AbortSignal, callId?: unknown}} CancellationContext */
|
|
33
|
+
|
|
5
34
|
export class CallRegistry {
|
|
35
|
+
/** @param {CallRegistryOptions} [options] */
|
|
6
36
|
constructor(options = {}) {
|
|
7
37
|
this.maximum = positiveInteger(options.maximum, 16);
|
|
8
38
|
this.now = typeof options.now === "function" ? options.now : () => performance.now();
|
|
9
39
|
this.scheduler = options.scheduler || { setTimeout, clearTimeout };
|
|
10
40
|
this.onCancel = typeof options.onCancel === "function" ? options.onCancel : () => {};
|
|
11
41
|
this.onFinish = typeof options.onFinish === "function" ? options.onFinish : () => {};
|
|
42
|
+
/** @type {Map<string, CallRecord>} */
|
|
12
43
|
this.calls = new Map();
|
|
13
44
|
}
|
|
14
45
|
|
|
46
|
+
/** @param {OpenCallInput} [input] */
|
|
15
47
|
open({ callId = "", tool = "", origin = "local", timeoutMs = 0 } = {}) {
|
|
16
48
|
const id = String(callId || `call_${randomBytes(16).toString("hex")}`);
|
|
17
49
|
if (this.calls.has(id)) throw new BridgeError("conflict", "duplicate in-flight call id");
|
|
18
|
-
if (this.calls.size >= this.maximum)
|
|
50
|
+
if (this.calls.size >= this.maximum) {
|
|
51
|
+
throw new BridgeError("limit_exceeded", `too many concurrent tool calls (${this.maximum})`, { retryable: true });
|
|
52
|
+
}
|
|
19
53
|
const controller = new AbortController();
|
|
20
54
|
const startedAt = this.now();
|
|
21
55
|
const timeout = positiveInteger(timeoutMs, 0);
|
|
56
|
+
/** @type {CallRecord} */
|
|
22
57
|
const record = {
|
|
23
58
|
id,
|
|
24
59
|
tool: String(tool || ""),
|
|
@@ -44,6 +79,7 @@ export class CallRegistry {
|
|
|
44
79
|
});
|
|
45
80
|
}
|
|
46
81
|
|
|
82
|
+
/** @param {unknown} callId @param {unknown} [reason] @param {string} [code] */
|
|
47
83
|
cancel(callId, reason = "cancelled", code = "cancelled") {
|
|
48
84
|
const record = this.calls.get(String(callId || ""));
|
|
49
85
|
if (!record || record.controller.signal.aborted) return false;
|
|
@@ -53,6 +89,7 @@ export class CallRegistry {
|
|
|
53
89
|
return true;
|
|
54
90
|
}
|
|
55
91
|
|
|
92
|
+
/** @param {unknown} callId */
|
|
56
93
|
finish(callId) {
|
|
57
94
|
const id = String(callId || "");
|
|
58
95
|
const record = this.calls.get(id);
|
|
@@ -63,6 +100,7 @@ export class CallRegistry {
|
|
|
63
100
|
return true;
|
|
64
101
|
}
|
|
65
102
|
|
|
103
|
+
/** @param {unknown} origin @param {unknown} [reason] */
|
|
66
104
|
cancelOrigin(origin, reason = "transport disconnected") {
|
|
67
105
|
const expected = String(origin || "");
|
|
68
106
|
let cancelled = 0;
|
|
@@ -73,6 +111,7 @@ export class CallRegistry {
|
|
|
73
111
|
return cancelled;
|
|
74
112
|
}
|
|
75
113
|
|
|
114
|
+
/** @param {unknown} [reason] */
|
|
76
115
|
cancelAll(reason = "runtime stopped") {
|
|
77
116
|
for (const id of [...this.calls.keys()]) {
|
|
78
117
|
this.cancel(id, reason);
|
|
@@ -80,6 +119,7 @@ export class CallRegistry {
|
|
|
80
119
|
}
|
|
81
120
|
}
|
|
82
121
|
|
|
122
|
+
/** @param {unknown} callId */
|
|
83
123
|
context(callId) {
|
|
84
124
|
const record = this.calls.get(String(callId || ""));
|
|
85
125
|
if (!record) return null;
|
|
@@ -93,6 +133,7 @@ export class CallRegistry {
|
|
|
93
133
|
};
|
|
94
134
|
}
|
|
95
135
|
|
|
136
|
+
/** @param {CancellationContext} [context] */
|
|
96
137
|
throwIfCancelled(context = {}) {
|
|
97
138
|
const signal = context.signal || this.calls.get(String(context.callId || ""))?.controller.signal;
|
|
98
139
|
if (!signal?.aborted) return;
|
|
@@ -103,6 +144,7 @@ export class CallRegistry {
|
|
|
103
144
|
|
|
104
145
|
snapshot() {
|
|
105
146
|
const now = this.now();
|
|
147
|
+
/** @type {Record<string, number>} */
|
|
106
148
|
const byOrigin = {};
|
|
107
149
|
let oldestMs = 0;
|
|
108
150
|
for (const call of this.calls.values()) {
|
|
@@ -118,6 +160,7 @@ export class CallRegistry {
|
|
|
118
160
|
}
|
|
119
161
|
}
|
|
120
162
|
|
|
163
|
+
/** @param {unknown} value @param {number} fallback */
|
|
121
164
|
function positiveInteger(value, fallback) {
|
|
122
165
|
const number = Number(value);
|
|
123
166
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
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", "及", "和", "的", "了", "在", "用", "使用", "进行", "根据"]);
|
|
2
3
|
const ENGLISH_TOKEN_CANONICAL = new Map([
|
|
3
4
|
["created", "create"], ["creating", "create"], ["creation", "create"], ["creator", "create"],
|
|
@@ -9,6 +10,7 @@ const ENGLISH_TOKEN_CANONICAL = new Map([
|
|
|
9
10
|
["factcheck", "verify"], ["fact-check", "verify"], ["testing", "test"], ["tests", "test"],
|
|
10
11
|
["building", "build"], ["built", "build"], ["deployment", "deploy"], ["deployed", "deploy"],
|
|
11
12
|
]);
|
|
13
|
+
/** @type {ReadonlyArray<readonly [RegExp, readonly string[]]>} */
|
|
12
14
|
const HAN_TOKEN_ALIASES = Object.freeze([
|
|
13
15
|
[/创建|新建|编写/u, ["create", "creator"]],
|
|
14
16
|
[/改进|优化|更新|维护/u, ["improve", "update"]],
|
|
@@ -29,10 +31,14 @@ const HAN_TOKEN_ALIASES = Object.freeze([
|
|
|
29
31
|
[/安全|漏洞/u, ["security", "audit"]],
|
|
30
32
|
]);
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* @param {{name: string, description: string, argv: string[], searchTerms?: string}} command
|
|
36
|
+
*/
|
|
32
37
|
export function commandMatchText(command) {
|
|
33
38
|
return `${command.name} ${command.description} ${command.argv.join(" ")} ${command.searchTerms || ""}`;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/** @param {unknown} task @param {unknown} candidate @param {unknown} [identity] */
|
|
36
42
|
export function relevanceScore(task, candidate, identity = "") {
|
|
37
43
|
const taskTokens = tokenize(task);
|
|
38
44
|
const candidateTokens = tokenize(candidate);
|
|
@@ -51,6 +57,10 @@ export function relevanceScore(task, candidate, identity = "") {
|
|
|
51
57
|
return score;
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* @param {unknown} task
|
|
62
|
+
* @param {{commandsAvailable: boolean, commandRelevant: boolean, skillRelevant: boolean}} options
|
|
63
|
+
*/
|
|
54
64
|
export function recommendTools(task, { commandsAvailable, commandRelevant, skillRelevant }) {
|
|
55
65
|
const lower = String(task || "").toLowerCase();
|
|
56
66
|
const tools = ["agent_context"];
|
|
@@ -64,6 +74,7 @@ export function recommendTools(task, { commandsAvailable, commandRelevant, skill
|
|
|
64
74
|
return [...new Set(tools)];
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
/** @param {unknown} value @returns {Set<string>} */
|
|
67
78
|
export function tokenize(value) {
|
|
68
79
|
const text = String(value || "").toLowerCase();
|
|
69
80
|
const tokens = new Set();
|
|
@@ -86,6 +97,7 @@ export function tokenize(value) {
|
|
|
86
97
|
return tokens;
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/** @param {unknown} value */
|
|
89
101
|
function comparableText(value) {
|
|
90
102
|
return String(value || "")
|
|
91
103
|
.toLowerCase()
|
|
@@ -94,6 +106,7 @@ function comparableText(value) {
|
|
|
94
106
|
.replace(/\s+/g, " ");
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
/** @param {Set<string>} tokens @param {unknown} raw */
|
|
97
110
|
function addToken(tokens, raw) {
|
|
98
111
|
const token = String(raw || "").replace(/^[.-]+|[.-]+$/g, "");
|
|
99
112
|
if (token.length < 2 || TOKEN_STOP_WORDS.has(token)) return;
|
|
@@ -25,17 +25,17 @@ export function createLocalAdminCommands(dependencies) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const RESOURCE_ACTION_HANDLERS =
|
|
29
|
-
list
|
|
30
|
-
add
|
|
31
|
-
"generate-ssh-key"
|
|
32
|
-
remove
|
|
33
|
-
check
|
|
34
|
-
|
|
28
|
+
const RESOURCE_ACTION_HANDLERS = new Map([
|
|
29
|
+
["list", resourceListAction],
|
|
30
|
+
["add", resourceAddAction],
|
|
31
|
+
["generate-ssh-key", resourceGenerateSshKeyAction],
|
|
32
|
+
["remove", resourceRemoveAction],
|
|
33
|
+
["check", resourceCheckAction],
|
|
34
|
+
]);
|
|
35
35
|
|
|
36
36
|
async function resourceCommand(args, { chooseWorkspace }) {
|
|
37
37
|
const action = String(args._[0] || "list").toLowerCase();
|
|
38
|
-
const handler = RESOURCE_ACTION_HANDLERS
|
|
38
|
+
const handler = RESOURCE_ACTION_HANDLERS.get(action);
|
|
39
39
|
if (!handler) throw new Error(`Unknown resource action: ${action}`);
|
|
40
40
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
41
41
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
@@ -155,7 +155,7 @@ async function resourceRemoveAction({ args, workspace, state }) {
|
|
|
155
155
|
|
|
156
156
|
function resourceCheckAction({ args, state }) {
|
|
157
157
|
const name = validateResourceName(args._[1]);
|
|
158
|
-
const resource = state.resources[name];
|
|
158
|
+
const resource = Object.hasOwn(state.resources, name) ? state.resources[name] : null;
|
|
159
159
|
if (!resource) throw new Error(`local resource is not registered: ${name}`);
|
|
160
160
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
161
161
|
const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
|
|
@@ -181,15 +181,47 @@ function publicResourceInspection(name, inspected, { includePath = false, ...ext
|
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
const BROWSER_ACTION_HANDLERS = new Map([
|
|
185
|
+
["path", browserPathAction],
|
|
186
|
+
["status", browserStatusAction],
|
|
187
|
+
["setup", browserPairAction],
|
|
188
|
+
["pair", browserPairAction],
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
async function browserCommand(args, dependencies) {
|
|
185
192
|
const action = String(args._[0] || "status").toLowerCase();
|
|
193
|
+
const handler = BROWSER_ACTION_HANDLERS.get(action);
|
|
194
|
+
if (!handler) throw new Error(`Unknown browser action: ${action}`);
|
|
195
|
+
return handler(args, dependencies);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function browserPathAction(args) {
|
|
186
199
|
const extensionPath = resolve(packageRoot, "browser-extension");
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
200
|
+
if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
|
|
201
|
+
else console.log(extensionPath);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function browserStatusAction(args, { chooseWorkspace }) {
|
|
205
|
+
const context = await browserCommandContext(args, chooseWorkspace);
|
|
206
|
+
renderBrowserStatus(context.result, args.json === true);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function browserPairAction(args, { chooseWorkspace }) {
|
|
210
|
+
const context = await browserCommandContext(args, chooseWorkspace);
|
|
211
|
+
if (!context.result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
|
|
212
|
+
await openExternal(context.pairingUrl);
|
|
213
|
+
if (args.json) {
|
|
214
|
+
console.log(JSON.stringify({ ...context.result, pairing_page_opened: true }, null, 2));
|
|
190
215
|
return;
|
|
191
216
|
}
|
|
192
|
-
|
|
217
|
+
console.log(`Extension path: ${context.extensionPath}`);
|
|
218
|
+
console.log("Load this directory in the Chromium profile you use every day; Machine Bridge does not install it into Playwright or a separate automation profile.");
|
|
219
|
+
console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
|
|
220
|
+
console.log(`Pairing page opened: ${context.pairingUrl}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function browserCommandContext(args, chooseWorkspace) {
|
|
224
|
+
const extensionPath = resolve(packageRoot, "browser-extension");
|
|
193
225
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
194
226
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
195
227
|
const pairingFile = join(state.paths.stateRoot, "browser-bridge.json");
|
|
@@ -197,6 +229,13 @@ async function browserCommand(args, { chooseWorkspace }) {
|
|
|
197
229
|
throw new Error("browser bridge is not initialized; start machine-mcp once, then run this command again");
|
|
198
230
|
}
|
|
199
231
|
ownerOnlyFile(pairingFile);
|
|
232
|
+
const pairing = readBrowserPairingState(pairingFile);
|
|
233
|
+
const pairingUrl = `http://127.0.0.1:${pairing.port}/pair`;
|
|
234
|
+
const health = await readBrowserHealth(`http://127.0.0.1:${pairing.port}/healthz`);
|
|
235
|
+
return { extensionPath, pairingUrl, result: browserStatusResult(health, extensionPath, pairingUrl) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readBrowserPairingState(pairingFile) {
|
|
200
239
|
let pairing;
|
|
201
240
|
try {
|
|
202
241
|
pairing = JSON.parse(readBoundedRegularFileSync(pairingFile, 64 * 1024).toString("utf8"));
|
|
@@ -206,12 +245,17 @@ async function browserCommand(args, { chooseWorkspace }) {
|
|
|
206
245
|
const port = Number(pairing.port);
|
|
207
246
|
if (!/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.token || ""))) throw new Error("browser bridge state contains an invalid token");
|
|
208
247
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("browser bridge state contains an invalid port");
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
248
|
+
return { port };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readBrowserHealth(healthUrl) {
|
|
252
|
+
return fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
|
|
212
253
|
.then(async (response) => response.ok ? await response.json() : null)
|
|
213
254
|
.catch(() => null);
|
|
214
|
-
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function browserStatusResult(health, extensionPath, pairingUrl) {
|
|
258
|
+
return {
|
|
215
259
|
running: health?.ok === true && health?.broker === "machine-bridge-browser",
|
|
216
260
|
connected: health?.broker === "machine-bridge-browser" && health?.connected === true,
|
|
217
261
|
extension_path: extensionPath,
|
|
@@ -227,28 +271,20 @@ async function browserCommand(args, { chooseWorkspace }) {
|
|
|
227
271
|
profile_identity_verifiable: health?.profile_identity_verifiable === true,
|
|
228
272
|
token_exposed: false,
|
|
229
273
|
};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
|
|
236
|
-
if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
|
|
237
|
-
console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
|
|
238
|
-
if (result.controls_extension_profile) console.log(`Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.`);
|
|
239
|
-
console.log(`Extension path: ${extensionPath}`);
|
|
240
|
-
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderBrowserStatus(result, json) {
|
|
277
|
+
if (json) {
|
|
278
|
+
console.log(JSON.stringify(result, null, 2));
|
|
241
279
|
return;
|
|
242
280
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
console.log(`Pairing page opened: ${pairingUrl}`);
|
|
251
|
-
}
|
|
281
|
+
console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
|
|
282
|
+
console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
|
|
283
|
+
if (result.expected_extension_version) console.log(`Expected extension build: ${result.expected_extension_version}`);
|
|
284
|
+
if (result.extension_version || result.extension_protocol) console.log(`Connected extension build: ${result.extension_version || "unknown"} (protocol ${result.extension_protocol ?? "unknown"})`);
|
|
285
|
+
console.log(`Browser profile: ${result.controls_extension_profile ? "the Chromium profile where this extension is installed" : "unknown"}`);
|
|
286
|
+
if (result.controls_extension_profile) console.log("Profile provenance: Machine Bridge did not launch the browser; daily-vs-isolated profile identity is not machine-verifiable.");
|
|
287
|
+
console.log(`Extension path: ${result.extension_path}`);
|
|
252
288
|
}
|
|
253
289
|
|
|
254
290
|
function openExternal(target) {
|