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.
Files changed (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CODE_OF_CONDUCT.md +24 -0
  3. package/CONTRIBUTING.md +3 -1
  4. package/GOVERNANCE.md +50 -0
  5. package/README.md +26 -0
  6. package/SUPPORT.md +31 -0
  7. package/browser-extension/browser-operations.js +10 -4
  8. package/browser-extension/devtools-input.js +2 -2
  9. package/browser-extension/manifest.json +2 -2
  10. package/browser-extension/page-automation.js +1 -1
  11. package/docs/ARCHITECTURE.md +14 -17
  12. package/docs/AUDIT.md +28 -0
  13. package/docs/ENGINEERING.md +8 -2
  14. package/docs/PROJECT_STANDARDS.md +4 -2
  15. package/docs/TESTING.md +9 -3
  16. package/docs/UPGRADING.md +32 -0
  17. package/package.json +12 -4
  18. package/scripts/coverage-check.mjs +27 -10
  19. package/src/local/account-access.mjs +7 -5
  20. package/src/local/agent-context.mjs +7 -148
  21. package/src/local/agent-contract.mjs +206 -0
  22. package/src/local/browser-bridge.mjs +30 -281
  23. package/src/local/browser-extension-protocol.mjs +19 -4
  24. package/src/local/browser-operation-service.mjs +325 -0
  25. package/src/local/call-registry.mjs +44 -1
  26. package/src/local/capability-ranking.mjs +13 -0
  27. package/src/local/cli-local-admin.mjs +74 -38
  28. package/src/local/cli-options.mjs +18 -18
  29. package/src/local/cli-policy.mjs +1 -1
  30. package/src/local/cli-service.mjs +132 -0
  31. package/src/local/cli.mjs +27 -109
  32. package/src/local/daemon-process.mjs +1 -1
  33. package/src/local/full-access-test.mjs +1 -1
  34. package/src/local/job-runner.mjs +9 -3
  35. package/src/local/managed-job-plan.mjs +3 -3
  36. package/src/local/monotonic-deadline.mjs +7 -0
  37. package/src/local/numbers.mjs +8 -0
  38. package/src/local/policy.mjs +97 -16
  39. package/src/local/project-metadata.mjs +7 -1
  40. package/src/local/project-package.mjs +1 -1
  41. package/src/local/records.mjs +6 -0
  42. package/src/local/resource-operations.mjs +1 -1
  43. package/src/local/runtime-capabilities.mjs +66 -0
  44. package/src/local/runtime-diagnostics.mjs +91 -0
  45. package/src/local/runtime-reporting.mjs +113 -0
  46. package/src/local/runtime.mjs +54 -191
  47. package/src/local/service-convergence.mjs +1 -1
  48. package/src/local/service.mjs +1 -1
  49. package/src/local/state.mjs +1 -1
  50. package/src/local/windows-service.mjs +1 -1
  51. package/src/local/worker-deployment.mjs +15 -10
  52. package/src/local/worker-health.mjs +8 -4
  53. package/src/worker/access.ts +8 -7
  54. package/src/worker/account-admin.ts +2 -2
  55. package/src/worker/authority.ts +3 -3
  56. package/src/worker/http.ts +2 -2
  57. package/src/worker/index.ts +29 -337
  58. package/src/worker/oauth-controller.ts +343 -0
  59. package/src/worker/oauth-state.ts +1 -1
  60. package/src/worker/oauth-tokens.ts +2 -2
  61. package/src/worker/tool-catalog.ts +1 -1
  62. package/tsconfig.json +2 -1
  63. 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) throw new BridgeError("limit_exceeded", `too many concurrent tool calls (${this.maximum})`, { retryable: true });
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 = Object.freeze({
29
- list: resourceListAction,
30
- add: resourceAddAction,
31
- "generate-ssh-key": resourceGenerateSshKeyAction,
32
- remove: resourceRemoveAction,
33
- check: resourceCheckAction,
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[action];
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
- async function browserCommand(args, { chooseWorkspace }) {
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 (action === "path") {
188
- if (args.json) console.log(JSON.stringify({ extension_path: extensionPath }, null, 2));
189
- else console.log(extensionPath);
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
- if (!["status", "setup", "pair"].includes(action)) throw new Error(`Unknown browser action: ${action}`);
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
- const pairingUrl = `http://127.0.0.1:${port}/pair`;
210
- const healthUrl = `http://127.0.0.1:${port}/healthz`;
211
- const health = await fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
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
- const result = {
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
- if (action === "status") {
231
- if (args.json) console.log(JSON.stringify(result, null, 2));
232
- else {
233
- console.log(`Browser bridge: ${result.running ? "running" : "not reachable"}`);
234
- console.log(`Extension: ${result.connected ? "connected" : result.extension_reload_required ? "reload required" : "not connected"}`);
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
- if (!result.running) throw new Error("browser bridge is not reachable; keep machine-mcp running and retry");
244
- await openExternal(pairingUrl);
245
- if (args.json) console.log(JSON.stringify({ ...result, pairing_page_opened: true }, null, 2));
246
- else {
247
- console.log(`Extension path: ${extensionPath}`);
248
- 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.");
249
- console.log("Enable Developer mode, choose Load unpacked, and reload the extension after each Machine Bridge upgrade.");
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) {