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
@@ -1,12 +1,11 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
3
- import { lstat, realpath, rm, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, realpath, stat } from "node:fs/promises";
4
3
  import { tmpdir } from "node:os";
5
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import { RelayConnection } from "./relay-connection.mjs";
7
6
  import { ProcessSessionManager } from "./process-sessions.mjs";
8
7
  export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
9
- import { allToolNames, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, PolicyGate, SERVER_NAME } from "./tools.mjs";
8
+ import { MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, PolicyGate, SERVER_NAME } from "./tools.mjs";
10
9
  import { publicError } from "./errors.mjs";
11
10
  import { ProcessTracker } from "./process-tracker.mjs";
12
11
  import { CallRegistry } from "./call-registry.mjs";
@@ -15,7 +14,7 @@ import { ToolExecutor } from "./tool-executor.mjs";
15
14
  import { boundedErrorMessage, ProcessExecutionService } from "./process-execution.mjs";
16
15
  import { GitService } from "./git-service.mjs";
17
16
  import { LifecycleController } from "./lifecycle.mjs";
18
- import { MAX_WRITE_BYTES, readBoundedFile, sha256, WorkspaceFileService } from "./workspace-file-service.mjs";
17
+ import { MAX_WRITE_BYTES, sha256, WorkspaceFileService } from "./workspace-file-service.mjs";
19
18
  export { MAX_WRITE_BYTES, sha256 } from "./workspace-file-service.mjs";
20
19
  import { classifyOperationalError } from "./log.mjs";
21
20
  import { inspectResourceFile, ManagedJobManager } from "./managed-jobs.mjs";
@@ -29,6 +28,12 @@ import { readBoundedRegularFileSync } from "./secure-file.mjs";
29
28
  import { clampInteger } from "./numbers.mjs";
30
29
  import { isPlainRecord } from "./records.mjs";
31
30
  import { AccountAccessGate, normalizeAccountRole } from "./account-access.mjs";
31
+ import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs";
32
+ import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
33
+ import {
34
+ resolveTaskCapabilities as resolveRuntimeTaskCapabilities,
35
+ sessionBootstrap as buildRuntimeSessionBootstrap,
36
+ } from "./runtime-capabilities.mjs";
32
37
 
33
38
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
34
39
  const MAX_CONCURRENT_TOOL_CALLS = 16;
@@ -232,53 +237,21 @@ export class LocalRuntime {
232
237
  }
233
238
 
234
239
  runtimeInfo() {
235
- return {
236
- name: SERVER_NAME,
237
- protocol_version: MCP_PROTOCOL_VERSION,
238
- supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
239
- workspace: this.displayPath(this.workspace),
240
- workspace_name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
240
+ return buildRuntimeInfo({
241
+ workspace: this.workspace,
242
+ displayPath: (value) => this.displayPath(value),
241
243
  policy: this.policy,
242
- policy_contract: {
243
- named_profile_is_canonical: this.policy.profile === "custom" || policyMatchesNamedProfile(this.policy),
244
- full_catalog_complete: this.policy.profile === "full" ? isCanonicalFullPolicy(this.policy) && this.tools().length + 1 === allToolNames().length : null,
245
- machine_bridge_internal_denials_under_full: this.policy.profile === "full" && isCanonicalFullPolicy(this.policy) ? false : null,
246
- },
247
- enforcement: {
248
- filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
249
- sensitive_filename_filter: false,
250
- operating_system_permissions_apply: true,
251
- host_policy_is_independent: true,
252
- },
253
- tool_delivery: {
254
- full_profile_scope: "local-daemon-and-relay-advertisement",
255
- daemon_advertised_tool_count: this.tools().length + 1,
256
- host_exposed_tools_known_to_server: false,
257
- host_may_expose_subset: true,
258
- },
259
- tools: ["server_info", ...this.tools()],
260
- observability: {
261
- relay_readiness: "authenticated-hello-acknowledged",
262
- brief_relay_interruptions: "debug-only",
263
- raw_transport_details: "debug-only",
264
- per_tool_events: "structured-debug-events",
265
- default_logs_include_tool_failures: false,
266
- tool_arguments_or_results_logged: false,
267
- capability_routing: this.capabilityObserver.snapshot(),
268
- tool_calls: this.observability.snapshot(),
269
- in_flight_calls: this.callRegistry.snapshot(),
270
- },
271
- runtime: {
272
- environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
273
- lifecycle: this.lifecycle.snapshot(),
274
- relay: this.relay?.status?.() || null,
275
- runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
276
- processes: this.processTracker.snapshot(),
277
- process_sessions: this.processSessionManager.status(),
278
- managed_jobs: this.managedJobManager.status(),
279
- local_resources: this.managedJobManager.resourceInfo(),
280
- },
281
- };
244
+ toolNames: this.tools(),
245
+ capabilityObserver: this.capabilityObserver,
246
+ observability: this.observability,
247
+ callRegistry: this.callRegistry,
248
+ lifecycle: this.lifecycle,
249
+ relayStatus: () => this.relay?.status?.() || null,
250
+ runtimeDir: this.runtimeDir,
251
+ processTracker: this.processTracker,
252
+ processSessionManager: this.processSessionManager,
253
+ managedJobManager: this.managedJobManager,
254
+ });
282
255
  }
283
256
 
284
257
  async start() {
@@ -426,18 +399,17 @@ export class LocalRuntime {
426
399
  }
427
400
 
428
401
  async projectOverview(context = {}) {
429
- this.throwIfCancelled(context);
430
- const top = await this.listDir(".", context).catch(error => ({ error: this.safeErrorMessage(error), entries: [] }));
431
- const git = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
432
- return {
433
- workspace: this.displayPath(this.workspace),
434
- workspaceName: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
435
- gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
402
+ return buildProjectOverview({
403
+ workspace: this.workspace,
404
+ displayPath: (value) => this.displayPath(value),
436
405
  policy: this.policy,
437
- tools: ["server_info", ...this.tools()],
438
- capabilityRouting: this.capabilityObserver.snapshot(),
439
- topLevel: top.entries || [],
440
- };
406
+ toolNames: this.tools(),
407
+ capabilityObserver: this.capabilityObserver,
408
+ listTopLevel: (callContext) => this.listDir(".", callContext),
409
+ runProcess: (...args) => this.runProcess(...args),
410
+ safeErrorMessage: (error) => this.safeErrorMessage(error),
411
+ throwIfCancelled: (callContext) => this.throwIfCancelled(callContext),
412
+ }, context);
441
413
  }
442
414
 
443
415
  listRoots() { return this.workspaceFileService.listRoots(); }
@@ -475,82 +447,15 @@ export class LocalRuntime {
475
447
  }
476
448
 
477
449
  async diagnoseRuntime(context = {}) {
478
- this.throwIfCancelled(context);
479
- const checks = [];
480
- checks.push({
481
- layer: "mcp-host-to-daemon",
482
- ok: true,
483
- detail: "This diagnostic request reached the local Machine Bridge runtime.",
484
- });
485
- checks.push({
486
- layer: "machine-bridge-policy",
487
- ok: this.policy.execMode === "direct" || this.policy.execMode === "shell",
488
- detail: `profile=${this.policy.profile}; exec_mode=${this.policy.execMode}; unrestricted_paths=${this.policy.unrestrictedPaths}`,
489
- });
490
-
491
- const probe = join(this.runtimeDir, `.diagnostic-${process.pid}-${randomBytes(6).toString("hex")}`);
492
- try {
493
- await writeFile(probe, "ok\n", { mode: 0o600, flag: "wx" });
494
- const { buffer } = await readBoundedFile(probe, 64, "diagnostic file");
495
- checks.push({ layer: "local-filesystem", ok: buffer.toString("utf8") === "ok\n", error_class: null });
496
- } catch (error) {
497
- checks.push({ layer: "local-filesystem", ok: false, error_class: classifyOperationalError(error) });
498
- } finally {
499
- await rm(probe, { force: true }).catch(() => {});
500
- }
501
-
502
- if (this.policy.execMode === "direct" || this.policy.execMode === "shell") {
503
- const direct = await this.runProcess(
504
- process.execPath,
505
- ["-e", "process.stdout.write('ok')"],
506
- 5000,
507
- true,
508
- 1024,
509
- context,
510
- this.workspace,
511
- ).catch((error) => ({ code: 127, stdout: "", stderr: "", error_class: classifyOperationalError(error) }));
512
- checks.push({
513
- layer: "local-process-spawn",
514
- ok: direct.code === 0 && direct.stdout === "ok",
515
- error_class: direct.error_class || (direct.code === 0 ? null : classifyOperationalError(direct.stderr || direct.stdout || "execution failed")),
516
- });
517
- } else {
518
- checks.push({ layer: "local-process-spawn", ok: false, skipped: true, error_class: "policy_denied" });
519
- }
520
-
521
- if (this.policy.execMode === "shell") {
522
- const result = await this.processExecutionService.probeShell(context)
523
- .catch((error) => ({ code: 127, error_class: classifyOperationalError(error) }));
524
- checks.push({
525
- layer: "local-shell",
526
- ok: result.code === 0,
527
- error_class: result.error_class || (result.code === 0 ? null : classifyOperationalError(result.stderr || result.stdout || "execution failed")),
528
- });
529
- } else {
530
- checks.push({ layer: "local-shell", ok: false, skipped: true, error_class: "policy_denied" });
531
- }
532
-
533
- const storage = this.managedJobManager.diagnoseStorage();
534
- checks.push({ layer: "managed-job-storage", ...storage });
535
- const resources = this.managedJobManager.listResources();
536
- checks.push({
537
- layer: "local-resource-registry",
538
- ok: resources.resources.every((resource) => resource.available),
539
- registered: resources.count,
540
- unavailable: resources.resources.filter((resource) => !resource.available).map((resource) => ({ name: resource.name, error_class: resource.error_class })),
541
- });
542
-
543
- return {
544
- request_reached_local_runtime: true,
545
- interpretation: {
546
- tool_call_blocked_before_response: "host/platform or connector gateway",
547
- diagnostic_reached_daemon_but_spawn_failed: "local OS, endpoint security, shell configuration, or Machine Bridge policy",
548
- managed_job_accepted_then_later_tools_blocked: "job continues independently; inspect with local CLI or a later read_job call",
549
- },
450
+ return runRuntimeDiagnostics({
550
451
  policy: this.policy,
551
- checks,
552
- ok: checks.filter((check) => !check.skipped).every((check) => check.ok),
553
- };
452
+ runtimeDir: this.runtimeDir,
453
+ workspace: this.workspace,
454
+ runProcess: (...args) => this.runProcess(...args),
455
+ probeShell: (callContext) => this.processExecutionService.probeShell(callContext),
456
+ managedJobManager: this.managedJobManager,
457
+ throwIfCancelled: (callContext) => this.throwIfCancelled(callContext),
458
+ }, context);
554
459
  }
555
460
 
556
461
  async generateSshKeyResource(args = {}, context = {}) {
@@ -588,43 +493,22 @@ export class LocalRuntime {
588
493
  };
589
494
  }
590
495
 
591
-
592
496
  async sessionBootstrap(args = {}, context = {}) {
593
- const bootstrap = await this.agentContextManager.sessionBootstrap(args, context);
594
- bootstrap.local_automation = {
595
- applications: this.appAutomationManager.capabilities(),
596
- browser: this.policy.profile === "full" ? {
597
- existing_profile: true,
598
- extension_bridge: true,
599
- status_tool: "browser_status",
600
- } : null,
601
- };
602
- this.capabilityObserver.recordBootstrap(bootstrap);
603
- return bootstrap;
497
+ return buildRuntimeSessionBootstrap({
498
+ agentContextManager: this.agentContextManager,
499
+ appAutomationManager: this.appAutomationManager,
500
+ capabilityObserver: this.capabilityObserver,
501
+ policy: this.policy,
502
+ }, args, context);
604
503
  }
605
504
 
606
505
  async resolveTaskCapabilities(args = {}, context = {}) {
607
- const result = await this.agentContextManager.resolveTaskCapabilities(args, context);
608
- const task = String(args.task || "");
609
- if (this.policy.profile === "full") {
610
- const applications = await this.appAutomationManager.listApplications({ query: "", max_results: 500 }, context).catch(() => ({ applications: [] }));
611
- const lower = task.toLowerCase();
612
- result.application_matches = applications.applications
613
- .map((application) => ({ application, score: applicationMatchScore(lower, application) }))
614
- .filter((item) => item.score > 0)
615
- .sort((left, right) => right.score - left.score || left.application.name.localeCompare(right.application.name))
616
- .slice(0, 20)
617
- .map(({ application, score }) => ({ ...application, score }));
618
- } else {
619
- result.application_matches = [];
620
- }
621
- if (result.application_matches.length) {
622
- result.recommended_tools = [...new Set([...result.recommended_tools, "list_local_applications", "open_local_application", "inspect_local_application", "operate_local_application"])];
623
- }
624
- result.browser_backend = this.policy.profile === "full" ? { tool: "browser_status", existing_profile: true, extension_bridge: true } : null;
625
- result.routing_observability = "Call server_info or project_overview to verify that bootstrap and task capability resolution reached the local runtime.";
626
- this.capabilityObserver.recordResolution(task, result);
627
- return result;
506
+ return resolveRuntimeTaskCapabilities({
507
+ agentContextManager: this.agentContextManager,
508
+ appAutomationManager: this.appAutomationManager,
509
+ capabilityObserver: this.capabilityObserver,
510
+ policy: this.policy,
511
+ }, args, context);
628
512
  }
629
513
 
630
514
  readLocalResourceBinary(name) {
@@ -749,25 +633,6 @@ export class LocalRuntime {
749
633
  }
750
634
  }
751
635
 
752
- function applicationMatchScore(task, application) {
753
- const name = String(application.name || "").toLowerCase();
754
- const id = String(application.id || "").toLowerCase();
755
- if (!name) return 0;
756
- if (task.includes(name)) return 10 + Math.min(name.length, 20);
757
- const words = name.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length >= 2);
758
- return words.reduce((score, word) => score + (task.includes(word) ? 2 : 0), id && task.includes(id) ? 5 : 0);
759
- }
760
-
761
- function policyMatchesNamedProfile(policy) {
762
- const named = POLICY_PROFILES[policy.profile];
763
- if (!named) return false;
764
- return policy.allowWrite === named.allowWrite
765
- && policy.execMode === named.execMode
766
- && policy.unrestrictedPaths === named.unrestrictedPaths
767
- && policy.minimalEnv === named.minimalEnv
768
- && policy.exposeAbsolutePaths === named.exposeAbsolutePaths;
769
- }
770
-
771
636
  function stateRootFromProfileStatePath(statePath) {
772
637
  const absolute = resolve(statePath);
773
638
  if (basename(absolute) !== "state.json") throw new Error("local resource state path is invalid");
@@ -854,8 +719,6 @@ function normalizeRelayAuthorization(value) {
854
719
  return Object.freeze({ account_id: accountId, account_version: accountVersion, role });
855
720
  }
856
721
 
857
-
858
-
859
722
  function collectToolPathCandidates(error, toolArgs, workspace) {
860
723
  const candidates = new Set();
861
724
  for (const value of [error?.path, error?.dest]) if (typeof value === "string" && value) candidates.add(value);
@@ -16,5 +16,5 @@ export async function waitForInactiveStatus(
16
16
  }
17
17
 
18
18
  function delay(milliseconds) {
19
- return new Promise(resolve => setTimeout(resolve, milliseconds));
19
+ return new Promise(resolve => { setTimeout(resolve, milliseconds); });
20
20
  }
@@ -0,0 +1,129 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
4
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
5
+
6
+ const SERVICE_ENVIRONMENT_SCHEMA = 1;
7
+ const MAX_SERVICE_ENVIRONMENT_BYTES = 64 * 1024;
8
+ const MAX_ENVIRONMENT_VALUE_BYTES = 16 * 1024;
9
+ const SERVICE_ENVIRONMENT_FILE = "service-environment.json";
10
+
11
+ export const SERVICE_NETWORK_ENVIRONMENT_KEYS = Object.freeze([
12
+ "HTTP_PROXY",
13
+ "HTTPS_PROXY",
14
+ "NO_PROXY",
15
+ "ALL_PROXY",
16
+ "http_proxy",
17
+ "https_proxy",
18
+ "no_proxy",
19
+ "all_proxy",
20
+ "NODE_USE_ENV_PROXY",
21
+ "NODE_EXTRA_CA_CERTS",
22
+ "SSL_CERT_FILE",
23
+ "SSL_CERT_DIR",
24
+ ]);
25
+
26
+ export function serviceEnvironmentPath(stateRoot) {
27
+ return path.join(path.resolve(String(stateRoot)), SERVICE_ENVIRONMENT_FILE);
28
+ }
29
+
30
+ export function captureServiceEnvironment(environment = process.env) {
31
+ const captured = {};
32
+ for (const key of SERVICE_NETWORK_ENVIRONMENT_KEYS) {
33
+ if (!Object.prototype.hasOwnProperty.call(environment, key)) continue;
34
+ const value = environment[key];
35
+ if (value === undefined || value === null) continue;
36
+ captured[key] = validateEnvironmentValue(key, value);
37
+ }
38
+ return captured;
39
+ }
40
+
41
+ export function writeServiceEnvironment(stateRoot, environment = process.env) {
42
+ const file = serviceEnvironmentPath(stateRoot);
43
+ const previous = existsSync(file) ? parseServiceEnvironment(file).environment : {};
44
+ const captured = captureServiceEnvironment(environment);
45
+ const merged = { ...previous };
46
+ for (const [key, value] of Object.entries(captured)) {
47
+ for (const existing of Object.keys(merged)) {
48
+ if (existing.toLowerCase() === key.toLowerCase()) delete merged[existing];
49
+ }
50
+ merged[key] = value;
51
+ }
52
+ const payload = {
53
+ schemaVersion: SERVICE_ENVIRONMENT_SCHEMA,
54
+ environment: merged,
55
+ updatedAt: new Date().toISOString(),
56
+ };
57
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
58
+ if (Buffer.byteLength(content) > MAX_SERVICE_ENVIRONMENT_BYTES) {
59
+ throw new Error("service network environment exceeds the persistence limit");
60
+ }
61
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
62
+ return { path: file, keys: Object.keys(payload.environment).sort() };
63
+ }
64
+
65
+ export function loadServiceEnvironment(stateRoot, targetEnvironment = process.env, options = {}) {
66
+ const file = serviceEnvironmentPath(stateRoot);
67
+ if (!existsSync(file)) return { path: file, keys: [], loaded: false };
68
+ const payload = parseServiceEnvironment(file);
69
+ const platform = String(options.platform || process.platform);
70
+ const loaded = [];
71
+ for (const [key, rawValue] of Object.entries(payload.environment)) {
72
+ if (!SERVICE_NETWORK_ENVIRONMENT_KEYS.includes(key)) continue;
73
+ if (hasEnvironmentKey(targetEnvironment, key, platform)) continue;
74
+ targetEnvironment[key] = validateEnvironmentValue(key, rawValue);
75
+ loaded.push(key);
76
+ }
77
+ return { path: file, keys: loaded.sort(), loaded: true };
78
+ }
79
+
80
+ export function serviceEnvironmentSummary(stateRoot) {
81
+ const file = serviceEnvironmentPath(stateRoot);
82
+ if (!existsSync(file)) return { configured: false, keys: [] };
83
+ const payload = parseServiceEnvironment(file);
84
+ return { configured: true, keys: Object.keys(payload.environment).sort() };
85
+ }
86
+
87
+ function parseServiceEnvironment(file) {
88
+ const content = readBoundedRegularFileSync(file, MAX_SERVICE_ENVIRONMENT_BYTES, "service environment file");
89
+ let payload;
90
+ try {
91
+ payload = JSON.parse(content.toString("utf8"));
92
+ } catch (error) {
93
+ throw new Error("service environment file is not valid JSON", { cause: error });
94
+ }
95
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
96
+ throw new Error("service environment file must contain an object");
97
+ }
98
+ if (payload.schemaVersion !== SERVICE_ENVIRONMENT_SCHEMA) {
99
+ throw new Error("service environment schema is obsolete; reinstall autostart");
100
+ }
101
+ if (!payload.environment || typeof payload.environment !== "object" || Array.isArray(payload.environment)) {
102
+ throw new Error("service environment file has an invalid environment map");
103
+ }
104
+ for (const [key, value] of Object.entries(payload.environment)) {
105
+ if (!SERVICE_NETWORK_ENVIRONMENT_KEYS.includes(key)) {
106
+ throw new Error("service environment file contains an unsupported key");
107
+ }
108
+ validateEnvironmentValue(key, value);
109
+ }
110
+ return payload;
111
+ }
112
+
113
+ function validateEnvironmentValue(key, value) {
114
+ const text = String(value);
115
+ if (text.includes("\0") || /[\r\n]/.test(text)) {
116
+ throw new Error(`service environment value ${key} contains a prohibited control character`);
117
+ }
118
+ if (Buffer.byteLength(text) > MAX_ENVIRONMENT_VALUE_BYTES) {
119
+ throw new Error(`service environment value ${key} exceeds the size limit`);
120
+ }
121
+ return text;
122
+ }
123
+
124
+ function hasEnvironmentKey(environment, key, platform) {
125
+ if (Object.prototype.hasOwnProperty.call(environment, key)) return true;
126
+ if (platform !== "win32") return false;
127
+ const wanted = key.toLowerCase();
128
+ return Object.keys(environment).some(existing => existing.toLowerCase() === wanted);
129
+ }
@@ -6,9 +6,17 @@ import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
6
6
  import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
7
  import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
8
8
  import { waitForInactiveStatus } from "./service-convergence.mjs";
9
+ import { writeServiceEnvironment } from "./service-environment.mjs";
10
+ import {
11
+ installWindowsTask,
12
+ startWindowsTask,
13
+ statusWindowsTask,
14
+ stopWindowsTask,
15
+ uninstallWindowsTask,
16
+ } from "./windows-service.mjs";
17
+ export { windowsCommandLineArgument } from "./windows-service.mjs";
9
18
 
10
19
  const LABEL = "dev.machine-bridge-mcp.daemon";
11
- const WINDOWS_TASK = "MachineBridgeMCP";
12
20
  const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
13
21
  const AUTOSTART_LOG_SCHEMA_VERSION = 3;
14
22
 
@@ -26,41 +34,38 @@ export function runServiceCommand(command, args, execute = runExecutable) {
26
34
 
27
35
  export async function installAutostart({ workspace, stateRoot, entryScript, logger = console }) {
28
36
  const spec = serviceSpec({ workspace, stateRoot, entryScript });
29
- if (process.platform === "darwin") return installLaunchd(spec, logger);
30
- if (process.platform === "win32") return installWindowsTask(spec, logger);
31
- return installSystemd(spec, logger);
37
+ const serviceEnvironment = writeServiceEnvironment(spec.stateRoot);
38
+ let result;
39
+ if (process.platform === "darwin") result = await installLaunchd(spec, logger);
40
+ else if (process.platform === "win32") result = await installWindowsTask(spec, logger, { run: serviceRun });
41
+ else result = await installSystemd(spec, logger);
42
+ return { ...result, service_environment: serviceEnvironment };
32
43
  }
33
44
 
34
45
  export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
35
46
  if (process.platform === "darwin") return uninstallLaunchd(logger);
36
- if (process.platform === "win32") return uninstallWindowsTask(logger);
47
+ if (process.platform === "win32") return uninstallWindowsTask(logger, { run: serviceRun, stateRoot });
37
48
  return uninstallSystemd(logger);
38
49
  }
39
50
 
40
- export async function autostartStatus({ logger = console } = {}) {
51
+ export async function autostartStatus() {
41
52
  if (process.platform === "darwin") return statusLaunchd();
42
- if (process.platform === "win32") return statusWindowsTask();
53
+ if (process.platform === "win32") return statusWindowsTask({ run: serviceRun });
43
54
  return statusSystemd();
44
55
  }
45
56
 
46
57
  export async function startAutostart({ logger = console } = {}) {
47
58
  if (process.platform === "darwin") return startLaunchd(logger);
48
- if (process.platform === "win32") {
49
- return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]));
50
- }
59
+ if (process.platform === "win32") return startWindowsTask(logger, { run: serviceRun });
51
60
  return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]));
52
61
  }
53
62
 
54
63
  export async function stopAutostart({ logger = console } = {}) {
55
64
  if (process.platform === "darwin") return stopLaunchd(logger);
56
- if (process.platform === "win32") {
57
- return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]), { allowAlreadyStopped: true });
58
- }
65
+ if (process.platform === "win32") return stopWindowsTask(logger, { run: serviceRun });
59
66
  return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]), { allowAlreadyStopped: true });
60
67
  }
61
68
 
62
-
63
-
64
69
  export function normalizeServiceCommandResult(provider, result, { allowAlreadyStopped = false } = {}) {
65
70
  const detail = `${result?.stdout || ""}
66
71
  ${result?.stderr || ""}`;
@@ -136,7 +141,7 @@ function serviceSpec({ workspace, stateRoot, entryScript }) {
136
141
  ensureOwnerOnlyDir(root);
137
142
  ensureOwnerOnlyDir(logs);
138
143
  for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) ensurePrivateLogFile(file);
139
- return {
144
+ const spec = {
140
145
  workspace,
141
146
  stateRoot: root,
142
147
  entryScript: resolvedEntryScript,
@@ -145,6 +150,7 @@ function serviceSpec({ workspace, stateRoot, entryScript }) {
145
150
  stdout: path.join(logs, "daemon.out.log"),
146
151
  stderr: path.join(logs, "daemon.err.log"),
147
152
  };
153
+ return { ...spec, daemonArgs: daemonArgs(spec) };
148
154
  }
149
155
 
150
156
  export function stableNodeExecutable(options = {}) {
@@ -413,43 +419,6 @@ WantedBy=default.target
413
419
  `;
414
420
  }
415
421
 
416
- async function installWindowsTask(spec, logger) {
417
- const command = windowsCommand(spec);
418
- const result = await serviceRun("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"]);
419
- const ok = result.code === 0;
420
- if (ok) logger.info?.("Windows Scheduled Task installed for logon");
421
- else logger.warn?.("Windows Scheduled Task installation failed");
422
- return { ok, provider: "schtasks", task: WINDOWS_TASK, result };
423
- }
424
-
425
- async function uninstallWindowsTask(logger) {
426
- const end = await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
427
- const result = await serviceRun("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
428
- const missing = /cannot find|not exist|not found/i.test(`${result.stdout}
429
- ${result.stderr}`);
430
- const ok = result.code === 0 || missing;
431
- if (ok) logger.info?.("Windows Scheduled Task removed");
432
- else logger.warn?.("Windows Scheduled Task removal failed");
433
- return { ok, provider: "schtasks", task: WINDOWS_TASK, end, result };
434
- }
435
-
436
- async function statusWindowsTask() {
437
- const result = await serviceRun("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "LIST"]);
438
- return { ok: result.code === 0, provider: "schtasks", installed: result.code === 0, task: WINDOWS_TASK, active: result.code === 0, detail: result.stdout || result.stderr };
439
- }
440
-
441
- function windowsCommand(spec) {
442
- return [spec.node, ...daemonArgs(spec)].map(windowsCommandLineArgument).join(" ");
443
- }
444
-
445
- export function windowsCommandLineArgument(value) {
446
- const text = String(value);
447
- if (text.includes("\0")) throw new Error("Windows command-line argument contains a NUL byte");
448
- const escapedQuotes = text.replace(/(\\*)"/g, (_match, slashes) => `${slashes}${slashes}\\"`);
449
- const escapedTrailingSlashes = escapedQuotes.replace(/(\\+)$/, (slashes) => `${slashes}${slashes}`);
450
- return `"${escapedTrailingSlashes}"`;
451
- }
452
-
453
422
  export function systemdQuote(value) {
454
423
  const escaped = String(value)
455
424
  .replaceAll("\\", "\\\\")
@@ -279,7 +279,7 @@ export async function acquireStartupLockWithWait(state, options = {}) {
279
279
  logger.info?.(`waiting for ${ownerPid} to finish the current startup/state operation`);
280
280
  const deadline = createMonotonicDeadline(timeoutMs);
281
281
  while (!deadline.expired()) {
282
- await new Promise((resolvePromise) => setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline.remainingMs()))));
282
+ await new Promise((resolvePromise) => { setTimeout(resolvePromise, Math.min(pollMs, Math.max(1, deadline.remainingMs()))); });
283
283
  lock = acquireStartupLock(state, metadata);
284
284
  if (lock.acquired) {
285
285
  logger.info?.("the previous startup/state operation finished; continuing");
@@ -497,7 +497,7 @@ function assertValidStateMarker(marker) {
497
497
  }
498
498
 
499
499
  function hasOnlyStateEntries(entries) {
500
- const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "maintenance.lock", "profiles", "logs"]);
500
+ const allowed = new Set([STATE_MARKER, "config.json", "browser-bridge.json", "maintenance.lock", "profiles", "logs", "service-environment.json", "service-launcher.cmd"]);
501
501
  return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
502
502
  }
503
503