machine-bridge-mcp 2.0.0 → 3.0.0-beta.9

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 (143) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/CONTRIBUTING.md +20 -25
  3. package/README.md +21 -14
  4. package/SECURITY.md +147 -123
  5. package/browser-extension/manifest.json +3 -2
  6. package/browser-extension/service-worker.js +31 -12
  7. package/docs/ARCHITECTURE.md +27 -13
  8. package/docs/AUDIT.md +85 -2
  9. package/docs/CLIENTS.md +2 -2
  10. package/docs/ENGINEERING.md +9 -3
  11. package/docs/GETTING_STARTED.md +10 -9
  12. package/docs/LOCAL_AUTHORIZATION.md +115 -72
  13. package/docs/LOGGING.md +12 -3
  14. package/docs/MANAGED_JOBS.md +24 -29
  15. package/docs/MULTI_ACCOUNT.md +98 -37
  16. package/docs/OPERATIONS.md +33 -22
  17. package/docs/OVERVIEW.md +7 -7
  18. package/docs/PROJECT_STANDARDS.md +9 -5
  19. package/docs/RELEASING.md +171 -64
  20. package/docs/TESTING.md +19 -7
  21. package/docs/THREAT_MODEL.md +138 -67
  22. package/docs/TOOL_REFERENCE.md +2 -2
  23. package/docs/UPGRADING.md +102 -18
  24. package/native/macos/MachineBridgeTrustBroker.swift +233 -0
  25. package/package.json +36 -5
  26. package/scripts/candidate-runtime-store.mjs +38 -0
  27. package/scripts/check-plan.mjs +19 -0
  28. package/scripts/check-runner.mjs +4 -1
  29. package/scripts/commit-message-check.mjs +5 -2
  30. package/scripts/coverage-check.mjs +30 -2
  31. package/scripts/github-push.mjs +11 -0
  32. package/scripts/github-release.mjs +38 -7
  33. package/scripts/install-published-prerelease.mjs +167 -0
  34. package/scripts/local-release-acceptance.mjs +23 -12
  35. package/scripts/npm-publication-policy.mjs +16 -0
  36. package/scripts/prerelease-activation.mjs +79 -0
  37. package/scripts/privacy-check.mjs +7 -5
  38. package/scripts/promotion-digest.mjs +121 -0
  39. package/scripts/publish-npm.mjs +51 -0
  40. package/scripts/published-release.mjs +80 -0
  41. package/scripts/release-acceptance.mjs +30 -1
  42. package/scripts/release-candidate-manifest.mjs +40 -0
  43. package/scripts/release-channel.mjs +106 -0
  44. package/scripts/release-impact-check.mjs +12 -22
  45. package/scripts/release-soak.mjs +237 -0
  46. package/scripts/start-release-candidate.mjs +113 -7
  47. package/src/local/account-access.mjs +6 -0
  48. package/src/local/account-admin.mjs +34 -16
  49. package/src/local/agent-context.mjs +45 -42
  50. package/src/local/app-automation.mjs +9 -4
  51. package/src/local/authority-context.mjs +106 -0
  52. package/src/local/browser-bridge-http.mjs +4 -1
  53. package/src/local/browser-bridge.mjs +14 -9
  54. package/src/local/browser-broker-routes.mjs +4 -0
  55. package/src/local/browser-broker-server.mjs +5 -4
  56. package/src/local/browser-extension-identity.mjs +50 -0
  57. package/src/local/browser-extension-protocol.mjs +9 -4
  58. package/src/local/browser-operation-service.mjs +7 -0
  59. package/src/local/browser-pairing-http.mjs +36 -0
  60. package/src/local/browser-pairing-store.mjs +56 -47
  61. package/src/local/cli-account-admin.mjs +41 -7
  62. package/src/local/cli-activate.mjs +90 -0
  63. package/src/local/cli-approval.mjs +14 -58
  64. package/src/local/cli-local-admin.mjs +5 -15
  65. package/src/local/cli-options.mjs +11 -7
  66. package/src/local/cli-service.mjs +35 -5
  67. package/src/local/cli.mjs +112 -58
  68. package/src/local/daemon-process.mjs +6 -0
  69. package/src/local/delegated-process-sandbox.mjs +147 -0
  70. package/src/local/device-identity.mjs +157 -48
  71. package/src/local/device-root-provider.mjs +87 -0
  72. package/src/local/errors.mjs +25 -3
  73. package/src/local/git-service.mjs +16 -15
  74. package/src/local/job-runner.mjs +35 -27
  75. package/src/local/log.mjs +14 -1
  76. package/src/local/macos-trust-broker.mjs +320 -0
  77. package/src/local/managed-job-lock.mjs +18 -0
  78. package/src/local/managed-job-projection.mjs +9 -1
  79. package/src/local/managed-job-runner.mjs +13 -1
  80. package/src/local/managed-job-storage.mjs +2 -1
  81. package/src/local/managed-job-terminal.mjs +138 -0
  82. package/src/local/managed-jobs.mjs +88 -32
  83. package/src/local/operation-authorization.mjs +75 -239
  84. package/src/local/operation-risk.mjs +28 -0
  85. package/src/local/operation-state-lock.mjs +7 -86
  86. package/src/local/owner-state-lock.mjs +104 -0
  87. package/src/local/policy.mjs +19 -0
  88. package/src/local/process-execution.mjs +21 -9
  89. package/src/local/process-sessions.mjs +27 -17
  90. package/src/local/process-tracker.mjs +30 -6
  91. package/src/local/process-tree-ownership.mjs +59 -0
  92. package/src/local/process-tree.mjs +22 -18
  93. package/src/local/relay-connection.mjs +47 -35
  94. package/src/local/relay-diagnostics.mjs +65 -0
  95. package/src/local/remote-configuration.mjs +48 -0
  96. package/src/local/runtime-activation.mjs +141 -0
  97. package/src/local/runtime-diagnostics.mjs +21 -0
  98. package/src/local/runtime-relay.mjs +9 -8
  99. package/src/local/runtime-reporting.mjs +8 -3
  100. package/src/local/runtime-tool-handlers.mjs +8 -8
  101. package/src/local/runtime.mjs +73 -39
  102. package/src/local/secure-file.mjs +5 -1
  103. package/src/local/security-audit-log.mjs +204 -0
  104. package/src/local/service-convergence.mjs +17 -1
  105. package/src/local/service-ownership.mjs +17 -0
  106. package/src/local/service-restart-handoff.mjs +50 -0
  107. package/src/local/service-restart-scheduler.mjs +49 -0
  108. package/src/local/service-status.mjs +47 -0
  109. package/src/local/service.mjs +142 -43
  110. package/src/local/state-inventory.mjs +21 -3
  111. package/src/local/state.mjs +155 -17
  112. package/src/local/stdio.mjs +5 -0
  113. package/src/local/tool-executor.mjs +39 -6
  114. package/src/local/tool-result-boundary.mjs +51 -0
  115. package/src/local/tools.mjs +1 -0
  116. package/src/local/trusted-executable.mjs +53 -0
  117. package/src/local/trusted-git-executable.mjs +34 -0
  118. package/src/local/trusted-github-cli.mjs +24 -0
  119. package/src/local/windows-service.mjs +20 -0
  120. package/src/local/worker-deployment.mjs +3 -4
  121. package/src/local/worker-secret-file.mjs +2 -2
  122. package/src/local/workspace-file-service.mjs +34 -31
  123. package/src/shared/admin-auth.d.mts +2 -1
  124. package/src/shared/admin-auth.mjs +3 -3
  125. package/src/shared/device-session-auth.d.mts +20 -0
  126. package/src/shared/device-session-auth.mjs +51 -0
  127. package/src/shared/server-metadata.json +1 -1
  128. package/src/shared/tool-catalog.json +2 -2
  129. package/src/worker/account-admin.ts +23 -22
  130. package/src/worker/authority.ts +9 -2
  131. package/src/worker/daemon-auth.ts +45 -67
  132. package/src/worker/daemon-sockets.ts +12 -2
  133. package/src/worker/device-session-verifier.ts +129 -0
  134. package/src/worker/dpop.ts +151 -0
  135. package/src/worker/http.ts +47 -16
  136. package/src/worker/index.ts +53 -16
  137. package/src/worker/nonce-store.ts +1 -5
  138. package/src/worker/oauth-client-admin.ts +45 -0
  139. package/src/worker/oauth-controller.ts +55 -6
  140. package/src/worker/oauth-refresh-families.ts +21 -7
  141. package/src/worker/oauth-state.ts +6 -0
  142. package/src/worker/oauth-tokens.ts +28 -8
  143. package/src/worker/websocket-protocol.ts +8 -2
@@ -0,0 +1,49 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { BridgeError } from "./errors.mjs";
4
+
5
+ const DEFAULT_HANDOFF_DELAY_MS = 300;
6
+
7
+ export function scheduleServiceRestart(options = {}) {
8
+ const platform = String(options.platform || process.platform);
9
+ if (platform === "win32") {
10
+ throw new BridgeError("unavailable", "in-process Windows service restart is not behavior-verified; run `machine-mcp service stop` and then `machine-mcp service start` from an independent terminal");
11
+ }
12
+ const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
13
+ const node = String(options.node || process.execPath);
14
+ const helper = String(options.helper || fileURLToPath(new URL("./service-restart-handoff.mjs", import.meta.url)));
15
+ const delayMs = boundedPositiveInteger(options.delayMs, DEFAULT_HANDOFF_DELAY_MS);
16
+ const child = spawnProcess(node, [helper, String(delayMs)], {
17
+ detached: true,
18
+ stdio: "ignore",
19
+ windowsHide: true,
20
+ shell: false,
21
+ env: serviceControlEnvironment(options.env || process.env),
22
+ });
23
+ return new Promise((resolvePromise, rejectPromise) => {
24
+ let settled = false;
25
+ const settle = (callback) => {
26
+ if (settled) return;
27
+ settled = true;
28
+ callback();
29
+ };
30
+ child.once("spawn", () => settle(() => {
31
+ child.unref?.();
32
+ resolvePromise({ ok: true, scheduled: true, delay_ms: delayMs });
33
+ }));
34
+ child.once("error", (error) => settle(() => rejectPromise(error)));
35
+ });
36
+ }
37
+
38
+ export function serviceControlEnvironment(source = {}) {
39
+ const env = {};
40
+ for (const key of ["PATH", "HOME", "USERPROFILE", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "LANG", "LC_ALL", "LC_CTYPE"]) {
41
+ if (typeof source[key] === "string" && source[key]) env[key] = source[key];
42
+ }
43
+ return env;
44
+ }
45
+
46
+ function boundedPositiveInteger(value, fallback) {
47
+ const parsed = Number(value);
48
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
49
+ }
@@ -0,0 +1,47 @@
1
+ const SYSTEMD_STATES = new Set(["active", "inactive", "failed", "activating", "deactivating", "reloading", "maintenance", "unknown"]);
2
+
3
+ export function launchdStatusSummary({ installed, definition, result }) {
4
+ const detail = String(result?.stdout || "");
5
+ const loaded = Number(result?.code) === 0;
6
+ const state = launchdField(detail, "state") || (loaded ? "loaded" : "inactive");
7
+ const pid = positiveMatch(detail, /\bpid = (\d+)/);
8
+ return {
9
+ ok: Boolean(installed),
10
+ provider: "launchd",
11
+ installed: Boolean(installed),
12
+ definition: String(definition || ""),
13
+ loaded,
14
+ active: loaded && (pid !== null || state === "running"),
15
+ state,
16
+ pid,
17
+ runs: positiveMatch(detail, /\bruns = (\d+)/),
18
+ last_termination_signal: textMatch(detail, /\blast terminating signal = ([^\n]+)/),
19
+ };
20
+ }
21
+
22
+ export function systemdStatusSummary({ installed, definition, result }) {
23
+ const firstLine = String(result?.stdout || "").split(/\r?\n/, 1)[0].trim().toLowerCase();
24
+ const state = SYSTEMD_STATES.has(firstLine) ? firstLine : "unknown";
25
+ return {
26
+ ok: Boolean(installed),
27
+ provider: "systemd",
28
+ installed: Boolean(installed),
29
+ definition: String(definition || ""),
30
+ active: state === "active",
31
+ state,
32
+ };
33
+ }
34
+
35
+ function launchdField(value, field) {
36
+ const escaped = String(field || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ return textMatch(value, new RegExp(`\\b${escaped} = ([^\\n]+)`));
38
+ }
39
+
40
+ function textMatch(value, pattern) {
41
+ return pattern.exec(String(value || ""))?.[1]?.trim() || null;
42
+ }
43
+
44
+ function positiveMatch(value, pattern) {
45
+ const parsed = Number(textMatch(value, pattern));
46
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
47
+ }
@@ -5,10 +5,12 @@ 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 { waitForInactiveStatus } from "./service-convergence.mjs";
8
+ import { waitForActiveStatus, waitForInactiveStatus, waitForStatus } from "./service-convergence.mjs";
9
+ import { launchdStatusSummary, systemdStatusSummary } from "./service-status.mjs";
9
10
  import { writeServiceEnvironment } from "./service-environment.mjs";
10
11
  import {
11
12
  installWindowsTask,
13
+ restartWindowsTask,
12
14
  startWindowsTask,
13
15
  statusWindowsTask,
14
16
  stopWindowsTask,
@@ -18,7 +20,7 @@ export { windowsCommandLineArgument } from "./windows-service.mjs";
18
20
 
19
21
  const LABEL = "dev.machine-bridge-mcp.daemon";
20
22
  const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
21
- const AUTOSTART_LOG_SCHEMA_VERSION = 3;
23
+ const AUTOSTART_LOG_SCHEMA_VERSION = 4;
22
24
 
23
25
  function serviceRun(command, args) {
24
26
  return runServiceCommand(command, args);
@@ -57,7 +59,13 @@ export async function autostartStatus() {
57
59
  export async function startAutostart({ logger = console } = {}) {
58
60
  if (process.platform === "darwin") return startLaunchd(logger);
59
61
  if (process.platform === "win32") return startWindowsTask(logger, { run: serviceRun });
60
- return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]));
62
+ return startSystemd(logger);
63
+ }
64
+
65
+ export async function restartAutostart({ logger = console } = {}) {
66
+ if (process.platform === "darwin") return restartLaunchd(logger);
67
+ if (process.platform === "win32") return restartWindowsTask(logger, { run: serviceRun });
68
+ return restartSystemd(logger);
61
69
  }
62
70
 
63
71
  export async function stopAutostart({ logger = console } = {}) {
@@ -84,33 +92,33 @@ export function trimAutostartLogs(stateRoot, options = {}) {
84
92
  const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
85
93
  const schemaVersion = String(AUTOSTART_LOG_SCHEMA_VERSION);
86
94
  const logs = path.join(root, "logs");
95
+ ensureOwnerOnlyDir(logs);
87
96
  const schemaFile = path.join(logs, ".log-schema");
88
97
  const reset = readLogSchema(schemaFile) !== schemaVersion;
89
-
90
- for (const name of ["daemon.out.log", "daemon.err.log"]) {
91
- const file = path.join(logs, name);
92
- let fd;
93
- try {
94
- const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
95
- label: "autostart log path",
96
- chmod: 0o600,
97
- });
98
- fd = opened.fd;
98
+ const openedLogs = [];
99
+ try {
100
+ for (const name of ["daemon.out.log", "daemon.err.log"]) {
101
+ const file = path.join(logs, name);
102
+ ensurePrivateLogFile(file);
103
+ openedLogs.push(openRegularFileSync(file, fsConstants.O_RDWR, {
104
+ label: "autostart log path", chmod: 0o600, rejectMultipleLinks: true,
105
+ }));
106
+ }
107
+ for (const opened of openedLogs) {
99
108
  if (reset) {
100
- ftruncateSync(fd, 0);
109
+ ftruncateSync(opened.fd, 0);
101
110
  } else if (opened.info.size > maxBytes) {
102
- const tail = readLogTail(fd, opened.info.size, keepBytes);
103
- ftruncateSync(fd, 0);
104
- if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
111
+ const tail = readLogTail(opened.fd, opened.info.size, keepBytes);
112
+ ftruncateSync(opened.fd, 0);
113
+ if (tail.length) writeSync(opened.fd, tail, 0, tail.length, 0);
105
114
  }
106
- } catch {
107
- // Log maintenance is best effort and must not stop daemon startup.
108
- } finally {
109
- if (fd !== undefined) try { closeSync(fd); } catch {}
115
+ }
116
+ writePrivateServiceFile(schemaFile, `${schemaVersion}\n`);
117
+ } finally {
118
+ for (const opened of openedLogs) {
119
+ try { closeSync(opened.fd); } catch {}
110
120
  }
111
121
  }
112
-
113
- try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
114
122
  }
115
123
 
116
124
  function readLogSchema(file) {
@@ -179,26 +187,30 @@ export function stableNodeExecutable(options = {}) {
179
187
 
180
188
  export function serviceEnvironmentPath(options = {}) {
181
189
  const platform = String(options.platform || process.platform);
190
+ const pathApi = platform === "win32" ? path.win32 : path;
182
191
  const delimiter = String(options.delimiter || (platform === "win32" ? ";" : ":"));
183
192
  const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
184
193
  const node = String(options.node || process.execPath || "");
185
194
  const entryScript = String(options.entryScript || "");
195
+ const entryDirectory = entryScript ? pathApi.dirname(pathApi.resolve(entryScript)) : "";
186
196
  const defaults = platform === "darwin"
187
197
  ? ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
188
198
  : platform === "win32"
189
199
  ? []
190
200
  : ["/usr/local/bin", "/usr/bin", "/bin", "/usr/local/sbin", "/usr/sbin", "/sbin"];
201
+ const inheritedEntries = stripNpmLifecyclePathPrefix(pathEnv.split(delimiter));
191
202
  const candidates = [
192
- node ? path.dirname(path.resolve(node)) : "",
193
- entryScript ? path.dirname(path.resolve(entryScript)) : "",
194
- ...pathEnv.split(delimiter),
203
+ node ? pathApi.dirname(pathApi.resolve(node)) : "",
204
+ entryDirectory,
205
+ ...inheritedEntries,
195
206
  ...defaults,
196
207
  ];
197
208
  const seen = new Set();
198
209
  const entries = [];
199
210
  for (const raw of candidates) {
200
- if (!raw || !path.isAbsolute(raw)) continue;
201
- const normalized = path.resolve(raw);
211
+ if (!raw || !pathApi.isAbsolute(raw)) continue;
212
+ const normalized = pathApi.resolve(raw);
213
+ if (isInactiveCandidateRuntimePath(normalized, entryDirectory, platform)) continue;
202
214
  const key = platform === "win32" ? normalized.toLowerCase() : normalized;
203
215
  if (seen.has(key)) continue;
204
216
  seen.add(key);
@@ -207,6 +219,33 @@ export function serviceEnvironmentPath(options = {}) {
207
219
  return entries.join(delimiter);
208
220
  }
209
221
 
222
+ function stripNpmLifecyclePathPrefix(entries) {
223
+ let lastMarker = -1;
224
+ for (let index = 0; index < entries.length; index += 1) {
225
+ const portable = String(entries[index] || "").replaceAll("\\", "/").toLowerCase();
226
+ if (portable.endsWith("/node-gyp-bin") && portable.includes("/@npmcli/run-script/")) lastMarker = index;
227
+ }
228
+ return lastMarker >= 0 ? entries.slice(lastMarker + 1) : entries;
229
+ }
230
+
231
+ function isInactiveCandidateRuntimePath(candidate, entryDirectory, platform) {
232
+ if (!entryDirectory) return false;
233
+ const pathApi = platform === "win32" ? path.win32 : path;
234
+ const normalize = (value) => {
235
+ const resolved = pathApi.resolve(value);
236
+ return platform === "win32" ? resolved.toLowerCase() : resolved;
237
+ };
238
+ const current = normalize(entryDirectory);
239
+ const portable = current.replaceAll("\\", "/");
240
+ const marker = "/release-channels/runtimes/";
241
+ const markerIndex = portable.indexOf(marker);
242
+ if (markerIndex < 0) return false;
243
+ const runtimeContainer = portable.slice(0, markerIndex + marker.length - 1);
244
+ const normalizedCandidate = normalize(candidate).replaceAll("\\", "/");
245
+ return normalizedCandidate !== portable
246
+ && (normalizedCandidate === runtimeContainer || normalizedCandidate.startsWith(`${runtimeContainer}/`));
247
+ }
248
+
210
249
  export function daemonArgs(spec) {
211
250
  return [
212
251
  spec.entryScript,
@@ -223,7 +262,7 @@ function ensurePrivateLogFile(file) {
223
262
  const opened = openRegularFileSync(
224
263
  file,
225
264
  Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
226
- { label: "autostart log path", mode: 0o600, chmod: 0o600 },
265
+ { label: "autostart log path", mode: 0o600, chmod: 0o600, rejectMultipleLinks: true },
227
266
  );
228
267
  closeSync(opened.fd);
229
268
  }
@@ -260,16 +299,53 @@ async function installLaunchd(spec, logger) {
260
299
 
261
300
  async function startLaunchd(logger) {
262
301
  const plistPath = launchdPlistPath();
263
- const target = launchdDomainTarget();
264
- if (!existsSync(plistPath)) return { ok: false, error: "launchd plist not installed" };
265
- const stopped = await stopLaunchd({ info() {}, warn() {} });
266
- if (!stopped.ok) return { ok: false, provider: "launchd", stop: stopped };
267
- const boot = await serviceRun("launchctl", ["bootstrap", target, plistPath]);
268
- const kick = await serviceRun("launchctl", ["kickstart", "-k", `${target}/${LABEL}`]);
269
- const ok = boot.code === 0 || kick.code === 0;
302
+ const domainTarget = launchdDomainTarget();
303
+ const serviceTarget = launchdServiceTarget();
304
+ if (!existsSync(plistPath)) return { ok: false, provider: "launchd", installed: false, loaded: false, active: false, reason: "not_installed" };
305
+ const before = await statusLaunchd();
306
+ 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" };
309
+ }
310
+ const boot = before.loaded
311
+ ? { code: 0, stdout: "", stderr: "", already_loaded: true }
312
+ : await serviceRun("launchctl", ["bootstrap", domainTarget, plistPath]);
313
+ const kick = await serviceRun("launchctl", ["kickstart", serviceTarget]);
314
+ const after = await waitForActiveStatus(statusLaunchd);
315
+ const ok = after?.active === true;
270
316
  if (ok) logger.info?.("launchd service started");
271
317
  else logger.warn?.("launchd service failed to start");
272
- return { ok, provider: "launchd", bootstrap: boot, kickstart: kick };
318
+ return {
319
+ ok,
320
+ provider: "launchd",
321
+ installed: true,
322
+ loaded: after?.loaded === true,
323
+ active_before: false,
324
+ active: after?.active === true,
325
+ bootstrap: boot,
326
+ kickstart: kick,
327
+ };
328
+ }
329
+
330
+ async function restartLaunchd(logger) {
331
+ const plistPath = launchdPlistPath();
332
+ if (!existsSync(plistPath)) return { ok: false, provider: "launchd", installed: false, reason: "not_installed" };
333
+ const before = await statusLaunchd();
334
+ if (!before.active) {
335
+ const started = await startLaunchd(logger);
336
+ return { ...started, restarted: false, reason: started.ok ? "started_inactive_service" : started.reason || "start_failed" };
337
+ }
338
+ const target = launchdServiceTarget();
339
+ const previousPid = before.pid;
340
+ const kick = await serviceRun("launchctl", ["kickstart", "-k", target]);
341
+ const after = await waitForStatus(
342
+ statusLaunchd,
343
+ (status) => status?.active === true && (!previousPid || status.pid !== previousPid),
344
+ );
345
+ const ok = kick.code === 0 && after?.active === true && (!previousPid || after.pid !== previousPid);
346
+ if (ok) logger.info?.("launchd service restarted");
347
+ 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 };
273
349
  }
274
350
 
275
351
  async function stopLaunchd(logger) {
@@ -277,12 +353,13 @@ async function stopLaunchd(logger) {
277
353
  const domainTarget = launchdDomainTarget();
278
354
  const serviceTarget = launchdServiceTarget();
279
355
  const before = await statusLaunchd();
280
- if (!before.active) {
356
+ if (!before.loaded) {
281
357
  logger.info?.("launchd service is not loaded");
282
358
  return {
283
359
  ok: true,
284
360
  provider: "launchd",
285
361
  installed: existsSync(plistPath),
362
+ loaded: false,
286
363
  active_before: false,
287
364
  active: false,
288
365
  already_stopped: true,
@@ -327,9 +404,8 @@ async function uninstallLaunchd(logger) {
327
404
 
328
405
  async function statusLaunchd() {
329
406
  const plistPath = launchdPlistPath();
330
- const target = launchdServiceTarget();
331
- const result = await serviceRun("launchctl", ["print", target]);
332
- return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
407
+ const result = await serviceRun("launchctl", ["print", launchdServiceTarget()]);
408
+ return launchdStatusSummary({ installed: existsSync(plistPath), definition: LABEL, result });
333
409
  }
334
410
 
335
411
  export function launchdPlist({ args, pathEnv, stdout, stderr }) {
@@ -393,10 +469,33 @@ ${disable.stderr}`));
393
469
  return { ok, provider: "systemd", path: servicePath, disable, active_check: activeCheck, reload, active: false };
394
470
  }
395
471
 
472
+ async function startSystemd(logger) {
473
+ const before = await statusSystemd();
474
+ 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" };
477
+ }
478
+ 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;
481
+ if (ok) logger.info?.("systemd service started");
482
+ else logger.warn?.("systemd service failed to start");
483
+ return { ok, provider: "systemd", installed: after.installed, active_before: false, active: after.active, command };
484
+ }
485
+
486
+ async function restartSystemd(logger) {
487
+ 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;
490
+ if (ok) logger.info?.("systemd service restarted");
491
+ else logger.warn?.("systemd service restart could not be verified");
492
+ return { ok, provider: "systemd", installed: after.installed, active: after.active, restarted: ok, command };
493
+ }
494
+
396
495
  async function statusSystemd() {
397
496
  const servicePath = systemdPath();
398
- const result = await serviceRun("systemctl", ["--user", "status", "machine-bridge-mcp.service", "--no-pager"]);
399
- return { ok: existsSync(servicePath), provider: "systemd", installed: existsSync(servicePath), path: servicePath, active: result.code === 0, detail: result.stdout || result.stderr };
497
+ const result = await serviceRun("systemctl", ["--user", "is-active", "machine-bridge-mcp.service"]);
498
+ return systemdStatusSummary({ installed: existsSync(servicePath), definition: "machine-bridge-mcp.service", result });
400
499
  }
401
500
 
402
501
  export function systemdUnit(spec) {
@@ -1,11 +1,13 @@
1
1
  import { existsSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { activeManagedJobs } from "./managed-jobs.mjs";
4
+ import { activeManagedJobLock } from "./managed-job-lock.mjs";
4
5
  import { inspectProcessInstance } from "./process-identity.mjs";
5
6
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
6
7
  import { STATE_SCHEMA_VERSION, expandHome, readDaemonLockOwner, resolveWorkspace } from "./state.mjs";
7
8
 
8
9
  const PROFILE_NAME = /^[a-f0-9]{24}$/;
10
+ const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
9
11
  const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10
12
  const MAX_STATE_BYTES = 2 * 1024 * 1024;
11
13
 
@@ -17,7 +19,7 @@ export function knownWorkerNames(stateRoot) {
17
19
  const profileDir = resolve(profiles, entry.name);
18
20
  const stateFile = resolve(profileDir, "state.json");
19
21
  if (!existsSync(stateFile)) {
20
- const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "daemon.lock");
22
+ const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "state.json.recovery-required" || name === "daemon.lock");
21
23
  if (evidence) throw unreadableWorkerState(entry.name);
22
24
  continue;
23
25
  }
@@ -101,8 +103,14 @@ export function activeStateLocks(stateRoot) {
101
103
  if (!existsSync(profiles)) return [];
102
104
  const active = [];
103
105
  for (const profile of profileDirectories(profiles)) {
104
- for (const [kind, name] of [["daemon", "daemon.lock"], ["startup", "startup.lock"]]) {
105
- const lockPath = resolve(profiles, profile.name, name);
106
+ const profileDir = resolve(profiles, profile.name);
107
+ for (const [kind, name] of [
108
+ ["daemon", "daemon.lock"],
109
+ ["startup", "startup.lock"],
110
+ ["operation-authorization", "operation-authorization.lock"],
111
+ ["security-audit", "security-audit.lock"],
112
+ ]) {
113
+ const lockPath = resolve(profileDir, name);
106
114
  if (!existsSync(lockPath)) continue;
107
115
  const owner = readDaemonLockOwner(lockPath);
108
116
  if (!owner) {
@@ -115,6 +123,16 @@ export function activeStateLocks(stateRoot) {
115
123
  active.push({ kind, pid: owner.pid, path: lockPath, reason: identity.reason });
116
124
  }
117
125
  }
126
+ const jobRoot = resolve(profileDir, "jobs");
127
+ if (!existsSync(jobRoot)) continue;
128
+ for (const entry of readdirSync(jobRoot, { withFileTypes: true })) {
129
+ if (!entry.isDirectory() || !JOB_ID.test(entry.name)) continue;
130
+ for (const [kind, name] of [["job-transition", "transition.lock"], ["job-recovery", "recovery.lock"]]) {
131
+ const lockPath = resolve(jobRoot, entry.name, name);
132
+ const lock = activeManagedJobLock(lockPath);
133
+ if (lock?.active) active.push({ kind, pid: lock.pid, path: lockPath, reason: lock.reason, job_id: entry.name });
134
+ }
135
+ }
118
136
  }
119
137
  return active;
120
138
  }