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.
- package/CHANGELOG.md +21 -0
- package/CODE_OF_CONDUCT.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/GOVERNANCE.md +50 -0
- package/README.md +30 -2
- package/SECURITY.md +1 -1
- package/SUPPORT.md +31 -0
- package/browser-extension/browser-operations.js +1 -1
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +1 -1
- package/docs/ARCHITECTURE.md +17 -18
- package/docs/AUDIT.md +28 -0
- package/docs/ENGINEERING.md +5 -2
- package/docs/LOGGING.md +2 -0
- package/docs/OPERATIONS.md +7 -3
- package/docs/PROJECT_STANDARDS.md +3 -2
- package/docs/TESTING.md +7 -4
- package/docs/UPGRADING.md +30 -0
- package/package.json +15 -7
- package/scripts/coverage-check.mjs +24 -9
- package/src/local/agent-context.mjs +7 -148
- package/src/local/agent-contract.mjs +206 -0
- package/src/local/browser-bridge.mjs +30 -281
- package/src/local/browser-extension-protocol.mjs +19 -4
- package/src/local/browser-operation-service.mjs +325 -0
- package/src/local/call-registry.mjs +44 -1
- package/src/local/capability-ranking.mjs +13 -0
- package/src/local/cli-account-admin.mjs +1 -1
- package/src/local/cli.mjs +16 -7
- package/src/local/daemon-process.mjs +1 -1
- package/src/local/full-access-test.mjs +1 -1
- package/src/local/job-runner.mjs +9 -3
- package/src/local/monotonic-deadline.mjs +7 -0
- package/src/local/numbers.mjs +8 -0
- package/src/local/policy.mjs +88 -12
- package/src/local/project-metadata.mjs +7 -1
- package/src/local/records.mjs +6 -0
- package/src/local/runtime-capabilities.mjs +66 -0
- package/src/local/runtime-diagnostics.mjs +91 -0
- package/src/local/runtime-reporting.mjs +113 -0
- package/src/local/runtime.mjs +53 -190
- package/src/local/service-convergence.mjs +1 -1
- package/src/local/service-environment.mjs +129 -0
- package/src/local/service.mjs +22 -53
- package/src/local/state.mjs +2 -2
- package/src/local/windows-service.mjs +248 -0
- package/src/local/worker-health.mjs +1 -1
- package/src/worker/access.ts +4 -4
- package/src/worker/account-admin.ts +2 -2
- package/src/worker/authority.ts +3 -3
- package/src/worker/index.ts +29 -337
- package/src/worker/oauth-controller.ts +334 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/oauth-tokens.ts +2 -2
- package/src/worker/tool-catalog.ts +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.local.json +27 -0
package/src/local/runtime.mjs
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
3
|
-
import { lstat, realpath,
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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);
|
|
@@ -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
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
-
|
|
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("\\", "\\\\")
|
package/src/local/state.mjs
CHANGED
|
@@ -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
|
|