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
@@ -8,6 +8,39 @@ export async function waitForActiveStatus(
8
8
  return waitForStatus(readStatus, (status) => status?.active === true, { attempts, delayMs, sleep });
9
9
  }
10
10
 
11
+
12
+ export async function waitForStableActiveStatus(
13
+ readStatus,
14
+ { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, stableSamples = 5, sleep = delay, identity = stableIdentity } = {},
15
+ ) {
16
+ if (typeof readStatus !== "function") throw new TypeError("readStatus must be a function");
17
+ if (typeof identity !== "function") throw new TypeError("identity must be a function");
18
+ const maximum = Number.isInteger(attempts) && attempts > 0 ? attempts : DEFAULT_ATTEMPTS;
19
+ const required = Number.isInteger(stableSamples) && stableSamples > 0 ? stableSamples : 5;
20
+ let status = null;
21
+ let consecutive = 0;
22
+ let priorIdentity = null;
23
+ for (let attempt = 1; attempt <= maximum; attempt += 1) {
24
+ status = await readStatus();
25
+ const currentIdentity = status?.active === true ? identity(status) : null;
26
+ const identityChanged = consecutive > 0
27
+ && (priorIdentity !== null || currentIdentity !== null)
28
+ && currentIdentity !== priorIdentity;
29
+ if (status?.active === true && !identityChanged) consecutive += 1;
30
+ else consecutive = status?.active === true ? 1 : 0;
31
+ priorIdentity = currentIdentity;
32
+ if (consecutive >= required) return { stable: true, status, samples: consecutive, attempts: attempt };
33
+ if (attempt < maximum) await sleep(delayMs);
34
+ }
35
+ return { stable: false, status, samples: consecutive, attempts: maximum };
36
+ }
37
+
38
+ function stableIdentity(status) {
39
+ if (Number.isInteger(status?.pid) && status.pid > 0) return `pid:${status.pid}`;
40
+ if (typeof status?.last_run_time === "string" && status.last_run_time) return `run:${status.last_run_time}`;
41
+ return null;
42
+ }
43
+
11
44
  export async function waitForInactiveStatus(
12
45
  readStatus,
13
46
  { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
@@ -0,0 +1,147 @@
1
+ // @ts-check
2
+
3
+ import { randomBytes } from "node:crypto";
4
+ import { existsSync, realpathSync, rmSync, statSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
8
+ import { ensureOwnerOnlyDir, machineServiceControlRoot } from "./state.mjs";
9
+
10
+ const SCHEMA_VERSION = 1;
11
+ const MAX_BYTES = 64 * 1024;
12
+ const FILE_NAME = "service-owner.json";
13
+ const VERSION = /^[0-9A-Za-z.+_-]{1,64}$/;
14
+ const TRANSACTION = /^[A-Za-z0-9_-]{20,128}$/;
15
+
16
+ /** @typedef {{ schemaVersion: 1, status: "pending" | "committed", transactionId: string, workspace: string, stateRoot: string, entryScript: string, version: string, createdAt: string, committedAt: string | null }} ServiceOwner */
17
+ /** @typedef {{ workspace?: unknown, stateRoot?: unknown, entryScript?: unknown, version?: unknown }} ServiceOwnerSpec */
18
+ /** @typedef {{ controlRoot?: string }} ServiceOwnerOptions */
19
+
20
+ /** @param {ServiceOwnerOptions} [options] */ export function serviceOwnerPath(options = {}) {
21
+ return path.join(machineServiceControlRoot(options), FILE_NAME);
22
+ }
23
+
24
+ /** @param {ServiceOwnerSpec} spec @param {ServiceOwnerOptions} [options] */ export function beginServiceOwnerUpdate(spec, options = {}) {
25
+ const file = serviceOwnerPath(options);
26
+ ensureOwnerOnlyDir(path.dirname(file));
27
+ const previous = existsSync(file) ? readOwnerSnapshot(file) : null;
28
+ const transactionId = randomBytes(24).toString("base64url");
29
+ const createdAt = new Date().toISOString();
30
+ const pending = normalizeOwner({
31
+ schemaVersion: SCHEMA_VERSION,
32
+ status: "pending",
33
+ transactionId,
34
+ workspace: spec?.workspace,
35
+ stateRoot: spec?.stateRoot,
36
+ entryScript: spec?.entryScript,
37
+ version: spec?.version,
38
+ createdAt,
39
+ committedAt: null,
40
+ });
41
+ writeOwner(file, pending);
42
+ let closed = false;
43
+ return {
44
+ path: file,
45
+ owner: pending,
46
+ commit() {
47
+ if (closed) throw new Error("service owner transaction is already closed");
48
+ assertCurrentTransaction(file, transactionId, "pending");
49
+ const committed = normalizeOwner({ ...pending, status: "committed", committedAt: new Date().toISOString() });
50
+ writeOwner(file, committed);
51
+ closed = true;
52
+ return committed;
53
+ },
54
+ rollback() {
55
+ if (closed) return false;
56
+ assertCurrentTransaction(file, transactionId, "pending");
57
+ if (previous) replaceFileAtomicallySync(file, previous.raw, { mode: 0o600 });
58
+ else rmSync(file, { force: true });
59
+ closed = true;
60
+ return true;
61
+ },
62
+ };
63
+ }
64
+
65
+ /** @param {ServiceOwnerOptions} [options] @returns {ServiceOwner | null} */ export function loadServiceOwner(options = {}) {
66
+ const file = serviceOwnerPath(options);
67
+ return existsSync(file) ? readOwnerSnapshot(file).owner : null;
68
+ }
69
+
70
+ /** @param {ServiceOwnerOptions} [options] @returns {ServiceOwner | null} */ export function loadCommittedServiceOwner(options = {}) {
71
+ const owner = loadServiceOwner(options);
72
+ if (!owner) return null;
73
+ if (owner.status !== "committed") {
74
+ throw new Error("machine service owner transition is incomplete; reinstall the service before starting it");
75
+ }
76
+ return owner;
77
+ }
78
+
79
+ /** @param {ServiceOwnerOptions} [options] */ export function removeServiceOwner(options = {}) {
80
+ const file = serviceOwnerPath(options);
81
+ if (!existsSync(file)) return false;
82
+ readBoundedRegularFileSync(file, MAX_BYTES, "machine service owner file", {
83
+ verifyPathIdentity: true, rejectMultipleLinks: true,
84
+ });
85
+ rmSync(file, { force: true });
86
+ return true;
87
+ }
88
+
89
+ /** @param {string} file @returns {{ owner: ServiceOwner, raw: Buffer }} */ function readOwnerSnapshot(file) {
90
+ const raw = readBoundedRegularFileSync(file, MAX_BYTES, "machine service owner file");
91
+ let parsed;
92
+ try { parsed = JSON.parse(raw.toString("utf8")); }
93
+ catch (error) { throw new Error("machine service owner file is not valid JSON", { cause: error }); }
94
+ return { owner: normalizeOwner(parsed), raw };
95
+ }
96
+
97
+ /** @param {string} file @param {ServiceOwner} owner */ function writeOwner(file, owner) {
98
+ const content = `${JSON.stringify(owner, null, 2)}\n`;
99
+ if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("machine service owner record exceeds the size limit");
100
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
101
+ }
102
+
103
+ /** @param {string} file @param {string} transactionId @param {"pending" | "committed"} status */ function assertCurrentTransaction(file, transactionId, status) {
104
+ const current = readOwnerSnapshot(file).owner;
105
+ if (current.transactionId !== transactionId || current.status !== status) {
106
+ throw new Error("machine service owner transaction changed before completion");
107
+ }
108
+ }
109
+
110
+ /** @param {unknown} value @returns {ServiceOwner} */ function normalizeOwner(value) {
111
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("machine service owner must be an object");
112
+ const record = /** @type {Record<string, unknown>} */ (value);
113
+ if (record.schemaVersion !== SCHEMA_VERSION) throw new Error("machine service owner schema is unsupported");
114
+ if (record.status !== "pending" && record.status !== "committed") throw new Error("machine service owner status is invalid");
115
+ if (typeof record.transactionId !== "string" || !TRANSACTION.test(record.transactionId)) throw new Error("machine service owner transaction id is invalid");
116
+ if (typeof record.version !== "string" || !VERSION.test(record.version)) throw new Error("machine service owner version is invalid");
117
+ const workspace = canonicalDirectory(record.workspace, "workspace");
118
+ const stateRoot = canonicalDirectory(record.stateRoot, "state root");
119
+ const entryScript = canonicalFile(record.entryScript, "entry script");
120
+ const createdAt = isoTimestamp(record.createdAt, "createdAt");
121
+ const committedAt = record.status === "committed" ? isoTimestamp(record.committedAt, "committedAt") : null;
122
+ return { schemaVersion: SCHEMA_VERSION, status: record.status, transactionId: record.transactionId,
123
+ workspace, stateRoot, entryScript, version: record.version, createdAt, committedAt };
124
+ }
125
+
126
+ /** @param {unknown} value @param {string} label */ function canonicalDirectory(value, label) {
127
+ const canonical = canonicalPath(value, label);
128
+ if (!statSync(canonical).isDirectory()) throw new Error(`machine service owner ${label} is not a directory`);
129
+ return canonical;
130
+ }
131
+
132
+ /** @param {unknown} value @param {string} label */ function canonicalFile(value, label) {
133
+ const canonical = canonicalPath(value, label);
134
+ if (!statSync(canonical).isFile()) throw new Error(`machine service owner ${label} is not a file`);
135
+ return canonical;
136
+ }
137
+
138
+ /** @param {unknown} value @param {string} label */ function canonicalPath(value, label) {
139
+ if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`machine service owner ${label} must be absolute`);
140
+ try { return realpathSync.native ? realpathSync.native(value) : realpathSync(value); }
141
+ catch (error) { throw new Error(`machine service owner ${label} is unavailable`, { cause: error }); }
142
+ }
143
+
144
+ /** @param {unknown} value @param {string} label */ function isoTimestamp(value, label) {
145
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) throw new Error(`machine service owner ${label} is invalid`);
146
+ return value;
147
+ }
@@ -1,24 +1,38 @@
1
1
  import { resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { restartAutostart } from "./service.mjs";
3
+ import { restartOwnedServiceRuntime } from "./service-runtime.mjs";
4
4
  import { createLogger } from "./log.mjs";
5
+ import { acquireMachineServiceLockWithWait } from "./state.mjs";
5
6
 
6
7
  const DEFAULT_DELAY_MS = 300;
7
8
  const MAX_DELAY_MS = 5_000;
8
9
 
9
10
  export async function runServiceRestartHandoff(options = {}) {
10
11
  const delayMs = boundedDelay(options.delayMs ?? process.argv[2]);
11
- const restart = typeof options.restartAutostart === "function" ? options.restartAutostart : restartAutostart;
12
+ const restart = typeof options.restartServiceRuntime === "function"
13
+ ? options.restartServiceRuntime
14
+ : typeof options.restartAutostart === "function" ? options.restartAutostart : restartOwnedServiceRuntime;
12
15
  const sleep = typeof options.sleep === "function" ? options.sleep : delay;
13
16
  const logger = options.logger || createLogger({ component: "service-restart", level: "warn", format: "json", stderrOnly: true });
17
+ const acquireLock = typeof options.acquireServiceLock === "function"
18
+ ? options.acquireServiceLock
19
+ : () => acquireMachineServiceLockWithWait({ operation: "service-restart", logger, ...(options.serviceLockOptions || {}) });
14
20
  await sleep(delayMs);
15
- const result = await restart({ logger });
16
- if (result?.ok !== true) {
17
- const error = new Error(`service restart handoff failed (${result?.reason || result?.provider || "unknown"})`);
18
- error.result = result;
19
- throw error;
21
+ const lock = await acquireLock();
22
+ if (!lock?.acquired || typeof lock.release !== "function") {
23
+ throw new Error("machine-service operation lock could not be acquired for restart");
24
+ }
25
+ try {
26
+ const result = await restart({ logger });
27
+ if (result?.ok !== true) {
28
+ const error = new Error(`service restart handoff failed (${result?.reason || result?.provider || "unknown"})`);
29
+ error.result = result;
30
+ throw error;
31
+ }
32
+ return result;
33
+ } finally {
34
+ lock.release();
20
35
  }
21
- return result;
22
36
  }
23
37
 
24
38
  export async function serviceRestartHandoffMain(options = {}) {
@@ -0,0 +1,145 @@
1
+ // @ts-check
2
+
3
+ import { inspectWorkspaceDaemon } from "./daemon-process.mjs";
4
+ import { loadCommittedServiceOwner } from "./service-owner.mjs";
5
+ import { autostartStatus, restartAutostart, startAutostart, stopAutostart } from "./service.mjs";
6
+ import { loadState } from "./state.mjs";
7
+
8
+ /** @typedef {{ status: string, workspace: string, stateRoot: string, entryScript: string, version: string }} ServiceOwner */
9
+ /** @typedef {Record<string, unknown> & { ok?: boolean, active?: boolean | null, provider?: string, reason?: string }} ProviderResult */
10
+ /** @typedef {Record<string, unknown> & { alive?: boolean, verified_service_daemon?: boolean, mode?: string, startup_readiness_verified?: boolean, identity_reason?: string }} DaemonStatus */
11
+ /** @typedef {{ expectedVersion: string, expectedEntryScript: string }} DaemonExpectation */
12
+ /** @typedef {{ owner?: ServiceOwner, ownerOptions?: Record<string, unknown>, loadOwner?: (options: Record<string, unknown>) => ServiceOwner | null, loadState?: (workspace: string, options: { stateDir: string }) => object, inspectDaemon?: (state: object, expectation: DaemonExpectation) => DaemonStatus, readProvider?: () => ProviderResult | Promise<ProviderResult>, mutateProvider?: (options: { logger?: Console }) => ProviderResult | Promise<ProviderResult>, stopProvider?: (options: { logger?: Console }) => ProviderResult | Promise<ProviderResult>, logger?: Console, attempts?: number, delayMs?: number, sleep?: (milliseconds: number) => Promise<void> }} OwnedRuntimeOptions */
13
+ /** @typedef {{ inspectDaemon?: () => DaemonStatus, attempts?: number, delayMs?: number, sleep?: (milliseconds: number) => Promise<void> }} OwnedWaitOptions */
14
+
15
+ const DEFAULT_ATTEMPTS = 900;
16
+ const DEFAULT_DELAY_MS = 100;
17
+
18
+ /** @param {OwnedRuntimeOptions} [options] */
19
+ export async function startOwnedServiceRuntime(options = {}) {
20
+ return convergeOwnedServiceRuntime({
21
+ ...options,
22
+ mutateProvider: options.mutateProvider || startAutostart,
23
+ });
24
+ }
25
+
26
+ /** @param {OwnedRuntimeOptions} [options] */
27
+ export async function restartOwnedServiceRuntime(options = {}) {
28
+ return convergeOwnedServiceRuntime({
29
+ ...options,
30
+ mutateProvider: options.mutateProvider || restartAutostart,
31
+ });
32
+ }
33
+
34
+ /** @param {OwnedRuntimeOptions} [options] */
35
+ export async function convergeOwnedServiceRuntime(options = {}) {
36
+ const loadOwner = options.loadOwner === undefined ? loadCommittedServiceOwner : options.loadOwner;
37
+ const loadOwnerState = options.loadState === undefined ? loadState : options.loadState;
38
+ const inspectDaemon = options.inspectDaemon === undefined ? inspectWorkspaceDaemon : options.inspectDaemon;
39
+ const readProvider = options.readProvider === undefined ? autostartStatus : options.readProvider;
40
+ const mutateProvider = options.mutateProvider;
41
+ const stopProvider = options.stopProvider === undefined ? stopAutostart : options.stopProvider;
42
+ for (const [name, value] of Object.entries({ loadOwner, loadOwnerState, inspectDaemon, readProvider, stopProvider })) {
43
+ if (typeof value !== "function") throw new TypeError(`owned service runtime requires ${name}`);
44
+ }
45
+ if (typeof mutateProvider !== "function") throw new TypeError("owned service runtime requires mutateProvider");
46
+ const owner = options.owner || loadOwner(options.ownerOptions || {});
47
+ if (!owner) throw new Error("machine service owner is unavailable; reinstall the service before starting it");
48
+ const state = loadOwnerState(owner.workspace, { stateDir: owner.stateRoot });
49
+ const inspect = () => inspectDaemon(state, {
50
+ expectedVersion: owner.version,
51
+ expectedEntryScript: owner.entryScript,
52
+ });
53
+ const providerBefore = await readProvider();
54
+ if (typeof providerBefore?.active !== "boolean") {
55
+ throw new Error("machine service provider activity could not be verified before mutation");
56
+ }
57
+ const daemonBefore = inspect();
58
+ assertStartOwnership(providerBefore, daemonBefore);
59
+ if (providerBefore.active === true && readyOwnedDaemon(daemonBefore)) {
60
+ return { ok: true, active: true, already_running: true, reason: "already_running",
61
+ provider: providerBefore.provider, daemon: daemonBefore, service_owner: ownerSummary(owner) };
62
+ }
63
+ const providerResult = await mutateProvider({ logger: options.logger });
64
+ if (providerResult?.ok !== true || providerResult.active !== true) return providerResult;
65
+ const convergence = await waitForOwnedServiceDaemon({
66
+ inspectDaemon: inspect,
67
+ attempts: options.attempts,
68
+ delayMs: options.delayMs,
69
+ sleep: options.sleep,
70
+ });
71
+ if (convergence.ok) {
72
+ return { ...providerResult, ok: true, active: true, daemon: convergence.daemon,
73
+ service_owner: ownerSummary(owner), readiness_attempts: convergence.attempts };
74
+ }
75
+ const providerStop = await stopProvider({ logger: options.logger });
76
+ const providerInactive = providerStop?.ok === true && providerStop.active === false;
77
+ return {
78
+ ok: false,
79
+ active: providerStop?.active === true ? true : providerStop?.active === false ? false : null,
80
+ provider: providerResult.provider || providerStop?.provider || "unknown",
81
+ reason: providerInactive ? convergence.reason : `${convergence.reason}_provider_stop_unverified`,
82
+ daemon: convergence.daemon,
83
+ provider_start: providerResult,
84
+ provider_stop: providerStop,
85
+ service_owner: ownerSummary(owner),
86
+ };
87
+ }
88
+
89
+ /** @param {OwnedWaitOptions} [options] */
90
+ export async function waitForOwnedServiceDaemon(options = {}) {
91
+ const { inspectDaemon, attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = options;
92
+ if (typeof inspectDaemon !== "function") throw new TypeError("owned service convergence requires inspectDaemon");
93
+ const maximum = boundedAttempts(attempts);
94
+ /** @type {DaemonStatus | null} */
95
+ let daemon = null;
96
+ for (let attempt = 1; attempt <= maximum; attempt += 1) {
97
+ daemon = inspectDaemon();
98
+ if (readyOwnedDaemon(daemon)) return { ok: true, attempts: attempt, daemon, reason: null };
99
+ if (daemon?.alive === true && daemon.verified_service_daemon !== true) {
100
+ return { ok: false, attempts: attempt, daemon,
101
+ reason: `daemon_identity_${daemon.identity_reason || "unverified"}` };
102
+ }
103
+ if (attempt < maximum) await sleep(delayMs);
104
+ }
105
+ return { ok: false, attempts: maximum, daemon,
106
+ reason: daemon?.alive ? "daemon_readiness_not_verified" : "daemon_not_running" };
107
+ }
108
+
109
+ /** @param {ProviderResult} provider @param {DaemonStatus} daemon */
110
+ function assertStartOwnership(provider, daemon) {
111
+ if (!daemon?.alive) return;
112
+ if (daemon.verified_service_daemon !== true || daemon.mode !== "service") {
113
+ throw new Error(`refusing to mutate the machine service while its owner has a ${daemon.identity_reason || daemon.mode || "unverified"} daemon`);
114
+ }
115
+ if (provider.active !== true) {
116
+ throw new Error("the machine service owner has an orphaned service daemon while the provider is inactive; stop it explicitly before retrying");
117
+ }
118
+ }
119
+
120
+ /** @param {DaemonStatus | null | undefined} daemon */
121
+ function readyOwnedDaemon(daemon) {
122
+ return daemon?.alive === true
123
+ && daemon.verified_service_daemon === true
124
+ && daemon.mode === "service"
125
+ && daemon.startup_readiness_verified === true;
126
+ }
127
+
128
+ /** @param {ServiceOwner} owner */
129
+ function ownerSummary(owner) {
130
+ return { status: owner.status, version: owner.version };
131
+ }
132
+
133
+ /** @param {unknown} value */
134
+ function boundedAttempts(value) {
135
+ const parsed = value === undefined ? DEFAULT_ATTEMPTS : Number(value);
136
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1800) {
137
+ throw new Error("owned service convergence attempts must be between 1 and 1800");
138
+ }
139
+ return parsed;
140
+ }
141
+
142
+ /** @param {number} milliseconds @returns {Promise<void>} */
143
+ function delay(milliseconds) {
144
+ return new Promise(resolve => { setTimeout(resolve, milliseconds); });
145
+ }
@@ -5,9 +5,10 @@ import { runExecutable } from "./shell.mjs";
5
5
  import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
6
6
  import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
7
  import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
8
- import { waitForActiveStatus, waitForInactiveStatus, waitForStatus } from "./service-convergence.mjs";
8
+ import { waitForInactiveStatus, waitForStableActiveStatus, waitForStatus } from "./service-convergence.mjs";
9
9
  import { launchdStatusSummary, systemdStatusSummary } from "./service-status.mjs";
10
10
  import { writeServiceEnvironment } from "./service-environment.mjs";
11
+ import { beginServiceOwnerUpdate, removeServiceOwner } from "./service-owner.mjs";
11
12
  import {
12
13
  installWindowsTask,
13
14
  restartWindowsTask,
@@ -20,6 +21,7 @@ export { windowsCommandLineArgument } from "./windows-service.mjs";
20
21
 
21
22
  const LABEL = "dev.machine-bridge-mcp.daemon";
22
23
  const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
24
+ const SERVICE_COMMAND_TIMEOUT_MS = 30_000;
23
25
  const AUTOSTART_LOG_SCHEMA_VERSION = 4;
24
26
 
25
27
  function serviceRun(command, args) {
@@ -30,24 +32,67 @@ export function runServiceCommand(command, args, execute = runExecutable) {
30
32
  return execute(command, args, {
31
33
  capture: true,
32
34
  allowFailure: true,
35
+ timeoutMs: SERVICE_COMMAND_TIMEOUT_MS,
33
36
  maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
34
37
  });
35
38
  }
36
39
 
37
- export async function installAutostart({ workspace, stateRoot, entryScript, logger = console }) {
40
+ export async function installAutostart({ workspace, stateRoot, entryScript, version, logger = console,
41
+ installProvider = defaultInstallProvider, beginOwnerUpdate = beginServiceOwnerUpdate,
42
+ writeEnvironment = writeServiceEnvironment } = {}) {
38
43
  const spec = serviceSpec({ workspace, stateRoot, entryScript });
39
- const serviceEnvironment = writeServiceEnvironment(spec.stateRoot);
44
+ const ownerUpdate = beginOwnerUpdate({ ...spec, version });
45
+ let serviceEnvironment;
46
+ try { serviceEnvironment = writeEnvironment(spec.stateRoot); }
47
+ catch (error) {
48
+ try { ownerUpdate.rollback(); }
49
+ catch (rollbackError) {
50
+ throw new AggregateError([error, rollbackError],
51
+ "service environment preparation failed and the previous machine service owner could not be restored");
52
+ }
53
+ throw error;
54
+ }
40
55
  let result;
41
- if (process.platform === "darwin") result = await installLaunchd(spec, logger);
42
- else if (process.platform === "win32") result = await installWindowsTask(spec, logger, { run: serviceRun });
43
- else result = await installSystemd(spec, logger);
44
- return { ...result, service_environment: serviceEnvironment };
56
+ try { result = await installProvider(spec, logger); }
57
+ catch (error) {
58
+ throw new Error(
59
+ "autostart installation threw after its machine service owner became pending; service start is blocked until reinstall",
60
+ { cause: error },
61
+ );
62
+ }
63
+ if (result?.ok !== true) {
64
+ return {
65
+ ...result,
66
+ service_environment: serviceEnvironment,
67
+ service_owner: { status: "pending", version },
68
+ reason: result?.reason || "installation_failed_owner_pending",
69
+ };
70
+ }
71
+ let owner;
72
+ try { owner = ownerUpdate.commit(); }
73
+ catch (error) {
74
+ throw new Error(
75
+ "autostart definition was installed but its machine service owner could not be committed; the pending owner was retained and service start is blocked until reinstall",
76
+ { cause: error },
77
+ );
78
+ }
79
+ return { ...result, service_environment: serviceEnvironment, service_owner: { status: owner.status, version: owner.version } };
80
+ }
81
+
82
+ async function defaultInstallProvider(spec, logger) {
83
+ if (process.platform === "darwin") return installLaunchd(spec, logger);
84
+ if (process.platform === "win32") return installWindowsTask(spec, logger, { run: serviceRun });
85
+ return installSystemd(spec, logger);
45
86
  }
46
87
 
88
+
47
89
  export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
48
- if (process.platform === "darwin") return uninstallLaunchd(logger);
49
- if (process.platform === "win32") return uninstallWindowsTask(logger, { run: serviceRun, stateRoot });
50
- return uninstallSystemd(logger);
90
+ let result;
91
+ if (process.platform === "darwin") result = await uninstallLaunchd(logger);
92
+ else if (process.platform === "win32") result = await uninstallWindowsTask(logger, { run: serviceRun, stateRoot });
93
+ else result = await uninstallSystemd(logger);
94
+ if (result?.ok === true) removeServiceOwner();
95
+ return result;
51
96
  }
52
97
 
53
98
  export async function autostartStatus() {
@@ -71,7 +116,7 @@ export async function restartAutostart({ logger = console } = {}) {
71
116
  export async function stopAutostart({ logger = console } = {}) {
72
117
  if (process.platform === "darwin") return stopLaunchd(logger);
73
118
  if (process.platform === "win32") return stopWindowsTask(logger, { run: serviceRun });
74
- return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]), { allowAlreadyStopped: true });
119
+ return stopSystemdService(logger);
75
120
  }
76
121
 
77
122
  export function normalizeServiceCommandResult(provider, result, { allowAlreadyStopped = false } = {}) {
@@ -304,15 +349,19 @@ async function startLaunchd(logger) {
304
349
  if (!existsSync(plistPath)) return { ok: false, provider: "launchd", installed: false, loaded: false, active: false, reason: "not_installed" };
305
350
  const before = await statusLaunchd();
306
351
  if (before.active) {
307
- logger.info?.("launchd service is already running");
308
- return { ...before, ok: true, active_before: true, already_running: true, reason: "already_running" };
352
+ const existing = await waitForStableActiveStatus(statusLaunchd);
353
+ if (existing.stable) {
354
+ logger.info?.("launchd service is already running");
355
+ return { ...existing.status, ok: true, active_before: true, already_running: true, reason: "already_running" };
356
+ }
309
357
  }
310
358
  const boot = before.loaded
311
359
  ? { code: 0, stdout: "", stderr: "", already_loaded: true }
312
360
  : await serviceRun("launchctl", ["bootstrap", domainTarget, plistPath]);
313
361
  const kick = await serviceRun("launchctl", ["kickstart", serviceTarget]);
314
- const after = await waitForActiveStatus(statusLaunchd);
315
- const ok = after?.active === true;
362
+ const stability = await waitForStableActiveStatus(statusLaunchd);
363
+ const after = stability.status;
364
+ const ok = stability.stable;
316
365
  if (ok) logger.info?.("launchd service started");
317
366
  else logger.warn?.("launchd service failed to start");
318
367
  return {
@@ -338,14 +387,18 @@ async function restartLaunchd(logger) {
338
387
  const target = launchdServiceTarget();
339
388
  const previousPid = before.pid;
340
389
  const kick = await serviceRun("launchctl", ["kickstart", "-k", target]);
341
- const after = await waitForStatus(
390
+ const changed = await waitForStatus(
342
391
  statusLaunchd,
343
392
  (status) => status?.active === true && (!previousPid || status.pid !== previousPid),
344
393
  );
345
- const ok = kick.code === 0 && after?.active === true && (!previousPid || after.pid !== previousPid);
394
+ const stability = changed?.active === true && (!previousPid || changed.pid !== previousPid)
395
+ ? await waitForStableActiveStatus(statusLaunchd, { identity: (status) => status?.pid || null })
396
+ : { stable: false, status: changed };
397
+ const after = stability.status;
398
+ const ok = kick.code === 0 && stability.stable && (!previousPid || after?.pid !== previousPid);
346
399
  if (ok) logger.info?.("launchd service restarted");
347
400
  else logger.warn?.("launchd service restart could not be verified");
348
- return { ok, provider: "launchd", installed: true, active_before: true, active: after?.active === true, restarted: ok, kickstart: kick };
401
+ return { ok, provider: "launchd", installed: true, active_before: true, active: after?.active === true && stability.stable, restarted: ok, kickstart: kick };
349
402
  }
350
403
 
351
404
  async function stopLaunchd(logger) {
@@ -362,6 +415,7 @@ async function stopLaunchd(logger) {
362
415
  loaded: false,
363
416
  active_before: false,
364
417
  active: false,
418
+ restore_required: false,
365
419
  already_stopped: true,
366
420
  code: 0,
367
421
  stdout: "",
@@ -384,8 +438,9 @@ async function stopLaunchd(logger) {
384
438
  ok,
385
439
  provider: "launchd",
386
440
  installed: existsSync(plistPath),
387
- active_before: true,
441
+ active_before: before.active === true,
388
442
  active,
443
+ restore_required: ok,
389
444
  already_stopped: false,
390
445
  code: ok ? 0 : rawResult.code,
391
446
  bootout_service_target: byServiceTarget,
@@ -469,15 +524,77 @@ ${disable.stderr}`));
469
524
  return { ok, provider: "systemd", path: servicePath, disable, active_check: activeCheck, reload, active: false };
470
525
  }
471
526
 
527
+ export async function stopSystemdService(logger = console, options = {}) {
528
+ const run = typeof options.run === "function" ? options.run : serviceRun;
529
+ const readStatus = typeof options.readStatus === "function" ? options.readStatus : statusSystemd;
530
+ const waitForInactive = typeof options.waitForInactive === "function"
531
+ ? options.waitForInactive
532
+ : callback => waitForInactiveStatus(callback);
533
+ const before = await readStatus();
534
+ if (systemdStatusIsInactive(before)) {
535
+ logger.info?.("systemd service is not active");
536
+ return {
537
+ ok: true,
538
+ provider: "systemd",
539
+ installed: before?.installed === true,
540
+ active_before: false,
541
+ active: false,
542
+ restore_required: false,
543
+ already_stopped: true,
544
+ state: before?.state || "inactive",
545
+ };
546
+ }
547
+ if (before?.active !== true && ["unknown", "maintenance"].includes(before?.state)) {
548
+ return {
549
+ ok: false,
550
+ provider: "systemd",
551
+ installed: before?.installed === true,
552
+ active_before: null,
553
+ active: null,
554
+ restore_required: null,
555
+ reason: "status_unavailable",
556
+ status: before,
557
+ };
558
+ }
559
+ const command = await run("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]);
560
+ const after = await waitForInactive(readStatus);
561
+ const inactive = systemdStatusIsInactive(after);
562
+ if (inactive) logger.info?.("systemd service stopped");
563
+ else logger.warn?.("systemd service stop could not be verified");
564
+ return {
565
+ ok: inactive,
566
+ provider: "systemd",
567
+ installed: after?.installed === true,
568
+ active_before: before?.active === true,
569
+ active: inactive ? false : after?.active ?? null,
570
+ restore_required: inactive && systemdStatusRequiresRestore(before),
571
+ already_stopped: false,
572
+ command,
573
+ status: after,
574
+ reason: inactive ? "stopped" : "stop_not_observed",
575
+ };
576
+ }
577
+
578
+ function systemdStatusIsInactive(status) {
579
+ return status?.installed === false || status?.active === false && ["inactive", "failed"].includes(status?.state);
580
+ }
581
+ function systemdStatusRequiresRestore(status) {
582
+ return status?.active === true || ["activating", "reloading"].includes(status?.state);
583
+ }
584
+
472
585
  async function startSystemd(logger) {
473
586
  const before = await statusSystemd();
474
587
  if (before.active) {
475
- logger.info?.("systemd service is already running");
476
- return { ...before, ok: true, active_before: true, already_running: true, reason: "already_running" };
588
+ const existing = await waitForStableActiveStatus(statusSystemd);
589
+ if (existing.stable) {
590
+ logger.info?.("systemd service is already running");
591
+ return { ...existing.status, ok: true, active_before: true, already_running: true, reason: "already_running" };
592
+ }
477
593
  }
478
594
  const command = await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]);
479
- const after = await waitForActiveStatus(statusSystemd);
480
- const ok = command.code === 0 && after.active === true;
595
+ const stability = await waitForStableActiveStatus(statusSystemd);
596
+ const after = stability.status;
597
+ const ok = command.code === 0 && stability.stable;
481
598
  if (ok) logger.info?.("systemd service started");
482
599
  else logger.warn?.("systemd service failed to start");
483
600
  return { ok, provider: "systemd", installed: after.installed, active_before: false, active: after.active, command };
@@ -485,8 +602,9 @@ async function startSystemd(logger) {
485
602
 
486
603
  async function restartSystemd(logger) {
487
604
  const command = await serviceRun("systemctl", ["--user", "restart", "machine-bridge-mcp.service"]);
488
- const after = await waitForActiveStatus(statusSystemd);
489
- const ok = command.code === 0 && after.active === true;
605
+ const stability = await waitForStableActiveStatus(statusSystemd);
606
+ const after = stability.status;
607
+ const ok = command.code === 0 && stability.stable;
490
608
  if (ok) logger.info?.("systemd service restarted");
491
609
  else logger.warn?.("systemd service restart could not be verified");
492
610
  return { ok, provider: "systemd", installed: after.installed, active: after.active, restarted: ok, command };