machine-bridge-mcp 3.0.0-beta.21 → 3.0.0-beta.26

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 (102) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/GOVERNANCE.md +2 -2
  4. package/README.md +24 -6
  5. package/browser-extension/manifest.json +1 -1
  6. package/docs/AGENT_CONTEXT.md +10 -7
  7. package/docs/ARCHITECTURE.md +35 -22
  8. package/docs/AUDIT.md +85 -1
  9. package/docs/CLIENTS.md +6 -2
  10. package/docs/ENGINEERING.md +31 -9
  11. package/docs/LOCAL_AUTOMATION.md +4 -2
  12. package/docs/LOGGING.md +8 -8
  13. package/docs/OPERATIONS.md +43 -17
  14. package/docs/PRIVACY.md +18 -4
  15. package/docs/PROJECT_STANDARDS.md +2 -2
  16. package/docs/RELEASING.md +35 -11
  17. package/docs/TESTING.md +36 -16
  18. package/docs/THREAT_MODEL.md +20 -5
  19. package/docs/TOOL_REFERENCE.md +18 -12
  20. package/docs/UPGRADING.md +32 -0
  21. package/package.json +15 -6
  22. package/scripts/check-plan.mjs +8 -0
  23. package/scripts/coverage-check.mjs +30 -1
  24. package/scripts/foreground-daemon-recovery.mjs +88 -0
  25. package/scripts/github-release.mjs +22 -16
  26. package/scripts/install-published-prerelease.mjs +7 -7
  27. package/scripts/official-mcp-conformance.mjs +243 -0
  28. package/scripts/persistent-activation-process.mjs +36 -0
  29. package/scripts/release-candidate-manifest.mjs +12 -0
  30. package/scripts/release-publication-guard.mjs +65 -0
  31. package/scripts/release-state.mjs +1 -1
  32. package/scripts/sbom-check.mjs +99 -0
  33. package/scripts/start-release-candidate.mjs +39 -13
  34. package/src/local/agent-context-projection.mjs +26 -7
  35. package/src/local/agent-context.mjs +25 -4
  36. package/src/local/autostart-log-maintenance.mjs +36 -0
  37. package/src/local/capability-observer.mjs +5 -0
  38. package/src/local/child-process-settlement.mjs +103 -0
  39. package/src/local/cli-activate.mjs +42 -5
  40. package/src/local/cli-service.mjs +55 -5
  41. package/src/local/cli.mjs +59 -10
  42. package/src/local/daemon-process.mjs +24 -3
  43. package/src/local/delegated-process-sandbox.mjs +1 -0
  44. package/src/local/execution-routing.mjs +231 -0
  45. package/src/local/git-service.mjs +3 -1
  46. package/src/local/job-runner.mjs +55 -19
  47. package/src/local/macos-trust-broker.mjs +7 -0
  48. package/src/local/managed-job-runner-claim.mjs +54 -0
  49. package/src/local/managed-job-runner.mjs +13 -2
  50. package/src/local/process-execution.mjs +2 -2
  51. package/src/local/process-identity.mjs +11 -0
  52. package/src/local/process-tree-ownership-types.d.ts +37 -0
  53. package/src/local/process-tree-ownership.mjs +49 -41
  54. package/src/local/process-tree.mjs +1 -1
  55. package/src/local/relay-call-recovery.mjs +40 -21
  56. package/src/local/runtime-activation.mjs +357 -38
  57. package/src/local/runtime-capabilities.mjs +22 -6
  58. package/src/local/runtime-diagnostics.mjs +9 -2
  59. package/src/local/runtime.mjs +18 -4
  60. package/src/local/service-convergence.mjs +33 -0
  61. package/src/local/service-owner.mjs +147 -0
  62. package/src/local/service-restart-handoff.mjs +22 -8
  63. package/src/local/service-runtime.mjs +145 -0
  64. package/src/local/service.mjs +143 -25
  65. package/src/local/state.mjs +104 -7
  66. package/src/local/stdio.mjs +139 -45
  67. package/src/local/system-network-route.mjs +76 -0
  68. package/src/local/tool-executor.mjs +24 -6
  69. package/src/local/tools.mjs +6 -5
  70. package/src/local/windows-service-convergence.mjs +49 -0
  71. package/src/local/windows-service.mjs +30 -53
  72. package/src/shared/mcp-protocol.d.mts +27 -0
  73. package/src/shared/mcp-protocol.mjs +256 -0
  74. package/src/shared/mcp-subscriptions.d.mts +4 -0
  75. package/src/shared/mcp-subscriptions.mjs +59 -0
  76. package/src/shared/relay-contract.json +1 -0
  77. package/src/shared/result-projection.d.mts +2 -1
  78. package/src/shared/result-projection.mjs +13 -2
  79. package/src/shared/server-metadata.json +11 -4
  80. package/src/shared/tool-argument-validation.d.mts +17 -0
  81. package/src/shared/tool-argument-validation.mjs +325 -0
  82. package/src/shared/tool-catalog.json +18 -12
  83. package/src/worker/durable-stream-calls.ts +12 -24
  84. package/src/worker/http.ts +36 -2
  85. package/src/worker/index.ts +181 -165
  86. package/src/worker/mcp-http-contract.ts +276 -0
  87. package/src/worker/mcp-jsonrpc.ts +12 -6
  88. package/src/worker/mcp-legacy-dispatch.ts +104 -0
  89. package/src/worker/mcp-modern-controller.ts +199 -0
  90. package/src/worker/mcp-modern-proxy.ts +126 -0
  91. package/src/worker/mcp-modern-stream.ts +71 -0
  92. package/src/worker/mcp-session.ts +12 -3
  93. package/src/worker/mcp-stream-proxy-contract.ts +67 -0
  94. package/src/worker/mcp-stream-proxy.ts +17 -60
  95. package/src/worker/mcp-tool-call-input.ts +23 -0
  96. package/src/worker/tool-catalog.ts +29 -1
  97. package/src/worker/tool-timeout.ts +53 -12
  98. package/src/worker/worker-mcp-config.ts +23 -0
  99. package/src/worker/worker-metadata.ts +10 -1
  100. package/src/worker/worker-runtime-config.ts +19 -0
  101. package/src/worker/worker-static-routes.ts +7 -2
  102. package/tsconfig.local.json +7 -1
@@ -0,0 +1,99 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import packageJson from "../package.json" with { type: "json" };
6
+
7
+ const MAX_SBOM_BYTES = 4 * 1024 * 1024;
8
+ const SBOM_TIMEOUT_MS = 30_000;
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ export function generateAndValidateSbom(options = {}) {
12
+ const npmCli = String(options.npmCli || process.env.npm_execpath || "").trim();
13
+ if (!npmCli) throw new Error("sbom check must run through an npm lifecycle so npm_execpath is available");
14
+ const cwd = resolve(options.cwd || root);
15
+ const result = spawnSync(process.execPath, [npmCli, "sbom", "--sbom-format", "cyclonedx"], {
16
+ cwd,
17
+ env: options.env || process.env,
18
+ encoding: "utf8",
19
+ maxBuffer: MAX_SBOM_BYTES,
20
+ timeout: SBOM_TIMEOUT_MS,
21
+ killSignal: "SIGKILL",
22
+ windowsHide: true,
23
+ });
24
+ if (result.error) throw result.error;
25
+ if (result.status !== 0) throw new Error(`npm sbom failed: ${boundedText(result.stderr || result.stdout)}`);
26
+ if (Buffer.byteLength(result.stdout) > MAX_SBOM_BYTES) throw new Error("npm sbom output exceeds the fixed byte budget");
27
+ let document;
28
+ try { document = JSON.parse(result.stdout); }
29
+ catch { throw new Error("npm sbom did not return valid JSON"); }
30
+ return validateCycloneDxSbom(document, {
31
+ packageName: options.packageName || packageJson.name,
32
+ packageVersion: options.packageVersion || packageJson.version,
33
+ forbiddenPaths: options.forbiddenPaths || [cwd, homedir()],
34
+ });
35
+ }
36
+
37
+ export function validateCycloneDxSbom(document, options = {}) {
38
+ if (!isRecord(document)) throw new Error("SBOM root must be an object");
39
+ if (document.bomFormat !== "CycloneDX" || document.specVersion !== "1.5") {
40
+ throw new Error("SBOM must be CycloneDX 1.5");
41
+ }
42
+ const component = isRecord(document.metadata) && isRecord(document.metadata.component)
43
+ ? document.metadata.component
44
+ : null;
45
+ const packageName = String(options.packageName || "");
46
+ const packageVersion = String(options.packageVersion || "");
47
+ if (!component || component.name !== packageName || component.version !== packageVersion) {
48
+ throw new Error("SBOM metadata component does not match the current package identity");
49
+ }
50
+ if (!Array.isArray(document.components) || document.components.length < 1 || document.components.length > 10_000) {
51
+ throw new Error("SBOM components must be a non-empty bounded array");
52
+ }
53
+ if (!Array.isArray(document.dependencies) || document.dependencies.length < 1 || document.dependencies.length > 20_000) {
54
+ throw new Error("SBOM dependencies must be a non-empty bounded array");
55
+ }
56
+ const references = new Set();
57
+ for (const item of document.components) {
58
+ if (!isRecord(item) || typeof item["bom-ref"] !== "string" || !item["bom-ref"]
59
+ || typeof item.name !== "string" || !item.name || typeof item.version !== "string" || !item.version) {
60
+ throw new Error("SBOM contains an invalid component record");
61
+ }
62
+ if (references.has(item["bom-ref"])) throw new Error("SBOM contains duplicate component references");
63
+ references.add(item["bom-ref"]);
64
+ }
65
+ const rootReference = String(component["bom-ref"] || "");
66
+ if (!rootReference || !document.dependencies.some((entry) => isRecord(entry)
67
+ && entry.ref === rootReference && Array.isArray(entry.dependsOn))) {
68
+ throw new Error("SBOM dependency graph omits the root package");
69
+ }
70
+ const serialized = JSON.stringify(document);
71
+ for (const path of options.forbiddenPaths || []) {
72
+ const candidate = String(path || "");
73
+ if (candidate && candidate.length >= 2 && serialized.includes(candidate)) {
74
+ throw new Error("SBOM contains a local filesystem path");
75
+ }
76
+ }
77
+ return Object.freeze({
78
+ bom_format: document.bomFormat,
79
+ spec_version: document.specVersion,
80
+ package: `${packageName}@${packageVersion}`,
81
+ components: document.components.length,
82
+ dependencies: document.dependencies.length,
83
+ });
84
+ }
85
+
86
+ function isRecord(value) {
87
+ return value !== null && typeof value === "object" && !Array.isArray(value);
88
+ }
89
+
90
+ function boundedText(value) {
91
+ return String(value || "").replace(/[\r\n]+/g, " ").slice(0, 1000);
92
+ }
93
+
94
+ async function main() {
95
+ const summary = generateAndValidateSbom();
96
+ process.stdout.write(`CycloneDX SBOM validated (${summary.components} components, ${summary.dependencies} dependencies)\n`);
97
+ }
98
+
99
+ if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await main();
@@ -5,13 +5,16 @@ import { createHash } from "node:crypto";
5
5
  import { existsSync, readFileSync, rmSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { defaultStateRoot, expandHome } from "../src/local/state.mjs";
8
+ import { defaultStateRoot, expandHome, selectedWorkspace } from "../src/local/state.mjs";
9
9
  import { ensureOwnerOnlyDirectorySync } from "../src/local/secure-file.mjs";
10
10
  import { createCandidateRuntimePrefix, pruneInactiveCandidateRuntimes } from "./candidate-runtime-store.mjs";
11
11
  import { writePrereleaseActivation } from "./prerelease-activation.mjs";
12
12
  import { verifyTarball } from "./release-acceptance.mjs";
13
13
  import { parseReleaseVersion } from "./release-channel.mjs";
14
- import { validateCandidateManifest } from "./release-candidate-manifest.mjs";
14
+ import { discoverForegroundDaemonRecovery } from "./foreground-daemon-recovery.mjs";
15
+ import { persistentActivationSpawnOptions, persistentCandidateFailureMessage } from "./persistent-activation-process.mjs";
16
+ import { assertCandidateMatchesCurrentSource, validateCandidateManifest } from "./release-candidate-manifest.mjs";
17
+ import { computePromotionContentDigest } from "./promotion-digest.mjs";
15
18
 
16
19
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
20
  const candidateDirectory = join(root, ".release-candidate");
@@ -22,9 +25,17 @@ const npmCli = process.env.npm_execpath;
22
25
  if (!npmCli) fail("candidate startup must run through npm so npm_execpath is available");
23
26
 
24
27
  try {
25
- const manifest = readJson(manifestPath, "release candidate manifest");
26
- validateCandidateManifest(manifest);
27
- const tarball = join(candidateDirectory, String(manifest.filename || ""));
28
+ const currentPackage = readJson(join(root, "package.json"), "current package");
29
+ const manifest = validateCandidateManifest(
30
+ readJson(manifestPath, "release candidate manifest"),
31
+ { packageName: currentPackage.name, packageVersion: currentPackage.version },
32
+ );
33
+ assertCandidateMatchesCurrentSource(manifest, {
34
+ packageName: currentPackage.name,
35
+ packageVersion: currentPackage.version,
36
+ promotionDigest: computePromotionContentDigest(root, { npmCli }),
37
+ });
38
+ const tarball = join(candidateDirectory, manifest.filename);
28
39
  verifyTarball(tarball, manifest);
29
40
 
30
41
  const npmVersion = runNpm(["--version"], root).stdout.trim();
@@ -111,15 +122,21 @@ function activatePersistentCandidate({ manifest, installedPackage, installPrefix
111
122
  const args = ["activate", ...withoutManagedFlags(forwardedArgs), "--state-dir", stateRoot, "--json"];
112
123
  const previous = currentGlobalInstallation(manifest.package_name);
113
124
  console.log("Activating the exact prerelease as the persistent login daemon. Portable-root startup does not prompt; a separately provisioned Secure Enclave broker may request one user-presence operation.");
114
- const result = spawnSync(process.execPath, [cli, ...args], {
115
- cwd: root,
116
- env: process.env,
117
- encoding: "utf8",
118
- timeout: 300_000,
119
- windowsHide: true,
120
- });
125
+ const result = spawnSync(
126
+ process.execPath,
127
+ [cli, ...args],
128
+ persistentActivationSpawnOptions({ cwd: root, env: process.env }),
129
+ );
121
130
  if (result.error) throw result.error;
122
- if (result.status !== 0) throw new Error(`persistent candidate activation failed: ${result.stderr || result.stdout}`);
131
+ if (result.status !== 0) {
132
+ const output = result.stderr || result.stdout;
133
+ const requestedWorkspace = argumentListValue("--workspace", withoutManagedFlags(forwardedArgs))
134
+ || selectedWorkspace(stateRoot);
135
+ const previousRuntime = discoverForegroundDaemonRecovery({
136
+ output, stateRoot, workspace: requestedWorkspace,
137
+ });
138
+ throw new Error(persistentCandidateFailureMessage(output, { cli, stateRoot, previousRuntime }));
139
+ }
123
140
  let activation;
124
141
  try { activation = JSON.parse(result.stdout); } catch { throw new Error("persistent candidate activation did not return valid JSON"); }
125
142
  if (
@@ -155,6 +172,7 @@ function activatePersistentCandidate({ manifest, installedPackage, installPrefix
155
172
  if (previous?.version) console.log(`Rollback baseline retained: globally installed ${previous.version}.`);
156
173
  }
157
174
 
175
+
158
176
  function currentGlobalInstallation(packageName) {
159
177
  try {
160
178
  const globalRoot = runNpm(["root", "--global"], root).stdout.trim();
@@ -180,6 +198,13 @@ function withoutManagedFlags(args) {
180
198
  return out;
181
199
  }
182
200
 
201
+ function argumentListValue(name, args) {
202
+ const exact = args.find(value => value.startsWith(`${name}=`));
203
+ if (exact) return exact.slice(name.length + 1);
204
+ const index = args.indexOf(name);
205
+ return index >= 0 ? args[index + 1] || "" : "";
206
+ }
207
+
183
208
  function argumentValue(name) {
184
209
  const exact = process.argv.find((value) => value.startsWith(`${name}=`));
185
210
  if (exact) return exact.slice(name.length + 1);
@@ -198,6 +223,7 @@ function runNpm(args, cwd) {
198
223
  encoding: "utf8",
199
224
  env: process.env,
200
225
  timeout: 300_000,
226
+ killSignal: "SIGKILL",
201
227
  windowsHide: true,
202
228
  });
203
229
  if (result.error) throw result.error;
@@ -36,6 +36,9 @@ import { createHash } from "node:crypto";
36
36
  */
37
37
  /**
38
38
  * @typedef {object} ProjectionState
39
+ * @property {string} target
40
+ * @property {string} targetDir
41
+ * @property {string} scopeRoot
39
42
  * @property {string[]} configFiles
40
43
  * @property {InstructionItem | null} builtinInstructions
41
44
  * @property {InstructionItem | null} automaticProjectContext
@@ -47,18 +50,34 @@ import { createHash } from "node:crypto";
47
50
  /** @param {ProjectionState} state @param {SkillSummary[]} skills */
48
51
  export function capabilityFingerprint(state, skills) {
49
52
  return sha256(JSON.stringify({
50
- configs: state.configFiles,
53
+ target: state.target,
54
+ target_dir: state.targetDir,
55
+ scope_root: state.scopeRoot,
56
+ configs: [...state.configFiles],
51
57
  instructions: [
52
- state.builtinInstructions?.sha256 || "",
53
- state.automaticProjectContext?.sha256 || "",
54
- state.modelInstructions?.sha256 || "",
55
- ...state.instructions.map((item) => item.sha256),
58
+ instructionFingerprintItem(state.builtinInstructions),
59
+ instructionFingerprintItem(state.automaticProjectContext),
60
+ instructionFingerprintItem(state.modelInstructions),
61
+ ...state.instructions.map(instructionFingerprintItem),
56
62
  ],
57
- skills: skills.map((skill) => [skill.id, skill.sha256]),
58
- commands: [...state.commands.values()].map((command) => [command.name, command.argv]),
63
+ skills: skills
64
+ .map((skill) => [skill.id, skill.name, skill.entrypoint, skill.sourceRoot, skill.sha256])
65
+ .sort((left, right) => String(left[0]).localeCompare(String(right[0]))),
66
+ commands: [...state.commands.values()]
67
+ .map((command) => [
68
+ command.name, command.description, command.argv, command.cwd, command.timeoutSeconds,
69
+ command.allowExtraArgs, command.source, command.sourceType || "", command.script || "",
70
+ ])
71
+ .sort((left, right) => String(left[0]).localeCompare(String(right[0]))),
59
72
  }));
60
73
  }
61
74
 
75
+ /** @param {InstructionItem | null | undefined} item */
76
+ function instructionFingerprintItem(item) {
77
+ if (!item) return null;
78
+ return [item.source || "", item.path || "", item.scope, item.sha256, item.precedence, item.bytes];
79
+ }
80
+
62
81
  /** @param {SkillSummary} skill @param {DisplayPath} displayPath */
63
82
  export function publicSkill(skill, displayPath) {
64
83
  return {
@@ -134,16 +134,37 @@ export class AgentContextManager {
134
134
  skillRelevant: Boolean(selected),
135
135
  });
136
136
  const refresh = capabilityFingerprint(state, discovered.skills);
137
- return {
138
- task,
139
- target: this.displayPath(state.target, context),
137
+ const knownRefreshFingerprint = args.known_refresh_fingerprint === undefined
138
+ ? ""
139
+ : String(args.known_refresh_fingerprint || "");
140
+ if (knownRefreshFingerprint && !/^[a-f0-9]{64}$/.test(knownRefreshFingerprint)) {
141
+ throw new Error("known_refresh_fingerprint must be a lowercase SHA-256 hex digest");
142
+ }
143
+ const contextUnchanged = Boolean(knownRefreshFingerprint && knownRefreshFingerprint === refresh);
144
+ const staticContext = contextUnchanged ? {} : {
140
145
  effective_instructions: renderEffectiveInstructions(effectiveInstructionItems(state), (value) => this.displayPath(value, context)),
141
146
  builtin_instructions: publicVirtualInstruction(state.builtinInstructions, false),
142
147
  automatic_project_context: publicVirtualInstruction(state.automaticProjectContext, false),
143
148
  model_instructions_file: state.modelInstructions ? this.displayPath(state.modelInstructions.path, context) : null,
144
149
  instruction_files: state.instructions.map((item) => ({ path: this.displayPath(item.path, context), scope: item.scope, bytes: item.bytes, sha256: item.sha256, precedence: item.precedence })),
150
+ };
151
+ return {
152
+ task,
153
+ target: this.displayPath(state.target, context),
154
+ ...staticContext,
145
155
  instructions_truncated: state.instructionsTruncated,
146
- refresh: { strategy: "rescan-on-every-call", fingerprint: refresh, generated_at: new Date().toISOString() },
156
+ refresh: {
157
+ strategy: "rescan-on-every-call",
158
+ fingerprint: refresh,
159
+ context_unchanged: contextUnchanged,
160
+ generated_at: new Date().toISOString(),
161
+ },
162
+ context_reuse: {
163
+ static_context_omitted: contextUnchanged,
164
+ omitted_fields: contextUnchanged
165
+ ? ["effective_instructions", "builtin_instructions", "automatic_project_context", "model_instructions_file", "instruction_files"]
166
+ : [],
167
+ },
147
168
  selected_skill: selectedSkill,
148
169
  skill_matches: skillMatches.map(({ skill, score }) => ({ ...publicSkill(skill, (value) => this.displayPath(value, context)), score })),
149
170
  command_matches: commandMatches.map(({ command, score }) => ({ ...publicCommands(new Map([[command.name, command]]), (value) => this.displayPath(value, context))[0], score })),
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+
3
+ import { trimAutostartLogs } from "./service.mjs";
4
+
5
+ const DEFAULT_MAINTENANCE_INTERVAL_MS = 15 * 60 * 1000;
6
+
7
+ /**
8
+ * Keep long-lived background-service logs within the same bounds enforced at
9
+ * startup. The injected scheduler/trim hooks keep the lifecycle deterministic
10
+ * in tests without weakening the production file-safety checks.
11
+ *
12
+ * @param {string} stateRoot
13
+ * @param {{
14
+ * intervalMs?: number,
15
+ * trim?: (stateRoot: string) => void,
16
+ * onError?: (error: unknown) => void,
17
+ * scheduler?: {setInterval: (callback: () => void, delay: number) => any},
18
+ * }} [options]
19
+ */
20
+ export function startAutostartLogMaintenance(stateRoot, options = {}) {
21
+ const intervalMs = positiveInteger(options.intervalMs, DEFAULT_MAINTENANCE_INTERVAL_MS);
22
+ const trim = typeof options.trim === "function" ? options.trim : trimAutostartLogs;
23
+ const onError = typeof options.onError === "function" ? options.onError : () => {};
24
+ const scheduler = options.scheduler || { setInterval };
25
+ const maintain = () => {
26
+ try { trim(stateRoot); } catch (error) { onError(error); }
27
+ };
28
+ const timer = scheduler.setInterval(maintain, intervalMs);
29
+ timer?.unref?.();
30
+ return { intervalMs, maintain };
31
+ }
32
+
33
+ function positiveInteger(value, fallback) {
34
+ const number = Number(value);
35
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
36
+ }
@@ -34,6 +34,11 @@ export class CapabilityObserver {
34
34
  matched_commands: Array.isArray(result?.command_matches) ? result.command_matches.length : 0,
35
35
  matched_applications: Array.isArray(result?.application_matches) ? result.application_matches.length : 0,
36
36
  recommended_tools: Array.isArray(result?.recommended_tools) ? [...result.recommended_tools] : [],
37
+ primary_route: result?.execution_routing?.primary_route?.id || null,
38
+ routing_ambiguity: result?.execution_routing?.ambiguity?.level || "none",
39
+ routing_score_gap: Number.isFinite(Number(result?.execution_routing?.ambiguity?.score_gap))
40
+ ? Number(result.execution_routing.ambiguity.score_gap)
41
+ : 0,
37
42
  refresh_fingerprint: String(result?.refresh?.fingerprint || ""),
38
43
  };
39
44
  }
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {{
5
+ * onSettle?: (code: number | null, signal: string | null, source: "close" | "exit_fallback") => void,
6
+ * onFallback?: () => void,
7
+ * schedule?: (callback: () => void, delay: number) => unknown,
8
+ * clearSchedule?: (timer: unknown) => void,
9
+ * fallbackMs?: unknown,
10
+ * readExitState?: () => { code?: number | null, signal?: string | null },
11
+ * }} ChildProcessSettlementOptions
12
+ */
13
+
14
+ /** @param {ChildProcessSettlementOptions} [options] */
15
+ export function createChildProcessSettlement(options = {}) {
16
+ if (typeof options.onSettle !== "function") throw new TypeError("child settlement requires onSettle");
17
+ const onSettle = options.onSettle;
18
+ /** @type {(callback: () => void, delay: number) => unknown} */
19
+ const defaultSchedule = (callback, delay) => setTimeout(callback, delay);
20
+ /** @type {(timer: unknown) => void} */
21
+ const defaultClearSchedule = timer => clearTimeout(/** @type {ReturnType<typeof setTimeout>} */ (timer));
22
+ const schedule = typeof options.schedule === "function" ? options.schedule : defaultSchedule;
23
+ const clearSchedule = typeof options.clearSchedule === "function" ? options.clearSchedule : defaultClearSchedule;
24
+ const delay = boundedDelay(options.fallbackMs);
25
+ let settled = false;
26
+ /** @type {unknown} */
27
+ let timer = null;
28
+ let timerSet = false;
29
+
30
+ /**
31
+ * @param {number | null} code
32
+ * @param {string | null} signal
33
+ * @param {"close" | "exit_fallback"} source
34
+ */
35
+ function settle(code, signal, source) {
36
+ if (settled) return false;
37
+ settled = true;
38
+ if (timerSet) clearSchedule(timer);
39
+ timer = null;
40
+ timerSet = false;
41
+ onSettle(code, signal, source);
42
+ return true;
43
+ }
44
+
45
+ /** @param {number | null} code @param {string | null} signal */
46
+ function onClose(code, signal) { return settle(code, signal, "close"); }
47
+
48
+ /** @param {number | null} code @param {string | null} signal */
49
+ function onExit(code, signal) {
50
+ if (settled || timerSet) return false;
51
+ timerSet = true;
52
+ timer = schedule(() => {
53
+ timer = null;
54
+ timerSet = false;
55
+ options.onFallback?.();
56
+ const observed = safeExitState(options.readExitState);
57
+ const settledCode = typeof observed.code === "number" && Number.isInteger(observed.code) ? observed.code : code;
58
+ const settledSignal = observed.signal || signal;
59
+ settle(settledCode, settledSignal, "exit_fallback");
60
+ }, delay);
61
+ return true;
62
+ }
63
+
64
+ function cancel() {
65
+ if (settled) return false;
66
+ settled = true;
67
+ if (timerSet) clearSchedule(timer);
68
+ timer = null;
69
+ timerSet = false;
70
+ return true;
71
+ }
72
+
73
+ return Object.freeze({ onClose, onExit, cancel });
74
+ }
75
+
76
+ /** @param {unknown} value */
77
+ function boundedDelay(value) {
78
+ if (value === undefined) return 1000;
79
+ const parsed = Number(value);
80
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 10_000) {
81
+ throw new TypeError("child settlement fallback must be between 0 and 10000 milliseconds");
82
+ }
83
+ return Math.floor(parsed);
84
+ }
85
+ export function childExitedBeforeTimeout({ exitCode = null, signalCode = null, processState = "unknown" } = {}) {
86
+ return Number.isInteger(exitCode) || Boolean(signalCode) || processState === "zombie";
87
+ }
88
+
89
+ /** @param {ChildProcessSettlementOptions["readExitState"]} reader @returns {{ code: number | null, signal: string | null }} */
90
+ function safeExitState(reader) {
91
+ if (typeof reader !== "function") return { code: null, signal: null };
92
+ try {
93
+ const value = reader();
94
+ const code = value?.code;
95
+ const signal = value?.signal;
96
+ return {
97
+ code: typeof code === "number" && Number.isInteger(code) ? code : null,
98
+ signal: typeof signal === "string" && signal ? signal : null,
99
+ };
100
+ } catch {
101
+ return { code: null, signal: null };
102
+ }
103
+ }
@@ -2,8 +2,9 @@ import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon } from "./daemon-
2
2
  import { effectiveLogFormat, effectiveLogLevel } from "./cli-options.mjs";
3
3
  import { createLogger } from "./log.mjs";
4
4
  import { activatePersistentRuntime } from "./runtime-activation.mjs";
5
- import { installAutostart, startAutostart, stopAutostart } from "./service.mjs";
6
- import { acquireStartupLockWithWait, loadState } from "./state.mjs";
5
+ import { autostartStatus, installAutostart, startAutostart, stopAutostart } from "./service.mjs";
6
+ import { startOwnedServiceRuntime } from "./service-runtime.mjs";
7
+ import { acquireMachineServiceLockWithWait, acquireStartupLockWithWait, daemonLockPathForState, loadState, readDaemonLockOwner } from "./state.mjs";
7
8
  import { workerHealth } from "./worker-health.mjs";
8
9
 
9
10
  export function createActivateCommand({
@@ -42,13 +43,41 @@ export function createActivateCommand({
42
43
  const result = await activatePersistentRuntime({
43
44
  expectedVersion,
44
45
  acquireStartupLock: () => acquireStartupLockWithWait(state, { operation: "activate", logger }),
46
+ acquireServiceLock: () => acquireMachineServiceLockWithWait({ operation: "activate", logger }),
47
+ inspectActivationOwnership: async () => {
48
+ const daemon = inspectWorkspaceDaemon(state);
49
+ const owner = daemon.alive && daemon.verified_service_daemon
50
+ ? readDaemonLockOwner(daemonLockPathForState(state))
51
+ : null;
52
+ return {
53
+ daemon,
54
+ provider: await autostartStatus(),
55
+ previousRuntime: owner ? { version: owner.version, entryScript: owner.entryScript } : null,
56
+ };
57
+ },
45
58
  stopAutostart: () => stopAutostart({ logger: structuredLogger(true) }),
46
59
  acquireDaemonLock: () => acquireDaemonLockWithTakeover(state, {
47
60
  takeOverServiceOwner: true,
48
61
  ownerMetadata: { mode: "foreground", version: expectedVersion },
49
62
  logger,
50
63
  }),
51
- prepareRemoteState: () => prepareRemoteState({ args: activationArgs, workspace, state, logger }),
64
+ prepareRemoteState: ({ onRemotePrepared } = {}) => prepareRemoteState({
65
+ args: activationArgs,
66
+ workspace,
67
+ state,
68
+ logger,
69
+ onRemotePrepared,
70
+ }),
71
+ repairRemoteState: async ({ onRemotePrepared } = {}) => {
72
+ logger.warn("candidate device authentication was rejected; redeploying the same Worker once with the current device identity");
73
+ return prepareRemoteState({
74
+ args: { ...activationArgs, forceWorker: true, rotateSecrets: false },
75
+ workspace,
76
+ state,
77
+ logger,
78
+ onRemotePrepared,
79
+ });
80
+ },
52
81
  createRuntime: ({ daemonLock, readiness }) => createRemoteRuntime({
53
82
  args: activationArgs,
54
83
  workspace,
@@ -61,10 +90,17 @@ export function createActivateCommand({
61
90
  workspace,
62
91
  stateRoot: state.paths.stateRoot,
63
92
  entryScript: process.argv[1],
93
+ version: expectedVersion,
64
94
  logger: structuredLogger(true),
65
95
  }),
66
- startAutostart: () => startAutostart({ logger: structuredLogger(true) }),
67
- inspectDaemon: async () => inspectWorkspaceDaemon(state),
96
+ startAutostart: () => startOwnedServiceRuntime({ logger: structuredLogger(true) }),
97
+ restorePreviousAutostart: () => startAutostart({ logger: structuredLogger(true) }),
98
+ inspectPreviousAutostart: (identity) => inspectWorkspaceDaemon(state, {
99
+ expectedVersion: identity.version, expectedEntryScript: identity.entryScript,
100
+ }),
101
+ inspectDaemon: async () => inspectWorkspaceDaemon(state, {
102
+ expectedVersion, expectedEntryScript: process.argv[1],
103
+ }),
68
104
  checkWorker: async () => workerHealth(state.worker.url, expectedVersion, { expectedWorkerName: state.worker.name }),
69
105
  });
70
106
  const convergence = result.convergence;
@@ -76,6 +112,7 @@ export function createActivateCommand({
76
112
  daemon: convergence.daemon,
77
113
  service: result.serviceStart,
78
114
  candidate_relay_verified_before_handoff: result.candidateRelayVerified,
115
+ candidate_auth_recovery_redeployed: result.candidateRecoveryRedeployed,
79
116
  };
80
117
  if (args.json) console.log(JSON.stringify(payload, null, 2));
81
118
  else {
@@ -4,6 +4,8 @@ import { inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-pro
4
4
  import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
5
5
  import { serviceEnvironmentSummary } from "./service-environment.mjs";
6
6
  import { scheduleServiceRestart } from "./service-restart-scheduler.mjs";
7
+ import { startOwnedServiceRuntime } from "./service-runtime.mjs";
8
+ import { loadServiceOwner } from "./service-owner.mjs";
7
9
  import { loadState, resolveWorkspace, selectedWorkspace } from "./state.mjs";
8
10
 
9
11
  const SERVICE_ACTION_HANDLERS = new Map([
@@ -21,12 +23,18 @@ export function createServiceCommand(dependencies) {
21
23
  chooseWorkspace: requiredFunction(dependencies.chooseWorkspace, "chooseWorkspace"),
22
24
  stateRootFromArgs: requiredFunction(dependencies.stateRootFromArgs, "stateRootFromArgs"),
23
25
  structuredLogger: requiredFunction(dependencies.structuredLogger, "structuredLogger"),
26
+ currentPackageVersion: requiredFunction(dependencies.currentPackageVersion, "currentPackageVersion"),
24
27
  service: dependencies.service || defaultService,
25
28
  inspectWorkspaceDaemon: dependencies.inspectWorkspaceDaemon || inspectWorkspaceDaemon,
26
29
  stopWorkspaceServiceDaemon: dependencies.stopWorkspaceServiceDaemon || stopWorkspaceServiceDaemon,
27
30
  stopAndRemoveAutostart: dependencies.stopAndRemoveAutostart || stopAndRemoveAutostart,
28
31
  serviceEnvironmentSummary: dependencies.serviceEnvironmentSummary || serviceEnvironmentSummary,
32
+ loadServiceOwner: dependencies.loadServiceOwner || loadServiceOwner,
29
33
  scheduleServiceRestart: dependencies.scheduleServiceRestart || scheduleServiceRestart,
34
+ startOwnedServiceRuntime: dependencies.startOwnedServiceRuntime || startOwnedServiceRuntime,
35
+ acquireMachineServiceLockWithWait: requiredFunction(
36
+ dependencies.acquireMachineServiceLockWithWait, "acquireMachineServiceLockWithWait",
37
+ ),
30
38
  loadState: dependencies.loadState || loadState,
31
39
  resolveWorkspace: dependencies.resolveWorkspace || resolveWorkspace,
32
40
  selectedWorkspace: dependencies.selectedWorkspace || selectedWorkspace,
@@ -42,18 +50,45 @@ async function serviceCommand(args, context) {
42
50
  const handler = SERVICE_ACTION_HANDLERS.get(action);
43
51
  if (!handler) throw new Error(`Unknown service action: ${action}`);
44
52
  const stateRoot = context.stateRootFromArgs(args);
45
- return handler({ args, stateRoot, service: context.service, context });
53
+ if (!new Set(["install", "start", "stop", "uninstall", "remove"]).has(action)) {
54
+ return handler({ args, stateRoot, service: context.service, context });
55
+ }
56
+ const lock = await context.acquireMachineServiceLockWithWait({ operation: `service-${action}` });
57
+ if (!lock?.acquired || typeof lock.release !== "function") {
58
+ throw new Error("machine-service operation lock could not be acquired");
59
+ }
60
+ try {
61
+ return await handler({ args, stateRoot, service: context.service, context });
62
+ } finally {
63
+ lock.release();
64
+ }
46
65
  }
47
66
 
48
67
  async function serviceStatusAction({ args, stateRoot, service, context }) {
49
68
  const status = await service.autostartStatus();
50
- const state = optionalServiceState(args, stateRoot, context);
51
- const workspaceDaemon = state ? context.inspectWorkspaceDaemon(state) : null;
69
+ let owner = null;
70
+ let ownerProjection = { status: "missing", version: null };
71
+ try {
72
+ owner = context.loadServiceOwner();
73
+ if (owner) ownerProjection = { status: owner.status, version: owner.version };
74
+ } catch {
75
+ ownerProjection = { status: "invalid", version: null, error_class: "invalid_state" };
76
+ }
77
+ const explicitState = hasExplicitServiceTarget(args) ? optionalServiceState(args, stateRoot, context) : null;
78
+ const ownerState = !explicitState && owner?.status === "committed"
79
+ ? context.loadState(owner.workspace, { stateDir: owner.stateRoot })
80
+ : null;
81
+ const state = explicitState || ownerState || optionalServiceState(args, stateRoot, context);
82
+ const effectiveStateRoot = ownerState ? owner.stateRoot : stateRoot;
83
+ const workspaceDaemon = state ? context.inspectWorkspaceDaemon(state, ownerState ? {
84
+ expectedVersion: owner.version, expectedEntryScript: owner.entryScript,
85
+ } : {}) : null;
52
86
  printServiceResult({
53
87
  ...status,
54
88
  workspace: state?.workspace?.path || null,
55
89
  workspace_daemon: workspaceDaemon,
56
- service_environment: context.serviceEnvironmentSummary(stateRoot),
90
+ service_owner: ownerProjection,
91
+ service_environment: context.serviceEnvironmentSummary(effectiveStateRoot),
57
92
  effective_active: Boolean(status.active || workspaceDaemon?.alive),
58
93
  orphaned_workspace_daemon: Boolean(status.active === false && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
59
94
  }, context, false);
@@ -66,10 +101,19 @@ async function serviceInstallAction({ args, stateRoot, service, context }) {
66
101
  if (!state.worker?.url) {
67
102
  throw new Error("No deployed Worker is recorded for this workspace. Run `machine-mcp` once before `machine-mcp service install`.");
68
103
  }
104
+ const provider = await service.autostartStatus();
105
+ const daemon = context.inspectWorkspaceDaemon(state);
106
+ if (typeof provider?.active !== "boolean") {
107
+ throw new Error("machine service activity could not be verified before installation");
108
+ }
109
+ if (provider.active || daemon?.alive) {
110
+ throw new Error("refusing to replace the machine service definition while a provider or workspace daemon is active");
111
+ }
69
112
  const result = await service.installAutostart({
70
113
  workspace,
71
114
  stateRoot,
72
115
  entryScript: context.entryScript,
116
+ version: context.currentPackageVersion(),
73
117
  logger: context.structuredLogger(Boolean(args.quiet)),
74
118
  });
75
119
  printServiceResult(result, context);
@@ -77,7 +121,13 @@ async function serviceInstallAction({ args, stateRoot, service, context }) {
77
121
 
78
122
  async function serviceStartAction({ args, service, context }) {
79
123
  assertGlobalServiceAction(args, "start");
80
- const result = await service.startAutostart({ logger: context.structuredLogger(Boolean(args.quiet)) });
124
+ const logger = context.structuredLogger(Boolean(args.quiet));
125
+ const result = await context.startOwnedServiceRuntime({
126
+ logger,
127
+ readProvider: service.autostartStatus,
128
+ mutateProvider: service.startAutostart,
129
+ stopProvider: service.stopAutostart,
130
+ });
81
131
  printServiceResult(result, context);
82
132
  }
83
133