machine-bridge-mcp 1.2.8 → 1.2.10

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 +20 -0
  2. package/CONTRIBUTING.md +16 -5
  3. package/README.md +115 -416
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +19 -9
  7. package/docs/AUDIT.md +20 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/LOGGING.md +1 -1
  10. package/docs/MULTI_ACCOUNT.md +2 -2
  11. package/docs/OPERATIONS.md +2 -2
  12. package/docs/OVERVIEW.md +113 -0
  13. package/docs/PROJECT_STANDARDS.md +4 -4
  14. package/docs/RELEASING.md +25 -12
  15. package/docs/TESTING.md +12 -8
  16. package/docs/THREAT_MODEL.md +142 -0
  17. package/package.json +10 -3
  18. package/scripts/check-plan.mjs +91 -0
  19. package/scripts/coverage-check.mjs +22 -0
  20. package/scripts/github-push.mjs +1 -1
  21. package/scripts/github-release.mjs +1 -1
  22. package/scripts/local-release-acceptance.mjs +56 -6
  23. package/scripts/release-acceptance.mjs +15 -3
  24. package/scripts/release-state.mjs +1 -1
  25. package/scripts/run-checks.mjs +29 -0
  26. package/scripts/start-release-candidate.mjs +113 -0
  27. package/src/local/agent-context-projection.mjs +158 -0
  28. package/src/local/agent-context.mjs +23 -332
  29. package/src/local/agent-skill-discovery.mjs +230 -0
  30. package/src/local/agent-text-file.mjs +41 -0
  31. package/src/local/browser-bridge-http.mjs +48 -0
  32. package/src/local/browser-bridge.mjs +48 -222
  33. package/src/local/browser-broker-routes.mjs +136 -0
  34. package/src/local/browser-broker-server.mjs +59 -0
  35. package/src/local/browser-request-registry.mjs +67 -0
  36. package/src/local/managed-job-lock.mjs +99 -0
  37. package/src/local/managed-job-projection.mjs +68 -0
  38. package/src/local/managed-job-runner.mjs +73 -0
  39. package/src/local/managed-job-storage.mjs +93 -0
  40. package/src/local/managed-jobs.mjs +12 -297
  41. package/src/local/relay-call-recovery.mjs +148 -0
  42. package/src/local/relay-connection.mjs +5 -0
  43. package/src/local/runtime-paths.mjs +107 -0
  44. package/src/local/runtime-relay.mjs +89 -0
  45. package/src/local/runtime-tool-handlers.mjs +66 -0
  46. package/src/local/runtime.mjs +80 -242
  47. package/src/local/windows-launcher.mjs +57 -0
  48. package/src/local/windows-service.mjs +7 -56
  49. package/src/worker/daemon-sockets.ts +10 -1
  50. package/src/worker/index.ts +51 -124
  51. package/src/worker/mcp-jsonrpc.ts +94 -0
  52. package/src/worker/oauth-authorization-page.ts +70 -0
  53. package/src/worker/oauth-controller.ts +9 -58
  54. package/src/worker/pending-call-contract.ts +26 -0
  55. package/src/worker/pending-calls.ts +41 -25
  56. package/src/worker/websocket-protocol.ts +24 -0
  57. package/tsconfig.local.json +7 -1
@@ -1,12 +1,12 @@
1
- import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
1
+ import { randomBytes } from "node:crypto";
2
+ import { realpathSync, rmSync } from "node:fs";
2
3
  import { lstat, realpath, stat } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { isRelayReadyContext, RelayConnection } from "./relay-connection.mjs";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { isRelayReadyContext } from "./relay-connection.mjs";
6
6
  import { ProcessSessionManager } from "./process-sessions.mjs";
7
7
  import { MAX_CONCURRENT_TOOL_CALLS } from "./execution-limits.mjs";
8
8
  export { MAX_COMMAND_BYTES } from "./process-contract.mjs";
9
- import { MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, PolicyGate, SERVER_NAME } from "./tools.mjs";
9
+ import { normalizePolicy, PolicyGate } from "./tools.mjs";
10
10
  import { publicError } from "./errors.mjs";
11
11
  import { ProcessTracker } from "./process-tracker.mjs";
12
12
  import { CallRegistry } from "./call-registry.mjs";
@@ -26,75 +26,23 @@ import { AppAutomationManager } from "./app-automation.mjs";
26
26
  import { BrowserBridgeManager } from "./browser-bridge.mjs";
27
27
  import { CapabilityObserver } from "./capability-observer.mjs";
28
28
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
29
- import { clampInteger } from "./numbers.mjs";
30
29
  import { isPlainRecord } from "./records.mjs";
31
- import { AccountAccessGate, normalizeAccountRole } from "./account-access.mjs";
30
+ import { AccountAccessGate } from "./account-access.mjs";
32
31
  import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs";
33
32
  import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
33
+ import { bindRuntimeToolHandlers, runtimeToolHandlerNames as registeredRuntimeToolHandlerNames } from "./runtime-tool-handlers.mjs";
34
+ import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelayToolCall } from "./runtime-relay.mjs";
35
+ import { RelayCallRecovery } from "./relay-call-recovery.mjs";
36
+ import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage, stateRootFromProfileStatePath } from "./runtime-paths.mjs";
34
37
  import {
35
38
  resolveTaskCapabilities as resolveRuntimeTaskCapabilities,
36
39
  sessionBootstrap as buildRuntimeSessionBootstrap,
37
40
  } from "./runtime-capabilities.mjs";
38
41
 
39
- const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
40
42
  const SLOW_TOOL_CALL_MS = 30_000;
41
43
 
42
- const RUNTIME_TOOL_HANDLERS = Object.freeze({
43
- server_info: (runtime) => runtime.runtimeInfo(),
44
- project_overview: (runtime, _args, context) => runtime.projectOverview(context),
45
- session_bootstrap: (runtime, args, context) => runtime.sessionBootstrap(args, context),
46
- agent_context: (runtime, args, context) => runtime.agentContextManager.agentContext(args, context),
47
- resolve_task_capabilities: (runtime, args, context) => runtime.resolveTaskCapabilities(args, context),
48
- list_local_skills: (runtime, args, context) => runtime.agentContextManager.listLocalSkills(args, context),
49
- load_local_skill: (runtime, args, context) => runtime.agentContextManager.loadLocalSkill(args, context),
50
- list_local_commands: (runtime, args, context) => runtime.agentContextManager.listLocalCommands(args, context),
51
- run_local_command: (runtime, args, context) => runtime.runLocalCommand(args, context),
52
- list_local_applications: (runtime, args, context) => runtime.appAutomationManager.listApplications(args, context),
53
- open_local_application: (runtime, args, context) => runtime.appAutomationManager.openApplication(args, context),
54
- inspect_local_application: (runtime, args, context) => runtime.appAutomationManager.inspectApplication(args, context),
55
- operate_local_application: (runtime, args, context) => runtime.appAutomationManager.operateApplication(args, context),
56
- browser_status: (runtime, _args, context) => runtime.browserBridgeManager.status(context),
57
- pair_browser_extension: (runtime, args, context) => runtime.browserBridgeManager.pair(args, context),
58
- browser_list_tabs: (runtime, args, context) => runtime.browserBridgeManager.listTabs(args, context),
59
- browser_manage_tabs: (runtime, args, context) => runtime.browserBridgeManager.manageTabs(args, context),
60
- browser_wait: (runtime, args, context) => runtime.browserBridgeManager.wait(args, context),
61
- browser_get_source: (runtime, args, context) => runtime.browserBridgeManager.getSource(args, context),
62
- browser_inspect_page: (runtime, args, context) => runtime.browserBridgeManager.inspectPage(args, context),
63
- browser_action: (runtime, args, context) => runtime.browserBridgeManager.act(args, context),
64
- browser_fill_form: (runtime, args, context) => runtime.browserBridgeManager.fillForm(args, context),
65
- browser_screenshot: (runtime, args, context) => runtime.browserBridgeManager.screenshot(args, context),
66
- browser_upload_files: (runtime, args, context) => runtime.browserBridgeManager.uploadFiles(args, context),
67
- list_roots: (runtime) => runtime.listRoots(),
68
- list_dir: (runtime, args, context) => runtime.listDir(args.path || ".", context),
69
- list_files: (runtime, args, context) => runtime.listFiles(args.path || ".", clampInteger(args.max_files, 1000, 1, 10000), context),
70
- read_file: (runtime, args, context) => runtime.readFile(args, context),
71
- view_image: (runtime, args, context) => runtime.viewImage(args, context),
72
- write_file: (runtime, args, context) => runtime.writeFile(args, context),
73
- edit_file: (runtime, args, context) => runtime.editFile(args, context),
74
- apply_patch: (runtime, args, context) => runtime.applyPatch(args, context),
75
- search_text: (runtime, args, context) => runtime.searchText(args, context),
76
- git_status: (runtime, args, context) => runtime.gitStatus(args, context),
77
- git_diff: (runtime, args, context) => runtime.gitDiff(args, context),
78
- git_log: (runtime, args, context) => runtime.gitLog(args, context),
79
- git_show: (runtime, args, context) => runtime.gitShow(args, context),
80
- diagnose_runtime: (runtime, _args, context) => runtime.diagnoseRuntime(context),
81
- list_local_resources: (runtime) => runtime.managedJobManager.listResources(),
82
- generate_ssh_key_resource: (runtime, args, context) => runtime.generateSshKeyResource(args, context),
83
- stage_job: (runtime, args) => runtime.managedJobManager.stage(args),
84
- start_job: (runtime, args) => runtime.managedJobManager.start(args),
85
- list_jobs: (runtime, args) => runtime.managedJobManager.list(args),
86
- read_job: (runtime, args) => runtime.managedJobManager.read(args),
87
- cancel_job: (runtime, args) => runtime.managedJobManager.cancel(args),
88
- run_process: (runtime, args, context) => runtime.runDirectProcess(args, context),
89
- start_process: (runtime, args, context) => runtime.processSessionManager.start(args, context),
90
- read_process: (runtime, args, context) => runtime.processSessionManager.read(args, context),
91
- write_process: (runtime, args, context) => runtime.processSessionManager.write(args, context),
92
- kill_process: (runtime, args, context) => runtime.processSessionManager.kill(args, context),
93
- exec_command: (runtime, args, context) => runtime.execCommand(args.command, clampInteger(args.timeout_seconds, 120, 1, 600), context),
94
- });
95
-
96
44
  export function runtimeToolHandlerNames() {
97
- return Object.keys(RUNTIME_TOOL_HANDLERS);
45
+ return registeredRuntimeToolHandlerNames();
98
46
  }
99
47
 
100
48
  export class LocalRuntime {
@@ -113,8 +61,10 @@ export class LocalRuntime {
113
61
  this.processTracker = new ProcessTracker();
114
62
  this.lifecycle = new LifecycleController("local runtime");
115
63
  this.observability = new RuntimeObservability();
64
+ this.relayInstanceId = `daemon_${randomBytes(18).toString("base64url")}`;
116
65
  this.activeRelayCalls = new Set();
117
66
  this.suppressedRelayResults = new Map();
67
+ this.relayResumeSessionId = 0;
118
68
  this.callRegistry = new CallRegistry({
119
69
  maximum: MAX_CONCURRENT_TOOL_CALLS,
120
70
  onCancel: (record) => {
@@ -215,10 +165,7 @@ export class LocalRuntime {
215
165
  logger: this.logger,
216
166
  });
217
167
  this.toolExecutor = new ToolExecutor({
218
- handlers: Object.fromEntries(Object.entries(RUNTIME_TOOL_HANDLERS).map(([name, handler]) => [
219
- name,
220
- (args, context) => handler(this, args, context),
221
- ])),
168
+ handlers: bindRuntimeToolHandlers(this),
222
169
  policyGate: this.policyGate,
223
170
  accountAccessGate: this.accountAccessGate,
224
171
  callRegistry: this.callRegistry,
@@ -227,11 +174,17 @@ export class LocalRuntime {
227
174
  safeMessage: (error, args) => this.safeErrorMessage(error, args),
228
175
  slowMs: SLOW_TOOL_CALL_MS,
229
176
  });
230
- this.relay = createRelayConnection(this, {
231
- workerUrl: remoteWorkerUrl,
232
- secret: remoteSecret,
233
- expectedVersion: expectedRelayVersion,
234
- onFatal,
177
+ this.relay = createRuntimeRelayConnection(this, {
178
+ workerUrl: remoteWorkerUrl, secret: remoteSecret, expectedVersion: expectedRelayVersion, onFatal,
179
+ });
180
+ this.relayCallRecovery = new RelayCallRecovery({
181
+ logger: this.logger,
182
+ send: (value) => this.send(value),
183
+ isRecoverable: () => Boolean(this.relay && !this.relay.status?.().closed),
184
+ activeCallIds: () => this.activeRelayCalls,
185
+ suppressCall: (callId, reason) => this.suppressedRelayResults.set(callId, reason),
186
+ cancelOrigin: (reason) => this.callRegistry.cancelOrigin("relay", reason),
187
+ terminate: () => this.terminateActiveProcesses("SIGTERM", true),
235
188
  });
236
189
  }
237
190
 
@@ -276,6 +229,7 @@ export class LocalRuntime {
276
229
  if (!this.lifecycle.beginStop()) return;
277
230
  try {
278
231
  this.relay?.stop();
232
+ this.relayCallRecovery.stop();
279
233
  this.callRegistry.cancelAll("runtime stopped");
280
234
  this.terminateActiveProcesses("SIGKILL");
281
235
  this.processSessionManager.clear();
@@ -300,7 +254,7 @@ export class LocalRuntime {
300
254
  this.handleRelayProtocolViolation("invalid_server_message");
301
255
  return;
302
256
  }
303
- if (this.handleRelayControlMessage(message)) return;
257
+ if (this.handleRelayControlMessage(message, relayContext)) return;
304
258
  if (message.type === "relay_probe") {
305
259
  if (isRelayReadyContext(relayContext, this.relay)) return this.handleRelayProtocolViolation("unexpected_relay_probe");
306
260
  this.handleRelayProbe(message, relayContext);
@@ -308,20 +262,38 @@ export class LocalRuntime {
308
262
  }
309
263
  if (message.type !== "tool_call") return this.handleRelayProtocolViolation("unexpected_server_message_type");
310
264
  if (!isRelayReadyContext(relayContext, this.relay)) return this.handleRelayProtocolViolation("tool_call_before_ready");
311
- await this.handleRelayToolCall(message, relayContext);
265
+ await this.handleRelayToolCall(message);
312
266
  }
313
267
 
314
- handleRelayControlMessage(message) {
268
+ handleRelayControlMessage(message, relayContext = {}) {
315
269
  if (message.type === "welcome") {
316
270
  this.relay?.observeWelcome(message);
317
271
  return true;
318
272
  }
319
273
  if (message.type === "hello_ack") {
274
+ this.relayResumeSessionId = 0;
320
275
  this.relay?.acknowledge(message);
321
276
  return true;
322
277
  }
278
+ if (message.type === "resume_calls") {
279
+ const sessionId = Number(relayContext.sessionId) || 0;
280
+ const resume = normalizeRelayResumeCalls(message);
281
+ if (!resume.ok || !sessionId || relayContext.authenticated !== true || relayContext.ready === true) {
282
+ this.handleRelayProtocolViolation("invalid_resume_calls");
283
+ return true;
284
+ }
285
+ this.reconcileRelayCalls(resume.ids);
286
+ this.relayResumeSessionId = sessionId;
287
+ return true;
288
+ }
323
289
  if (message.type === "ready_ack") {
290
+ const sessionId = Number(relayContext.sessionId) || 0;
291
+ if (!sessionId || sessionId !== this.relayResumeSessionId) {
292
+ this.handleRelayProtocolViolation("resume_calls_required");
293
+ return true;
294
+ }
324
295
  this.relay?.confirmReady(message);
296
+ this.relayResumeSessionId = 0;
325
297
  return true;
326
298
  }
327
299
  if (message.type === "pong") return true;
@@ -348,12 +320,12 @@ export class LocalRuntime {
348
320
  const id = typeof message?.id === "string" && /^probe_[A-Za-z0-9_-]{8,240}$/.test(message.id) ? message.id : "";
349
321
  const relaySessionId = Number(relayContext.sessionId) || 0;
350
322
  if (!id || !relaySessionId) return this.handleRelayProtocolViolation("invalid_relay_probe");
351
- this.deliverRelayToolResult({ type: "relay_probe_result", id }, relaySessionId);
323
+ const outcome = this.relay?.sendForSession?.({ type: "relay_probe_result", id }, relaySessionId);
324
+ if (!outcome?.ok) this.handleRelayProtocolViolation("relay_probe_delivery_failed");
352
325
  }
353
326
 
354
- async handleRelayToolCall(message, relayContext = {}) {
327
+ async handleRelayToolCall(message) {
355
328
  const envelope = normalizeRelayToolCall(message);
356
- const relaySessionId = Number(relayContext.sessionId) || 0;
357
329
  if (!envelope.ok) {
358
330
  this.logger.warn?.("Received an invalid tool request from the relay; the request was rejected.");
359
331
  this.logger.event?.("debug", "relay.tool_call.invalid", {
@@ -364,7 +336,7 @@ export class LocalRuntime {
364
336
  id: envelope.id,
365
337
  ok: false,
366
338
  error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false },
367
- }, relaySessionId);
339
+ });
368
340
  return;
369
341
  }
370
342
  if (this.activeRelayCalls.has(envelope.id)) {
@@ -392,31 +364,15 @@ export class LocalRuntime {
392
364
  }, "Discarded a tool result because the caller was no longer waiting");
393
365
  return;
394
366
  }
395
- this.deliverRelayToolResult(response, relaySessionId);
367
+ this.deliverRelayToolResult(response);
396
368
  } finally {
397
369
  this.activeRelayCalls.delete(envelope.id);
398
370
  this.suppressedRelayResults.delete(envelope.id);
399
371
  }
400
372
  }
401
373
 
402
- deliverRelayToolResult(response, relaySessionId = 0) {
403
- const sessionId = Number(relaySessionId) || 0;
404
- const outcome = this.relay?.sendForSession
405
- ? this.relay.sendForSession(response, sessionId)
406
- : (this.send(response) ? { ok: true, reason: "sent" } : { ok: false, reason: "transport_unavailable" });
407
- if (outcome?.ok) return true;
408
- const reason = String(outcome?.reason || "transport_unavailable");
409
- const missingSession = reason === "session_ended" && sessionId <= 0;
410
- this.logger.event?.(missingSession ? "error" : "debug", "relay.tool_result.discarded", {
411
- call_id: shortCallId(response?.id), reason, relay_session_id: sessionId,
412
- active_session_id: Number(this.relay?.currentSessionId?.() || 0),
413
- }, missingSession
414
- ? "Discarded a tool result because the relay session id was missing from the inbound tool_call context"
415
- : reason === "send_failed"
416
- ? "Could not send a tool result because the relay transport failed"
417
- : "Discarded a tool result because its relay session had ended");
418
- if (reason === "send_failed") this.relay?.interrupt?.("relay_transport_error");
419
- return false;
374
+ deliverRelayToolResult(response) {
375
+ return this.relayCallRecovery.deliver(response);
420
376
  }
421
377
 
422
378
  finishCall(callId) {
@@ -430,18 +386,25 @@ export class LocalRuntime {
430
386
 
431
387
  cancelRelayCall(callId, suppressionReason = "caller_cancelled") {
432
388
  const id = String(callId);
389
+ const discardedResult = this.relayCallRecovery.discard(id);
433
390
  if (this.activeRelayCalls.has(id)) this.suppressedRelayResults.set(id, suppressionReason);
434
- return this.cancelCall(id, "remote cancellation");
391
+ return this.cancelCall(id, "remote cancellation") || discardedResult;
392
+ }
393
+
394
+ reconcileRelayCalls(resumedCallIds) {
395
+ this.relayCallRecovery.reconcile(
396
+ resumedCallIds,
397
+ (callId) => this.cancelRelayCall(callId, "caller_no_longer_waiting"),
398
+ );
435
399
  }
436
400
 
437
401
  handleRelayDisconnect() {
438
- for (const callId of this.activeRelayCalls) this.suppressedRelayResults.set(callId, "relay_disconnected");
439
- const cancelled = this.callRegistry.cancelOrigin("relay", "remote relay disconnected");
440
- this.terminateActiveProcesses("SIGTERM", true);
441
- if (cancelled > 0) {
442
- this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled },
443
- "Cancelled in-flight tool calls after the relay connection ended");
444
- }
402
+ this.relayResumeSessionId = 0;
403
+ this.relayCallRecovery.disconnected();
404
+ }
405
+
406
+ handleRelayReady() {
407
+ this.relayCallRecovery.ready();
445
408
  }
446
409
 
447
410
  async executeTool(tool, args, context = {}) {
@@ -662,19 +625,17 @@ export class LocalRuntime {
662
625
  }
663
626
 
664
627
  safeErrorMessage(error, toolArgs = {}) {
665
- let message = boundedErrorMessage(error);
666
- if (!this.policy.exposeAbsolutePaths) {
667
- for (const prefix of equivalentPathPrefixes(this.workspace, this.workspaceInput)) message = replacePathPrefix(message, prefix, ".");
668
- for (const prefix of equivalentPathPrefixes(this.runtimeDir)) message = replacePathPrefix(message, prefix, "<runtime>");
669
- const home = process.env.HOME || process.env.USERPROFILE;
670
- if (home) message = replacePathPrefix(message, resolve(home), "<home>");
671
- for (const candidate of collectToolPathCandidates(error, toolArgs, this.workspaceInput)) {
672
- const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(this.workspaceInput, candidate);
673
- const replacement = this.displayPath(absolute);
674
- for (const prefix of equivalentPathPrefixes(candidate, absolute)) message = replacePathPrefix(message, prefix, replacement);
675
- }
676
- }
677
- return message;
628
+ const message = boundedErrorMessage(error);
629
+ if (this.policy.exposeAbsolutePaths) return message;
630
+ return redactRuntimeErrorMessage(message, {
631
+ error,
632
+ toolArgs,
633
+ workspace: this.workspace,
634
+ workspaceInput: this.workspaceInput,
635
+ runtimeDir: this.runtimeDir,
636
+ home: process.env.HOME || process.env.USERPROFILE || "",
637
+ displayPath: (value) => this.displayPath(value),
638
+ });
678
639
  }
679
640
 
680
641
  throwIfCancelled(context = {}) {
@@ -690,129 +651,6 @@ export class LocalRuntime {
690
651
  }
691
652
  }
692
653
 
693
- function stateRootFromProfileStatePath(statePath) {
694
- const absolute = resolve(statePath);
695
- if (basename(absolute) !== "state.json") throw new Error("local resource state path is invalid");
696
- const profileDir = dirname(absolute);
697
- const profilesDir = dirname(profileDir);
698
- if (basename(profilesDir) !== "profiles") throw new Error("local resource state path is outside the expected profile layout");
699
- return dirname(profilesDir);
700
- }
701
-
702
- function assertContainedPath(root, target) {
703
- const rel = relative(root, target);
704
- if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) return;
705
- throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
706
- }
707
-
708
- function createRuntimeDir() {
709
- const root = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-"));
710
- for (const name of ["home", "tmp", "cache"]) mkdirSync(join(root, name), { recursive: true, mode: 0o700 });
711
- return root;
712
- }
713
-
714
- function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, onFatal }) {
715
- if (!workerUrl) return null;
716
- return new RelayConnection({
717
- workerUrl,
718
- secret,
719
- logger: runtime.logger,
720
- maxPayload: MAX_WS_MESSAGE_BYTES,
721
- expectedServer: SERVER_NAME,
722
- expectedVersion: String(expectedVersion || ""),
723
- helloMessage: () => ({
724
- type: "hello",
725
- tools: runtime.tools(),
726
- policy: runtime.policy,
727
- protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
728
- }),
729
- onMessage: (data, relayContext) => handleRelayData(runtime, data, relayContext),
730
- onDisconnect: () => runtime.handleRelayDisconnect(),
731
- onSuperseded: () => {
732
- runtime.terminateActiveProcesses("SIGKILL");
733
- runtime.processSessionManager.clear();
734
- runtime.onSuperseded?.();
735
- },
736
- onFatal: (error) => {
737
- runtime.terminateActiveProcesses("SIGKILL");
738
- runtime.processSessionManager.clear();
739
- onFatal?.(error);
740
- },
741
- });
742
- }
743
-
744
-
745
- function handleRelayData(runtime, data, relayContext = {}) {
746
- const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
747
- if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
748
- runtime.handleRelayProtocolViolation("server_message_too_large");
749
- return;
750
- }
751
- return runtime.handleMessage(raw, relayContext);
752
- }
753
-
754
- function normalizeRelayToolCall(message) {
755
- const id = typeof message.id === "string" && message.id.length <= 256 ? message.id : "";
756
- const tool = typeof message.tool === "string" && message.tool.length <= 128 ? message.tool : "";
757
- const argumentsValue = message.arguments === undefined ? {} : message.arguments;
758
- const authorization = normalizeRelayAuthorization(message.authorization);
759
- if (!id || !tool || !isPlainRecord(argumentsValue) || !authorization) return { ok: false, id };
760
- return {
761
- ok: true,
762
- id,
763
- tool,
764
- arguments: argumentsValue,
765
- authorization,
766
- timeoutMs: clampInteger(message.timeout_ms, 60_000, 1000, 610_000),
767
- };
768
- }
769
-
770
- function normalizeRelayAuthorization(value) {
771
- if (!isPlainRecord(value)) return null;
772
- const accountId = typeof value.account_id === "string" && /^acct_[A-Za-z0-9_-]{20,96}$/.test(value.account_id) ? value.account_id : "";
773
- const accountVersion = Number(value.account_version);
774
- let role;
775
- try { role = normalizeAccountRole(value.role); } catch { return null; }
776
- if (!accountId || !Number.isInteger(accountVersion) || accountVersion < 1) return null;
777
- return Object.freeze({ account_id: accountId, account_version: accountVersion, role });
778
- }
779
-
780
- function collectToolPathCandidates(error, toolArgs, workspace) {
781
- const candidates = new Set();
782
- for (const value of [error?.path, error?.dest]) if (typeof value === "string" && value) candidates.add(value);
783
- const visit = (value, key = "", depth = 0) => {
784
- if (depth > 5 || value === null || value === undefined) return;
785
- if (typeof value === "string") {
786
- if (/(?:^|[_-])(?:path|cwd|workspace|root|directory|dir)(?:$|[_-])/i.test(key) && value && !value.includes("\0")) candidates.add(value);
787
- return;
788
- }
789
- if (Array.isArray(value)) {
790
- for (const item of value.slice(0, 64)) visit(item, key, depth + 1);
791
- return;
792
- }
793
- if (typeof value !== "object") return;
794
- for (const [childKey, child] of Object.entries(value).slice(0, 128)) visit(child, childKey, depth + 1);
795
- };
796
- visit(toolArgs);
797
- candidates.delete(workspace);
798
- return [...candidates].sort((left, right) => right.length - left.length);
799
- }
800
-
801
- function equivalentPathPrefixes(...values) {
802
- const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
803
- for (const value of [...prefixes]) {
804
- if (value.startsWith("/private/")) prefixes.add(value.slice("/private".length));
805
- else if (value.startsWith("/") && ["/var/", "/tmp/", "/etc/"].some((prefix) => value.startsWith(prefix))) prefixes.add(`/private${value}`);
806
- }
807
- return [...prefixes].sort((left, right) => right.length - left.length);
808
- }
809
-
810
- function replacePathPrefix(message, pathValue, replacement) {
811
- if (!pathValue) return message;
812
- const normalized = String(pathValue);
813
- return message.split(normalized).join(replacement);
814
- }
815
-
816
654
  function shortCallId(value) {
817
655
  return String(value || "").slice(0, 20);
818
656
  }
@@ -0,0 +1,57 @@
1
+ import path from "node:path";
2
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
3
+
4
+ const WINDOWS_LAUNCHER = "service-launcher.cmd";
5
+ const WINDOWS_TASK_COMMAND_MAX_CHARS = 262;
6
+
7
+ export function windowsLauncherPath(stateRoot) {
8
+ return path.join(path.resolve(String(stateRoot)), WINDOWS_LAUNCHER);
9
+ }
10
+
11
+ export function writeWindowsLauncher(spec) {
12
+ const launcherPath = windowsLauncherPath(spec.stateRoot);
13
+ const content = windowsLauncherContent(spec);
14
+ replaceFileAtomicallySync(launcherPath, content, { mode: 0o600 });
15
+ return { path: launcherPath, content };
16
+ }
17
+
18
+ export function windowsLauncherContent(spec) {
19
+ const command = [spec.node, ...(spec.daemonArgs || [])].map(windowsBatchArgument).join(" ");
20
+ const stdout = windowsBatchArgument(spec.stdout);
21
+ const stderr = windowsBatchArgument(spec.stderr);
22
+ return [
23
+ "@echo off",
24
+ "setlocal DisableDelayedExpansion",
25
+ ":restart",
26
+ `${command} 1>>${stdout} 2>>${stderr}`,
27
+ 'set "mbm_exit=%ERRORLEVEL%"',
28
+ 'if "%mbm_exit%"=="0" exit /b 0',
29
+ '"%SystemRoot%\\System32\\timeout.exe" /t 5 /nobreak >nul 2>&1',
30
+ "goto restart",
31
+ "",
32
+ ].join("\r\n");
33
+ }
34
+
35
+ export function windowsTaskAction(launcherPath) {
36
+ const action = String(launcherPath);
37
+ if (action.includes("\0") || /[\r\n]/.test(action)) throw new Error("Windows autostart launcher path contains a prohibited control character");
38
+ if (!path.isAbsolute(action) && !path.win32.isAbsolute(action)) throw new Error("Windows autostart launcher path must be absolute");
39
+ if (action.includes("%")) throw new Error("Windows autostart launcher path must not contain a percent sign because Task Scheduler may expand it as an environment variable");
40
+ if (action.length > WINDOWS_TASK_COMMAND_MAX_CHARS) {
41
+ throw new Error(`Windows autostart action exceeds the ${WINDOWS_TASK_COMMAND_MAX_CHARS}-character Task Scheduler limit; use the default state directory or a shorter --state-dir`);
42
+ }
43
+ return action;
44
+ }
45
+
46
+ export function windowsCommandLineArgument(value) {
47
+ const text = String(value);
48
+ if (text.includes("\0")) throw new Error("Windows command-line argument contains a NUL byte");
49
+ if (/[\r\n]/.test(text)) throw new Error("Windows command-line argument contains a line break");
50
+ const escapedQuotes = text.replace(/(\\*)"/g, (_match, slashes) => `${slashes}${slashes}\\"`);
51
+ const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, slashes => `${slashes}${slashes}`);
52
+ return `"${escapedTrailingSlashes}"`;
53
+ }
54
+
55
+ export function windowsBatchArgument(value) {
56
+ return windowsCommandLineArgument(value).replaceAll("%", "%%");
57
+ }
@@ -1,10 +1,13 @@
1
- import path from "node:path";
2
- import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
3
1
  import { waitForInactiveStatus } from "./service-convergence.mjs";
2
+ import {
3
+ windowsLauncherPath, windowsTaskAction, writeWindowsLauncher,
4
+ } from "./windows-launcher.mjs";
5
+ export {
6
+ windowsBatchArgument, windowsCommandLineArgument, windowsLauncherContent,
7
+ windowsLauncherPath, windowsTaskAction, writeWindowsLauncher,
8
+ } from "./windows-launcher.mjs";
4
9
 
5
10
  export const WINDOWS_TASK = "MachineBridgeMCP";
6
- const WINDOWS_LAUNCHER = "service-launcher.cmd";
7
- const WINDOWS_TASK_COMMAND_MAX_CHARS = 262;
8
11
  const WINDOWS_STATUS_SCRIPT = [
9
12
  "$task = Get-ScheduledTask -TaskName 'MachineBridgeMCP' -ErrorAction SilentlyContinue;",
10
13
  "if ($null -eq $task) { exit 3 };",
@@ -150,58 +153,6 @@ export async function statusWindowsTask(options = {}) {
150
153
  };
151
154
  }
152
155
 
153
- export function windowsLauncherPath(stateRoot) {
154
- return path.join(path.resolve(String(stateRoot)), WINDOWS_LAUNCHER);
155
- }
156
-
157
- export function writeWindowsLauncher(spec) {
158
- const launcherPath = windowsLauncherPath(spec.stateRoot);
159
- const content = windowsLauncherContent(spec);
160
- replaceFileAtomicallySync(launcherPath, content, { mode: 0o600 });
161
- return { path: launcherPath, content };
162
- }
163
-
164
- export function windowsLauncherContent(spec) {
165
- const command = [spec.node, ...(spec.daemonArgs || [])].map(windowsBatchArgument).join(" ");
166
- const stdout = windowsBatchArgument(spec.stdout);
167
- const stderr = windowsBatchArgument(spec.stderr);
168
- return [
169
- "@echo off",
170
- "setlocal DisableDelayedExpansion",
171
- ":restart",
172
- `${command} 1>>${stdout} 2>>${stderr}`,
173
- 'set "mbm_exit=%ERRORLEVEL%"',
174
- 'if "%mbm_exit%"=="0" exit /b 0',
175
- '"%SystemRoot%\\System32\\timeout.exe" /t 5 /nobreak >nul 2>&1',
176
- "goto restart",
177
- "",
178
- ].join("\r\n");
179
- }
180
-
181
- export function windowsTaskAction(launcherPath) {
182
- const action = String(launcherPath);
183
- if (action.includes("\0") || /[\r\n]/.test(action)) throw new Error("Windows autostart launcher path contains a prohibited control character");
184
- if (!path.isAbsolute(action) && !path.win32.isAbsolute(action)) throw new Error("Windows autostart launcher path must be absolute");
185
- if (action.includes("%")) throw new Error("Windows autostart launcher path must not contain a percent sign because Task Scheduler may expand it as an environment variable");
186
- if (action.length > WINDOWS_TASK_COMMAND_MAX_CHARS) {
187
- throw new Error(`Windows autostart action exceeds the ${WINDOWS_TASK_COMMAND_MAX_CHARS}-character Task Scheduler limit; use the default state directory or a shorter --state-dir`);
188
- }
189
- return action;
190
- }
191
-
192
- export function windowsCommandLineArgument(value) {
193
- const text = String(value);
194
- if (text.includes("\0")) throw new Error("Windows command-line argument contains a NUL byte");
195
- if (/[\r\n]/.test(text)) throw new Error("Windows command-line argument contains a line break");
196
- const escapedQuotes = text.replace(/(\\*)"/g, (_match, slashes) => `${slashes}${slashes}\\"`);
197
- const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, slashes => `${slashes}${slashes}`);
198
- return `"${escapedTrailingSlashes}"`;
199
- }
200
-
201
- export function windowsBatchArgument(value) {
202
- return windowsCommandLineArgument(value).replaceAll("%", "%%");
203
- }
204
-
205
156
  function completedSince(before, after) {
206
157
  return after?.installed === true
207
158
  && after.active === false
@@ -7,6 +7,7 @@ export interface DaemonAttachment {
7
7
  connectedAt: string;
8
8
  lastSeenAt?: string;
9
9
  probeId?: string;
10
+ instanceId?: string;
10
11
  policy?: DaemonPolicy;
11
12
  tools?: string[];
12
13
  }
@@ -29,6 +30,7 @@ export class DaemonSocketRegistry {
29
30
  connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
30
31
  lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
31
32
  probeId: sanitizeProbeId(candidate.probeId),
33
+ instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
32
34
  policy,
33
35
  tools: sanitizeDaemonTools(candidate.tools, policy),
34
36
  };
@@ -54,12 +56,13 @@ export class DaemonSocketRegistry {
54
56
  socket.serializeAttachment({ role: "candidate", connectedAt } satisfies DaemonAttachment);
55
57
  }
56
58
 
57
- beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; policy: DaemonPolicy; tools: string[] }): void {
59
+ beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
58
60
  socket.serializeAttachment({
59
61
  role: "probing",
60
62
  connectedAt: values.connectedAt,
61
63
  lastSeenAt: values.connectedAt,
62
64
  probeId: values.probeId,
65
+ instanceId: values.instanceId,
63
66
  policy: values.policy,
64
67
  tools: values.tools,
65
68
  } satisfies DaemonAttachment);
@@ -89,6 +92,7 @@ export class DaemonSocketRegistry {
89
92
  role: "expired",
90
93
  connectedAt: attachment.connectedAt,
91
94
  lastSeenAt: attachment.lastSeenAt,
95
+ instanceId: attachment.instanceId,
92
96
  } satisfies DaemonAttachment);
93
97
  }
94
98
 
@@ -105,3 +109,8 @@ function sanitizeProbeId(value: unknown): string | undefined {
105
109
  if (typeof value !== "string" || !/^probe_[A-Za-z0-9_-]{8,240}$/.test(value)) return undefined;
106
110
  return value;
107
111
  }
112
+
113
+ function sanitizeDaemonInstanceId(value: unknown): string | undefined {
114
+ if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return undefined;
115
+ return value;
116
+ }