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,90 @@
1
+ import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon } from "./daemon-process.mjs";
2
+ import { effectiveLogFormat, effectiveLogLevel } from "./cli-options.mjs";
3
+ import { createLogger } from "./log.mjs";
4
+ import { activatePersistentRuntime } from "./runtime-activation.mjs";
5
+ import { installAutostart, startAutostart, stopAutostart } from "./service.mjs";
6
+ import { acquireStartupLockWithWait, loadState } from "./state.mjs";
7
+ import { workerHealth } from "./worker-health.mjs";
8
+
9
+ export function createActivateCommand({
10
+ chooseWorkspace,
11
+ prepareRemoteState,
12
+ createRemoteRuntime,
13
+ currentPackageVersion,
14
+ assertNodeVersion,
15
+ structuredLogger,
16
+ } = {}) {
17
+ for (const [name, value] of Object.entries({
18
+ chooseWorkspace,
19
+ prepareRemoteState,
20
+ createRemoteRuntime,
21
+ currentPackageVersion,
22
+ assertNodeVersion,
23
+ structuredLogger,
24
+ })) {
25
+ if (typeof value !== "function") throw new TypeError(`activate command requires ${name}`);
26
+ }
27
+
28
+ return async function activateCommand(args) {
29
+ assertNodeVersion();
30
+ const logger = createLogger({
31
+ level: args.json ? "error" : effectiveLogLevel(args),
32
+ format: effectiveLogFormat(args),
33
+ component: "activate",
34
+ });
35
+ const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: true, allowPositional: true });
36
+ const state = loadState(workspace, { stateDir: args.stateDir });
37
+ if (!state.worker?.url) {
38
+ throw new Error("activate requires an existing deployment; run machine-mcp once interactively before persistent activation");
39
+ }
40
+ const activationArgs = { ...args, noAutostart: true, daemonOnly: false };
41
+ const expectedVersion = currentPackageVersion();
42
+ const result = await activatePersistentRuntime({
43
+ expectedVersion,
44
+ acquireStartupLock: () => acquireStartupLockWithWait(state, { operation: "activate", logger }),
45
+ stopAutostart: () => stopAutostart({ logger: structuredLogger(true) }),
46
+ acquireDaemonLock: () => acquireDaemonLockWithTakeover(state, {
47
+ takeOverServiceOwner: true,
48
+ ownerMetadata: { mode: "foreground", version: expectedVersion },
49
+ logger,
50
+ }),
51
+ prepareRemoteState: () => prepareRemoteState({ args: activationArgs, workspace, state, logger }),
52
+ createRuntime: ({ daemonLock, readiness }) => createRemoteRuntime({
53
+ args: activationArgs,
54
+ workspace,
55
+ state,
56
+ daemonLock,
57
+ deviceSessionIdentity: readiness.deviceSessionIdentity,
58
+ exitOnTerminal: false,
59
+ }),
60
+ installAutostart: () => installAutostart({
61
+ workspace,
62
+ stateRoot: state.paths.stateRoot,
63
+ entryScript: process.argv[1],
64
+ logger: structuredLogger(true),
65
+ }),
66
+ startAutostart: () => startAutostart({ logger: structuredLogger(true) }),
67
+ inspectDaemon: async () => inspectWorkspaceDaemon(state),
68
+ checkWorker: async () => workerHealth(state.worker.url, expectedVersion, { expectedWorkerName: state.worker.name }),
69
+ });
70
+ const convergence = result.convergence;
71
+ const payload = {
72
+ ok: true,
73
+ version: expectedVersion,
74
+ workspace: state.workspace.path,
75
+ worker: { name: state.worker.name, url: state.worker.url, health: convergence.worker },
76
+ daemon: convergence.daemon,
77
+ service: result.serviceStart,
78
+ candidate_relay_verified_before_handoff: result.candidateRelayVerified,
79
+ };
80
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
81
+ else {
82
+ logger.success(`Persistent runtime activated: ${expectedVersion}`);
83
+ logger.safePlain(` Worker: ${state.worker.name}`);
84
+ logger.safePlain(` Daemon: pid ${convergence.daemon.pid}`);
85
+ logger.safePlain(" Candidate relay readiness was verified before the login service handoff.");
86
+ logger.safePlain(" The login service now owns the daemon; the activation terminal may close.");
87
+ }
88
+ return payload;
89
+ };
90
+ }
@@ -1,14 +1,11 @@
1
1
  import {
2
- OPERATION_APPROVAL_SCOPES,
3
- approvePendingOperation,
4
2
  clearOperationLeases,
5
- grantOperationLease,
6
3
  listOperationApprovals,
7
4
  revokeOperationLease,
8
5
  } from "./operation-authorization.mjs";
9
6
  import { loadState } from "./state.mjs";
10
7
 
11
- const ACTIONS = new Set(["list", "approve", "grant", "revoke", "clear"]);
8
+ const ACTIONS = new Set(["list", "revoke", "clear"]);
12
9
 
13
10
  export function createApprovalCommand({ chooseWorkspace, confirm }) {
14
11
  if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
@@ -16,85 +13,44 @@ export function createApprovalCommand({ chooseWorkspace, confirm }) {
16
13
  }
17
14
  return async function approvalCommand(args) {
18
15
  const action = String(args._[0] || "list").toLowerCase();
19
- if (!ACTIONS.has(action)) throw new Error(`Unknown approval action: ${action}`);
16
+ if (!ACTIONS.has(action)) {
17
+ throw new Error("Terminal operation approval was removed. Remote calls run automatically within the account role ceiling; use account management to change trust and approval revoke/clear only to remove legacy leases.");
18
+ }
20
19
  const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
21
20
  const state = loadState(workspace, { stateDir: args.stateDir });
22
21
  const root = state.paths.profileDir;
23
22
  if (action === "list") return renderList(listOperationApprovals(root), args.json === true);
24
- if (action === "approve") {
25
- const id = requiredPositional(args, 1, "approval approve requires APPROVAL_ID");
26
- return renderLease(await approvePendingOperation(
27
- root,
28
- id,
29
- args.duration || (args.full ? "8h" : "1h"),
30
- Date.now(),
31
- args.full ? "full" : "",
32
- ), args.json === true, "Approved");
33
- }
34
- if (action === "grant") {
35
- const scope = requiredPositional(args, 1, "approval grant requires SCOPE");
36
- if (!OPERATION_APPROVAL_SCOPES.includes(scope)) {
37
- throw new Error(`approval scope must be one of: ${OPERATION_APPROVAL_SCOPES.join(", ")}`);
38
- }
39
- const clientId = String(args.client || "");
40
- const accountId = String(args.account || "");
41
- if (!clientId) throw new Error("approval grant requires --client CLIENT_ID; use * only for an intentional all-client lease");
42
- if (!accountId) throw new Error("approval grant requires --account ACCOUNT_ID; use * only for an intentional all-account lease");
43
- return renderLease(await grantOperationLease(root, {
44
- accountId,
45
- clientId,
46
- scope,
47
- duration: args.duration || "1h",
48
- }), args.json === true, "Granted");
49
- }
50
23
  if (action === "revoke") {
51
24
  const id = requiredPositional(args, 1, "approval revoke requires LEASE_ID");
52
25
  const removed = await revokeOperationLease(root, id);
53
26
  if (args.json) console.log(JSON.stringify({ lease_id: id, revoked: removed }, null, 2));
54
- else console.log(removed ? `Revoked capability lease: ${id}` : `Capability lease was not active: ${id}`);
27
+ else console.log(removed ? `Revoked legacy capability lease: ${id}` : `Legacy capability lease was not active: ${id}`);
55
28
  return;
56
29
  }
57
- const approved = await confirm("Revoke all active remote capability leases?", args.yes === true);
30
+ const approved = await confirm("Revoke all legacy remote capability leases?", args.yes === true);
58
31
  if (!approved) {
59
- console.log("No capability leases were changed.");
32
+ console.log("No legacy capability leases were changed.");
60
33
  return;
61
34
  }
62
35
  await clearOperationLeases(root);
63
36
  if (args.json) console.log(JSON.stringify({ cleared: true }, null, 2));
64
- else console.log("Revoked all active remote capability leases.");
37
+ else console.log("Revoked all legacy remote capability leases.");
65
38
  };
66
39
  }
67
40
 
68
41
  function renderList(result, json) {
69
42
  if (json) {
70
- console.log(JSON.stringify(result, null, 2));
43
+ console.log(JSON.stringify({ ...result, runtime_authorization: "automatic-within-role-ceiling" }, null, 2));
71
44
  return;
72
45
  }
73
- if (!result.pending.length && !result.leases.length) {
74
- console.log("No pending approvals or active capability leases.");
46
+ if (!result.leases.length) {
47
+ console.log("No active legacy capability leases. Runtime authorization is automatic within each account role ceiling.");
75
48
  return;
76
49
  }
77
- if (result.pending.length) {
78
- console.log("Pending approvals:");
79
- for (const item of result.pending) {
80
- console.log(` ${item.id}\t${scopeText(item.scopes)}\t${item.category}\tclient ${shortId(item.client_id)}\texpires ${formatTime(item.expires_at)}`);
81
- }
82
- }
83
- if (result.leases.length) {
84
- console.log("Active capability leases:");
85
- for (const item of result.leases) {
86
- console.log(` ${item.id}\t${scopeText(item.scopes)}\tclient ${shortId(item.client_id)}\texpires ${formatTime(item.expires_at)}`);
87
- }
88
- }
89
- }
90
-
91
- function renderLease(lease, json, verb) {
92
- if (json) {
93
- console.log(JSON.stringify(lease, null, 2));
94
- return;
50
+ console.log("Legacy capability leases (not consumed by the current runtime):");
51
+ for (const item of result.leases) {
52
+ console.log(` ${item.id}\t${scopeText(item.scopes)}\tclient ${shortId(item.client_id)}\texpires ${formatTime(item.expires_at)}`);
95
53
  }
96
- console.log(`${verb} ${scopeText(lease.scopes)} capability lease for client ${shortId(lease.client_id)} until ${formatTime(lease.expires_at)}.`);
97
- console.log(`Lease ID: ${lease.id}`);
98
54
  }
99
55
 
100
56
  function requiredPositional(args, index, message) {
@@ -244,7 +244,10 @@ function readBrowserPairingState(pairingFile) {
244
244
  throw new Error("browser bridge state is invalid; restart machine-mcp to repair it");
245
245
  }
246
246
  const port = Number(pairing.port);
247
- if (!/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.token || ""))) throw new Error("browser bridge state contains an invalid token");
247
+ const extensionToken = String(pairing.extensionToken || pairing.token || "");
248
+ if (!/^[A-Za-z0-9_-]{32,100}$/.test(extensionToken) || (pairing.runtimeToken !== undefined && !/^[A-Za-z0-9_-]{32,100}$/.test(String(pairing.runtimeToken)))) {
249
+ throw new Error("browser bridge state contains invalid bounded tokens");
250
+ }
248
251
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("browser bridge state contains an invalid port");
249
252
  return { port };
250
253
  }
@@ -302,7 +305,7 @@ function openExternal(target) {
302
305
  });
303
306
  }
304
307
 
305
- async function jobCommand(args, { chooseWorkspace, confirm }) {
308
+ async function jobCommand(args, { chooseWorkspace }) {
306
309
  const action = String(args._[0] || "list").toLowerCase();
307
310
  const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
308
311
  const state = loadState(workspace, { stateDir: args.stateDir });
@@ -320,19 +323,6 @@ async function jobCommand(args, { chooseWorkspace, confirm }) {
320
323
  else if (action === "read") result = manager.read({ job_id: args._[1] });
321
324
  else if (action === "inspect") result = manager.inspectLocal({ job_id: args._[1] });
322
325
  else if (action === "cancel") result = manager.cancel({ job_id: args._[1] });
323
- else if (action === "approve") {
324
- if (args.json && !args.yes) throw new Error("job approve --json requires --yes");
325
- const inspection = manager.inspectLocal({ job_id: args._[1] });
326
- if (!args.yes) {
327
- console.log(JSON.stringify(inspection, null, 2));
328
- const approved = await confirm(`Approve and execute managed job ${args._[1]}?`, false);
329
- if (!approved) {
330
- console.log("Managed job approval cancelled. Re-run with --yes after review to skip confirmation.");
331
- return;
332
- }
333
- }
334
- result = manager.approve({ job_id: args._[1] }, { localOperator: true });
335
- }
336
326
  else if (action === "submit") {
337
327
  const planPath = args._[1];
338
328
  if (!planPath) throw new Error("job submit requires a JSON plan file");
@@ -3,10 +3,10 @@ import { normalizeLogLevel } from "./log.mjs";
3
3
  const BOOLEAN_OPTIONS = new Set([
4
4
  "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
5
5
  "daemonOnly", "noAutostart", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
6
- "yes", "keepWorker", "allowInsecurePermissions", "showPaths", "full",
6
+ "yes", "keepWorker", "allowInsecurePermissions", "showPaths",
7
7
  ]);
8
8
  const VALUE_OPTIONS = new Set([
9
- "workspace", "stateDir", "workerName", "profile", "execMode", "client", "account", "duration", "logLevel", "logFormat",
9
+ "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel", "logFormat",
10
10
  ]);
11
11
 
12
12
  const LOG_FORMATS = new Set(["text", "json"]);
@@ -17,6 +17,10 @@ const COMMAND_OPTIONS = new Map(Object.entries({
17
17
  "daemonOnly", "noAutostart",
18
18
  "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
19
19
  ]),
20
+ activate: new Set([
21
+ "workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "logFormat", "rotateSecrets", "forceWorker",
22
+ "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
23
+ ]),
20
24
  stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose", "quiet", "logLevel", "logFormat"]),
21
25
  "client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
22
26
  status: new Set(["workspace", "stateDir"]),
@@ -28,13 +32,13 @@ const COMMAND_OPTIONS = new Map(Object.entries({
28
32
  autostart: new Set(["workspace", "stateDir", "quiet"]),
29
33
  resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
30
34
  account: new Set(["workspace", "stateDir", "json", "yes"]),
31
- approval: new Set(["workspace", "stateDir", "json", "yes", "client", "account", "duration", "full"]),
35
+ approval: new Set(["workspace", "stateDir", "json", "yes"]),
32
36
  browser: new Set(["workspace", "stateDir", "json"]),
33
37
  job: new Set(["workspace", "stateDir", "json", "yes"]),
34
38
  uninstall: new Set(["stateDir", "keepWorker", "yes"]),
35
39
  }));
36
40
 
37
- const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
41
+ const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "activate", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
38
42
  const STATIC_POSITIONAL_RULES = new Map([
39
43
  ["client-config", Object.freeze({ max: 1, tooMany: "client-config accepts at most one positional client name" })],
40
44
  ["uninstall", Object.freeze({ max: 0, tooMany: "uninstall does not accept positional arguments" })],
@@ -42,7 +46,7 @@ const STATIC_POSITIONAL_RULES = new Map([
42
46
  const RESOURCE_POSITIONAL_LIMITS = new Map(Object.entries({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 }));
43
47
  const JOB_POSITIONAL_LIMITS = new Map(Object.entries({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 }));
44
48
  const ACCOUNT_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 }));
45
- const APPROVAL_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, approve: 2, grant: 2, revoke: 2, clear: 1 }));
49
+ const APPROVAL_POSITIONAL_LIMITS = new Map(Object.entries({ list: 1, revoke: 2, clear: 1 }));
46
50
  const ACTION_POSITIONAL_RULES = new Map(Object.entries({
47
51
  workspace(args) {
48
52
  const action = String(args._[0] || "show");
@@ -50,7 +54,7 @@ const ACTION_POSITIONAL_RULES = new Map(Object.entries({
50
54
  },
51
55
  service(args) {
52
56
  const action = String(args._[0] || "status");
53
- return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
57
+ return { max: ["install", "status", "stop"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
54
58
  },
55
59
  autostart(args) { return ACTION_POSITIONAL_RULES.get("service")(args); },
56
60
  resource(args) {
@@ -63,7 +67,7 @@ const ACTION_POSITIONAL_RULES = new Map(Object.entries({
63
67
  },
64
68
  approval(args) {
65
69
  const action = String(args._[0] || "list");
66
- return { max: APPROVAL_POSITIONAL_LIMITS.get(action) ?? 1, tooMany: `approval ${action} received too many positional arguments` };
70
+ return { max: APPROVAL_POSITIONAL_LIMITS.get(action) ?? 2, tooMany: `approval ${action} received too many positional arguments` };
67
71
  },
68
72
  browser(args) {
69
73
  const action = String(args._[0] || "status");
@@ -3,12 +3,14 @@ import * as defaultService from "./service.mjs";
3
3
  import { inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
4
4
  import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
5
5
  import { serviceEnvironmentSummary } from "./service-environment.mjs";
6
+ import { scheduleServiceRestart } from "./service-restart-scheduler.mjs";
6
7
  import { loadState, resolveWorkspace, selectedWorkspace } from "./state.mjs";
7
8
 
8
9
  const SERVICE_ACTION_HANDLERS = new Map([
9
10
  ["status", serviceStatusAction],
10
11
  ["install", serviceInstallAction],
11
12
  ["start", serviceStartAction],
13
+ ["restart", serviceRestartAction],
12
14
  ["stop", serviceStopAction],
13
15
  ["uninstall", serviceUninstallAction],
14
16
  ["remove", serviceUninstallAction],
@@ -24,6 +26,7 @@ export function createServiceCommand(dependencies) {
24
26
  stopWorkspaceServiceDaemon: dependencies.stopWorkspaceServiceDaemon || stopWorkspaceServiceDaemon,
25
27
  stopAndRemoveAutostart: dependencies.stopAndRemoveAutostart || stopAndRemoveAutostart,
26
28
  serviceEnvironmentSummary: dependencies.serviceEnvironmentSummary || serviceEnvironmentSummary,
29
+ scheduleServiceRestart: dependencies.scheduleServiceRestart || scheduleServiceRestart,
27
30
  loadState: dependencies.loadState || loadState,
28
31
  resolveWorkspace: dependencies.resolveWorkspace || resolveWorkspace,
29
32
  selectedWorkspace: dependencies.selectedWorkspace || selectedWorkspace,
@@ -73,14 +76,24 @@ async function serviceInstallAction({ args, stateRoot, service, context }) {
73
76
  }
74
77
 
75
78
  async function serviceStartAction({ args, service, context }) {
79
+ assertGlobalServiceAction(args, "start");
76
80
  const result = await service.startAutostart({ logger: context.structuredLogger(Boolean(args.quiet)) });
77
81
  printServiceResult(result, context);
78
82
  }
79
83
 
84
+ async function serviceRestartAction({ args, context }) {
85
+ assertGlobalServiceAction(args, "restart");
86
+ const result = await context.scheduleServiceRestart();
87
+ printServiceResult({ ...result, provider: "detached-handoff", reason: "restart_scheduled" }, context);
88
+ }
89
+
80
90
  async function serviceStopAction({ args, stateRoot, service, context }) {
81
91
  const logger = context.structuredLogger(Boolean(args.quiet));
82
- const provider = await service.stopAutostart({ logger });
83
92
  const state = optionalServiceState(args, stateRoot, context);
93
+ const status = await service.autostartStatus();
94
+ const before = state ? context.inspectWorkspaceDaemon(state) : null;
95
+ assertExplicitStopTarget(args, status, before);
96
+ const provider = await service.stopAutostart({ logger });
84
97
  const workspaceDaemon = state
85
98
  ? await context.stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
86
99
  : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
@@ -92,16 +105,17 @@ async function serviceStopAction({ args, stateRoot, service, context }) {
92
105
  }, context);
93
106
  }
94
107
 
95
- async function serviceUninstallAction({ args, stateRoot, service, context }) {
108
+ async function serviceUninstallAction({ args, stateRoot, context }) {
109
+ assertGlobalServiceAction(args, "uninstall");
96
110
  const logger = context.structuredLogger(Boolean(args.quiet));
97
- const state = optionalServiceState(args, stateRoot, context);
111
+ const state = optionalServiceState({ ...args, workspace: undefined, stateDir: undefined }, stateRoot, context);
98
112
  const lifecycle = await context.stopAndRemoveAutostart({
99
113
  states: state ? [state] : [],
100
114
  stateRoot,
101
115
  logger,
102
116
  reason: "service uninstall",
103
- stopAutostart: service.stopAutostart,
104
- uninstallAutostart: service.uninstallAutostart,
117
+ stopAutostart: context.service.stopAutostart,
118
+ uninstallAutostart: context.service.uninstallAutostart,
105
119
  stopWorkspaceServiceDaemon: context.stopWorkspaceServiceDaemon,
106
120
  });
107
121
  printServiceResult({
@@ -112,6 +126,22 @@ async function serviceUninstallAction({ args, stateRoot, service, context }) {
112
126
  }, context);
113
127
  }
114
128
 
129
+ function assertGlobalServiceAction(args, action) {
130
+ if (args.workspace || args.stateDir || args._[1]) {
131
+ throw new Error(`service ${action} acts on the single installed machine service and does not accept workspace or state overrides; use service install to change its owner`);
132
+ }
133
+ }
134
+
135
+ function assertExplicitStopTarget(args, status, workspaceDaemon) {
136
+ if (!hasExplicitServiceTarget(args) || status?.active !== true) return;
137
+ if (workspaceDaemon?.alive === true && workspaceDaemon?.verified_service_daemon === true) return;
138
+ throw new Error("refusing to stop the active machine service because the requested workspace/state does not own its verified daemon; omit the target to stop the installed service globally");
139
+ }
140
+
141
+ function hasExplicitServiceTarget(args) {
142
+ return Boolean(args.workspace || args.stateDir || args._[1]);
143
+ }
144
+
115
145
  function optionalServiceState(args, stateRoot, context) {
116
146
  const requested = args.workspace || args._[1] || context.selectedWorkspace(stateRoot);
117
147
  if (!requested || requested === true) return null;