machine-bridge-mcp 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) 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 +30 -2
  6. package/SECURITY.md +1 -1
  7. package/SUPPORT.md +31 -0
  8. package/browser-extension/browser-operations.js +1 -1
  9. package/browser-extension/manifest.json +2 -2
  10. package/browser-extension/page-automation.js +1 -1
  11. package/docs/ARCHITECTURE.md +17 -18
  12. package/docs/AUDIT.md +28 -0
  13. package/docs/ENGINEERING.md +5 -2
  14. package/docs/LOGGING.md +2 -0
  15. package/docs/OPERATIONS.md +7 -3
  16. package/docs/PROJECT_STANDARDS.md +3 -2
  17. package/docs/TESTING.md +7 -4
  18. package/docs/UPGRADING.md +30 -0
  19. package/package.json +15 -7
  20. package/scripts/coverage-check.mjs +24 -9
  21. package/src/local/agent-context.mjs +7 -148
  22. package/src/local/agent-contract.mjs +206 -0
  23. package/src/local/browser-bridge.mjs +30 -281
  24. package/src/local/browser-extension-protocol.mjs +19 -4
  25. package/src/local/browser-operation-service.mjs +325 -0
  26. package/src/local/call-registry.mjs +44 -1
  27. package/src/local/capability-ranking.mjs +13 -0
  28. package/src/local/cli-account-admin.mjs +1 -1
  29. package/src/local/cli.mjs +16 -7
  30. package/src/local/daemon-process.mjs +1 -1
  31. package/src/local/full-access-test.mjs +1 -1
  32. package/src/local/job-runner.mjs +9 -3
  33. package/src/local/monotonic-deadline.mjs +7 -0
  34. package/src/local/numbers.mjs +8 -0
  35. package/src/local/policy.mjs +88 -12
  36. package/src/local/project-metadata.mjs +7 -1
  37. package/src/local/records.mjs +6 -0
  38. package/src/local/runtime-capabilities.mjs +66 -0
  39. package/src/local/runtime-diagnostics.mjs +91 -0
  40. package/src/local/runtime-reporting.mjs +113 -0
  41. package/src/local/runtime.mjs +53 -190
  42. package/src/local/service-convergence.mjs +1 -1
  43. package/src/local/service-environment.mjs +129 -0
  44. package/src/local/service.mjs +22 -53
  45. package/src/local/state.mjs +2 -2
  46. package/src/local/windows-service.mjs +248 -0
  47. package/src/local/worker-health.mjs +1 -1
  48. package/src/worker/access.ts +4 -4
  49. package/src/worker/account-admin.ts +2 -2
  50. package/src/worker/authority.ts +3 -3
  51. package/src/worker/index.ts +29 -337
  52. package/src/worker/oauth-controller.ts +334 -0
  53. package/src/worker/oauth-state.ts +1 -1
  54. package/src/worker/oauth-tokens.ts +2 -2
  55. package/src/worker/tool-catalog.ts +1 -1
  56. package/tsconfig.json +2 -1
  57. 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;
@@ -73,7 +73,7 @@ function printAccountResult(action, result) {
73
73
  if (result.account) console.log(`${result.account.name}: ${result.account.role}; ${result.account.active ? "active" : "disabled"}`);
74
74
  if (result.password) {
75
75
  process.stdout.write(`Password: ${result.password}\n`);
76
- console.log("Save this password now; it is not stored locally or shown again.");
76
+ console.log("Save this password now; it is not stored locally or shown again. Do not share this terminal output.");
77
77
  }
78
78
  if (result.removed) console.log("Account removed.");
79
79
  }
package/src/local/cli.mjs CHANGED
@@ -18,6 +18,7 @@ import { classifyOperationalError, createLogger, sanitizeLogText } from "./log.m
18
18
  import { runExecutable, runWrangler } from "./shell.mjs";
19
19
  import { runFullAccessTest } from "./full-access-test.mjs";
20
20
  import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
21
+ import { loadServiceEnvironment, serviceEnvironmentSummary } from "./service-environment.mjs";
21
22
  import { ensureWorkerDeployment } from "./worker-deployment.mjs";
22
23
  import { workerHealth } from "./worker-health.mjs";
23
24
  export { workerHealthUserReason } from "./worker-health.mjs";
@@ -168,6 +169,10 @@ async function startCommand(args) {
168
169
  const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), format: effectiveLogFormat(args), component: "cli" });
169
170
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
170
171
  const state = loadState(workspace, { stateDir: args.stateDir });
172
+ if (args.daemonOnly) {
173
+ const serviceEnvironment = loadServiceEnvironment(state.paths.stateRoot);
174
+ logger.debug?.("Loaded persisted service network environment", { keys: serviceEnvironment.keys });
175
+ }
171
176
  const startupLock = await acquireStartupLockWithWait(state, { operation: "start", logger });
172
177
 
173
178
  try {
@@ -245,7 +250,7 @@ async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }
245
250
  let runtime = null;
246
251
  try {
247
252
  const readiness = await prepareRemoteState({ args, workspace, state, logger });
248
- runtime = createRemoteRuntime({ args, workspace, state, daemonLock, logger });
253
+ runtime = createRemoteRuntime({ args, workspace, state, daemonLock });
249
254
  await runtime.start();
250
255
  reportRemoteReady(args, state, readiness);
251
256
  keepProcessAlive({ daemon: runtime, lock: daemonLock, logger });
@@ -290,7 +295,7 @@ async function ensureInitialOwnerAccount(state) {
290
295
  return { ...created.account, password };
291
296
  }
292
297
 
293
- function createRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
298
+ function createRemoteRuntime({ args, workspace, state, daemonLock }) {
294
299
  return new LocalRuntime({
295
300
  workerUrl: state.worker.url,
296
301
  secret: state.worker.daemonSecret,
@@ -395,7 +400,7 @@ function printMcpConnection(state, { quiet = false, verbose = false, initialOwne
395
400
  logger.success("Remote MCP bridge is ready");
396
401
  logger.plain(` MCP Server URL: ${state.worker.mcpServerUrl}`);
397
402
  if (initialOwner) {
398
- logger.warn("Initial owner account created; save the password now because it is not stored locally or shown again.");
403
+ logger.warn("Initial owner account created; save the password now because it is not stored locally or shown again. Do not share this terminal output; rotate the password immediately if it has been exposed.");
399
404
  logger.plain(` Account: ${initialOwner.name}`);
400
405
  logger.plain(` Password: ${initialOwner.password}`);
401
406
  } else {
@@ -537,15 +542,16 @@ async function serviceCommand(args) {
537
542
  const stateRoot = stateRootFromArgs(args);
538
543
  const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
539
544
  if (action === "status") {
540
- const status = await autostartStatus({ logger: structuredLogger(Boolean(args.quiet)) });
545
+ const status = await autostartStatus();
541
546
  const state = optionalServiceState(args, stateRoot);
542
547
  const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
543
548
  console.log(JSON.stringify({
544
549
  ...status,
545
550
  workspace: state?.workspace?.path || null,
546
551
  workspace_daemon: workspaceDaemon,
552
+ service_environment: serviceEnvironmentSummary(stateRoot),
547
553
  effective_active: Boolean(status.active || workspaceDaemon?.alive),
548
- orphaned_workspace_daemon: Boolean(!status.active && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
554
+ orphaned_workspace_daemon: Boolean(status.active === false && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
549
555
  }, null, 2));
550
556
  return;
551
557
  }
@@ -620,7 +626,10 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
620
626
  const { installAutostart } = await import("./service.mjs");
621
627
  const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(true) });
622
628
  if (result?.ok) logger.info("Autostart installed for future logins", { provider: result.provider });
623
- else logger.warn("Autostart installation reported a problem; run `machine-mcp service status` for details");
629
+ else logger.warn("Autostart installation reported a problem; run `machine-mcp service status` for details", {
630
+ provider: result?.provider || "unknown",
631
+ reason: result?.reason || "installation_failed",
632
+ });
624
633
  } catch (error) {
625
634
  logger.warn("Autostart installation skipped", { error_class: classifyOperationalError(error) });
626
635
  }
@@ -869,5 +878,5 @@ function version() {
869
878
  }
870
879
 
871
880
  function sleep(ms) {
872
- return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
881
+ return new Promise(resolvePromise => { setTimeout(resolvePromise, ms); });
873
882
  }
@@ -209,5 +209,5 @@ function boundedPositiveInt(value, fallback) {
209
209
  }
210
210
 
211
211
  function sleep(ms) {
212
- return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
212
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, ms); });
213
213
  }
@@ -183,7 +183,7 @@ async function waitForJob(manager, jobId, timeoutMs) {
183
183
  while (!deadline.expired()) {
184
184
  const value = manager.read({ job_id: jobId });
185
185
  if (TERMINAL_JOB_STATES.has(value.status)) return value;
186
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
186
+ await new Promise((resolvePromise) => { setTimeout(resolvePromise, 50); });
187
187
  }
188
188
  throw new Error("full access managed-job test timed out");
189
189
  }
@@ -71,7 +71,7 @@ async function releaseRecoveryClaim() {
71
71
  const deadline = createMonotonicDeadline(5000);
72
72
  while (!deadline.expired()) {
73
73
  if (removeOwnedJsonFileSync(file, { pid: process.pid, token: recoveryLockToken })) return;
74
- await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
74
+ await new Promise((resolvePromise) => { setTimeout(resolvePromise, 10); });
75
75
  }
76
76
  throw new Error("recovery runner could not verify ownership of the recovery lock");
77
77
  }
@@ -257,7 +257,10 @@ async function runStep(step, index, phase, plan, resourceContext, cancellationAw
257
257
 
258
258
  function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captureOutput, captureBudget }) {
259
259
  return new Promise((resolvePromise, rejectPromise) => {
260
- if (cancellationAware && isCancellationRequested()) return rejectPromise(new JobCancelledError());
260
+ if (cancellationAware && isCancellationRequested()) {
261
+ rejectPromise(new JobCancelledError());
262
+ return;
263
+ }
261
264
  const child = spawn(argv[0], argv.slice(1), {
262
265
  cwd,
263
266
  env,
@@ -300,7 +303,10 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
300
303
  });
301
304
  child.on("error", (error) => finish(() => rejectPromise(error)));
302
305
  child.on("close", (code, signal) => finish(() => {
303
- if (cancellationAware && isCancellationRequested()) return rejectPromise(new JobCancelledError());
306
+ if (cancellationAware && isCancellationRequested()) {
307
+ rejectPromise(new JobCancelledError());
308
+ return;
309
+ }
304
310
  resolvePromise({
305
311
  code: Number.isInteger(code) ? code : 1,
306
312
  signal: signal ? String(signal) : null,
@@ -1,5 +1,11 @@
1
+ // @ts-check
2
+
1
3
  import { performance } from "node:perf_hooks";
2
4
 
5
+ /**
6
+ * @param {number} timeoutMs
7
+ * @param {() => number} [now]
8
+ */
3
9
  export function createMonotonicDeadline(timeoutMs, now = () => performance.now()) {
4
10
  const durationMs = Number(timeoutMs);
5
11
  if (!Number.isFinite(durationMs) || durationMs < 0) throw new TypeError("timeoutMs must be a finite non-negative number");
@@ -23,6 +29,7 @@ export function createMonotonicDeadline(timeoutMs, now = () => performance.now()
23
29
  });
24
30
  }
25
31
 
32
+ /** @param {() => number} now */
26
33
  function readSample(now) {
27
34
  const value = Number(now());
28
35
  if (!Number.isFinite(value)) throw new TypeError("monotonic clock returned a non-finite value");