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
@@ -7,9 +7,11 @@ import serverMetadata from "../shared/server-metadata.json" with { type: "json"
7
7
  import { replaceFileSync } from "./atomic-fs.mjs";
8
8
  import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
9
9
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
10
- import { createDeviceIdentity, validateDeviceIdentity } from "./device-identity.mjs";
10
+ import { createDeviceIdentity } from "./device-identity.mjs";
11
+ import { validateDeviceRootIdentity } from "./device-root-provider.mjs";
11
12
  import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
12
13
  import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
14
+ import { isPlainRecord } from "./records.mjs";
13
15
 
14
16
  export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
15
17
  export const appName = String(serverMetadata.name);
@@ -17,6 +19,7 @@ const STATE_MARKER = ".machine-bridge-mcp-state";
17
19
  const STATE_MARKER_SCHEMA = 2;
18
20
  export const STATE_SCHEMA_VERSION = 6;
19
21
  const GLOBAL_CONFIG_SCHEMA = 1;
22
+ const RECOVERY_MARKER_SCHEMA = 1;
20
23
  const CORRUPT_RECOVERY = Symbol("corrupt-recovery");
21
24
  const MAX_STATE_JSON_BYTES = 2 * 1024 * 1024;
22
25
  const MAX_LOCK_BYTES = 64 * 1024;
@@ -66,7 +69,7 @@ export function loadGlobalConfig(stateRoot = defaultStateRoot()) {
66
69
  const file = configPath(root);
67
70
  if (!existsSync(file)) return { schemaVersion: GLOBAL_CONFIG_SCHEMA };
68
71
  ownerOnlyFile(file);
69
- const config = readJsonObjectOrBackup(file);
72
+ const config = readJsonObjectOrBackup(file, { allowEmptyRecovery: true });
70
73
  if (config[CORRUPT_RECOVERY]) return { schemaVersion: GLOBAL_CONFIG_SCHEMA };
71
74
  if (config.schemaVersion !== GLOBAL_CONFIG_SCHEMA) {
72
75
  throw new Error("global configuration schema is obsolete; remove the state root and initialize the current version");
@@ -149,6 +152,16 @@ function assertStateRootSeparatedFromWorkspace(stateRoot, workspace) {
149
152
  }
150
153
  }
151
154
 
155
+ function samePotentialPathIdentity(left, right) {
156
+ try {
157
+ const a = canonicalizePotentialPath(String(left));
158
+ const b = canonicalizePotentialPath(String(right));
159
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
152
165
  function sameWorkspaceIdentity(left, right) {
153
166
  try {
154
167
  const a = resolveWorkspace(left);
@@ -169,13 +182,20 @@ export function loadState(workspace, options = {}) {
169
182
  const profileDir = profileDirForWorkspace(canonicalWorkspace, stateRoot);
170
183
  const statePath = path.join(profileDir, "state.json");
171
184
  ensureOwnerOnlyDir(profileDir);
185
+ const recoveryPath = recoveryMarkerPath(statePath);
186
+ const recoveryPending = existsSync(recoveryPath);
187
+ const recoveryMarker = recoveryPending ? readRecoveryMarker(recoveryPath, statePath) : null;
172
188
  let state = {};
173
189
  if (existsSync(statePath)) {
174
190
  ownerOnlyFile(statePath);
175
- state = readJsonObjectOrBackup(statePath);
176
- if (!state[CORRUPT_RECOVERY] && state.schemaVersion !== STATE_SCHEMA_VERSION) {
191
+ state = readJsonObjectOrBackup(statePath, { recoveryPath });
192
+ if (state.schemaVersion !== STATE_SCHEMA_VERSION) {
177
193
  throw new Error("workspace state schema is obsolete; remove the state root and initialize the current version");
178
194
  }
195
+ assertWorkspaceStateEnvelope(state, { canonicalWorkspace, stateRoot, profileDir, statePath });
196
+ if (recoveryPending) unlinkSync(recoveryPath);
197
+ } else if (recoveryPending) {
198
+ throw recoveryRequiredError(recoveryMarker);
179
199
  }
180
200
  state.schemaVersion = STATE_SCHEMA_VERSION;
181
201
  state.workspace = {
@@ -185,17 +205,57 @@ export function loadState(workspace, options = {}) {
185
205
  };
186
206
  state.paths = { stateRoot, profileDir, statePath };
187
207
  state.worker ||= {};
208
+ delete state.worker.accountAdminSecret;
188
209
  state.policy ||= {};
189
210
  state.resources ||= {};
190
211
  return state;
191
212
  }
192
213
 
193
214
  export function saveState(state) {
194
- assertNoForeignMaintenance(state?.paths?.stateRoot);
195
- const statePath = state?.paths?.statePath;
196
- if (!statePath) throw new Error("state path is missing");
197
- ensureOwnerOnlyDir(path.dirname(statePath));
198
- atomicWriteJson(statePath, { ...state });
215
+ const envelope = assertWorkspaceStateEnvelope(state);
216
+ assertNoForeignMaintenance(envelope.stateRoot);
217
+ assertValidStateMarker(path.join(envelope.stateRoot, STATE_MARKER));
218
+ ensureOwnerOnlyDir(envelope.profileDir);
219
+ atomicWriteJson(envelope.statePath, { ...state });
220
+ const recoveryPath = recoveryMarkerPath(envelope.statePath);
221
+ if (existsSync(recoveryPath)) unlinkSync(recoveryPath);
222
+ }
223
+
224
+ function assertWorkspaceStateEnvelope(state, expected = {}) {
225
+ if (!isPlainRecord(state) || state.schemaVersion !== STATE_SCHEMA_VERSION) {
226
+ throw new Error("workspace state envelope is incomplete or uses the wrong schema");
227
+ }
228
+ if (!isPlainRecord(state.workspace) || typeof state.workspace.path !== "string" || typeof state.workspace.hash !== "string") {
229
+ throw new Error("workspace state envelope is missing workspace identity");
230
+ }
231
+ if (!isPlainRecord(state.paths) || !isPlainRecord(state.worker) || !isPlainRecord(state.policy) || !isPlainRecord(state.resources)) {
232
+ throw new Error("workspace state envelope is missing required object fields");
233
+ }
234
+ const canonicalWorkspace = resolveWorkspace(expected.canonicalWorkspace || state.workspace.path);
235
+ const expectedHash = workspaceHash(canonicalWorkspace);
236
+ if (!sameWorkspaceIdentity(state.workspace.path, canonicalWorkspace) || state.workspace.hash !== expectedHash) {
237
+ throw new Error("workspace state envelope does not match the selected workspace");
238
+ }
239
+ if (typeof state.paths.stateRoot !== "string" || !state.paths.stateRoot
240
+ || typeof state.paths.profileDir !== "string" || !state.paths.profileDir
241
+ || typeof state.paths.statePath !== "string" || !state.paths.statePath) {
242
+ throw new Error("workspace state envelope is missing canonical state paths");
243
+ }
244
+ const stateRoot = canonicalizePotentialPath(expected.stateRoot || state.paths.stateRoot);
245
+ const profileDir = canonicalizePotentialPath(expected.profileDir || state.paths.profileDir);
246
+ const statePath = canonicalizePotentialPath(expected.statePath || state.paths.statePath);
247
+ const expectedProfileDir = canonicalizePotentialPath(profileDirForWorkspace(canonicalWorkspace, stateRoot));
248
+ const expectedStatePath = canonicalizePotentialPath(path.join(expectedProfileDir, "state.json"));
249
+ if (!samePotentialPathIdentity(profileDir, expectedProfileDir) || !samePotentialPathIdentity(statePath, expectedStatePath)) {
250
+ throw new Error("workspace state envelope contains inconsistent state paths");
251
+ }
252
+ if (!samePotentialPathIdentity(state.paths.stateRoot, stateRoot)
253
+ || !samePotentialPathIdentity(state.paths.profileDir, profileDir)
254
+ || !samePotentialPathIdentity(state.paths.statePath, statePath)) {
255
+ throw new Error("workspace state envelope does not match the active state location");
256
+ }
257
+ assertStateRootSeparatedFromWorkspace(stateRoot, canonicalWorkspace);
258
+ return { canonicalWorkspace, stateRoot, profileDir, statePath };
199
259
  }
200
260
 
201
261
  export function daemonLockPathForState(state) {
@@ -422,7 +482,7 @@ function readBoundedUtf8(filePath, maxBytes, label) {
422
482
  }
423
483
  }
424
484
 
425
- function readJsonObjectOrBackup(filePath) {
485
+ function readJsonObjectOrBackup(filePath, options = {}) {
426
486
  const text = readBoundedUtf8(filePath, MAX_STATE_JSON_BYTES, "state JSON");
427
487
  let parsed;
428
488
  try {
@@ -434,10 +494,39 @@ function readJsonObjectOrBackup(filePath) {
434
494
  replaceFileSync(filePath, backupPath);
435
495
  ownerOnlyFile(backupPath);
436
496
  pruneBackups(filePath, 3);
437
- const recovered = {};
438
- Object.defineProperty(recovered, CORRUPT_RECOVERY, { value: true });
439
- return recovered;
497
+ if (options.allowEmptyRecovery === true) {
498
+ const recovered = {};
499
+ Object.defineProperty(recovered, CORRUPT_RECOVERY, { value: true });
500
+ return recovered;
501
+ }
502
+ const recoveryPath = options.recoveryPath || recoveryMarkerPath(filePath);
503
+ const marker = { schemaVersion: RECOVERY_MARKER_SCHEMA, backup: path.basename(backupPath), detectedAt: new Date().toISOString() };
504
+ replaceFileAtomicallySync(recoveryPath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
505
+ ownerOnlyFile(recoveryPath);
506
+ throw recoveryRequiredError(marker);
507
+ }
508
+ }
509
+
510
+ function recoveryMarkerPath(statePath) { return `${statePath}.recovery-required`; }
511
+
512
+ function readRecoveryMarker(markerPath, statePath) {
513
+ ownerOnlyFile(markerPath);
514
+ let marker;
515
+ try { marker = JSON.parse(readBoundedUtf8(markerPath, MAX_MARKER_BYTES, "state recovery marker")); }
516
+ catch { throw new Error("workspace state recovery marker is invalid; inspect the profile manually before continuing"); }
517
+ if (marker?.schemaVersion !== RECOVERY_MARKER_SCHEMA || typeof marker.backup !== "string"
518
+ || !marker.backup.startsWith(`${path.basename(statePath)}.corrupt-`) || path.basename(marker.backup) !== marker.backup
519
+ || !Number.isFinite(Date.parse(String(marker.detectedAt || "")))) {
520
+ throw new Error("workspace state recovery marker is invalid; inspect the profile manually before continuing");
440
521
  }
522
+ return Object.freeze({ schemaVersion: marker.schemaVersion, backup: marker.backup, detectedAt: marker.detectedAt });
523
+ }
524
+
525
+ function recoveryRequiredError(marker) {
526
+ const backup = marker?.backup || "the bounded corrupt backup";
527
+ const error = new Error(`workspace state recovery is required; invalid JSON was preserved as ${backup}. Restore a valid current-schema state.json or intentionally remove this profile before restarting`);
528
+ error.code = "state_recovery_required";
529
+ return error;
441
530
  }
442
531
 
443
532
  function ensureStateRoot(inputRoot) {
@@ -592,10 +681,11 @@ function pruneBackups(filePath, keep) {
592
681
 
593
682
  export function ensureWorkerSecrets(state, options = {}) {
594
683
  state.worker ||= {};
684
+ delete state.worker.accountAdminSecret;
595
685
  const enrollingDeviceIdentity = !state.worker.deviceIdentity;
596
- if (!state.worker.accountAdminSecret || options.rotateSecrets) state.worker.accountAdminSecret = randomToken("account_admin");
597
- if (enrollingDeviceIdentity || options.rotateSecrets) state.worker.deviceIdentity = createDeviceIdentity();
598
- else validateDeviceIdentity(state.worker.deviceIdentity);
686
+ if (enrollingDeviceIdentity || (options.rotateSecrets && !options.deferDeviceRotation)) state.worker.deviceIdentity = createDeviceIdentity();
687
+ else validateDeviceRootIdentity(state.worker.deviceIdentity);
688
+ if (state.worker.pendingDeviceIdentity) validateDeviceRootIdentity(state.worker.pendingDeviceIdentity);
599
689
  delete state.worker.daemonSecret;
600
690
  if (!state.worker.oauthTokenVersion || enrollingDeviceIdentity || options.rotateSecrets) {
601
691
  state.worker.oauthTokenVersion = randomToken("token_version");
@@ -622,6 +712,42 @@ export function ensureWorkerSecrets(state, options = {}) {
622
712
  delete state.worker.updatedAt;
623
713
  }
624
714
 
715
+ export function deploymentDeviceIdentity(state) {
716
+ const identity = state?.worker?.pendingDeviceIdentity || state?.worker?.deviceIdentity;
717
+ return validateDeviceRootIdentity(identity);
718
+ }
719
+
720
+ export function promotePendingDeviceIdentity(state) {
721
+ const pending = state?.worker?.pendingDeviceIdentity;
722
+ if (!pending) return false;
723
+ validateDeviceRootIdentity(pending);
724
+ const current = state.worker.deviceIdentity;
725
+ if (current) {
726
+ const previous = Array.isArray(state.worker.previousDeviceIdentities) ? state.worker.previousDeviceIdentities : [];
727
+ state.worker.previousDeviceIdentities = [...previous, publicDeviceRootRecord(current)].slice(-2);
728
+ }
729
+ state.worker.deviceIdentity = pending;
730
+ delete state.worker.pendingDeviceIdentity;
731
+ return true;
732
+ }
733
+
734
+ function publicDeviceRootRecord(identity) {
735
+ validateDeviceRootIdentity(identity);
736
+ return {
737
+ scheme: identity.scheme,
738
+ provider: identity.provider || "portable-jwk-v1",
739
+ brokerProtocol: identity.brokerProtocol || undefined,
740
+ brokerPath: identity.brokerPath || undefined,
741
+ brokerIdentifier: identity.brokerIdentifier || undefined,
742
+ brokerTeamIdentifier: identity.brokerTeamIdentifier || undefined,
743
+ keyTag: identity.keyTag || undefined,
744
+ publicJwk: identity.publicJwk,
745
+ keyId: identity.keyId,
746
+ createdAt: identity.createdAt,
747
+ retiredAt: new Date().toISOString(),
748
+ };
749
+ }
750
+
625
751
  function defaultWorkerName(hash) {
626
752
  return `mbm-${String(hash || "default").slice(0, 12)}`;
627
753
  }
@@ -640,8 +766,8 @@ export function ownerOnlyFile(filePath) {
640
766
 
641
767
  export function redactState(state) {
642
768
  const clone = redactHomeInValue(JSON.parse(JSON.stringify(state)));
643
- if (clone.worker?.accountAdminSecret) clone.worker.accountAdminSecret = "<redacted>";
644
769
  if (clone.worker?.deviceIdentity?.privateJwk?.d) clone.worker.deviceIdentity.privateJwk.d = "<redacted>";
770
+ redactBrokerPaths(clone.worker);
645
771
  if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
646
772
  if (clone.resources && typeof clone.resources === "object") {
647
773
  for (const value of Object.values(clone.resources)) {
@@ -653,6 +779,18 @@ export function redactState(state) {
653
779
  return clone;
654
780
  }
655
781
 
782
+
783
+ function redactBrokerPaths(worker) {
784
+ if (!worker || typeof worker !== "object") return;
785
+ for (const identity of [
786
+ worker.deviceIdentity,
787
+ worker.pendingDeviceIdentity,
788
+ ...(Array.isArray(worker.previousDeviceIdentities) ? worker.previousDeviceIdentities : []),
789
+ ]) {
790
+ if (identity?.brokerPath) identity.brokerPath = "<local-broker-path>";
791
+ }
792
+ }
793
+
656
794
  function redactHomeInValue(value) {
657
795
  const home = os.homedir();
658
796
  if (typeof value === "string") {
@@ -15,6 +15,7 @@ import {
15
15
  } from "./tools.mjs";
16
16
 
17
17
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
18
+ const MAX_PENDING_TOOL_CALLS = 32;
18
19
  const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version);
19
20
 
20
21
  export async function runStdioServer({ workspace, policy, logLevel = "info", jobRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "" }) {
@@ -108,6 +109,10 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
108
109
  return;
109
110
  }
110
111
  const args = asObject(params.arguments);
112
+ if (pending.size >= MAX_PENDING_TOOL_CALLS) {
113
+ send(rpcError(message.id, -32000, "Too many concurrent tool calls"));
114
+ return;
115
+ }
111
116
  const callId = `stdio_${randomBytes(16).toString("hex")}`;
112
117
  const key = jsonRpcIdKey(message.id);
113
118
  if (key && pending.has(key)) {
@@ -1,5 +1,6 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { errorCode, normalizeBridgeError } from "./errors.mjs";
3
+ import { normalizeToolResult } from "./tool-result-boundary.mjs";
3
4
 
4
5
  export class ToolExecutor {
5
6
  constructor(options = {}) {
@@ -9,12 +10,13 @@ export class ToolExecutor {
9
10
  this.accountAccessGate = options.accountAccessGate;
10
11
  this.operationAuthorizer = options.operationAuthorizer;
11
12
  this.observability = options.observability;
13
+ this.securityAudit = options.securityAudit || null;
12
14
  this.logger = options.logger || console;
13
15
  this.safeMessage = typeof options.safeMessage === "function" ? options.safeMessage : (error) => String(error?.message || error || "operation failed");
14
16
  this.slowMs = Number.isFinite(Number(options.slowMs)) ? Math.max(1, Number(options.slowMs)) : 30_000;
15
17
  this.pipeline = composeMiddleware([
16
18
  lifecycleMiddleware(this.callRegistry),
17
- observabilityMiddleware(this.observability, this.logger, this.safeMessage, this.slowMs),
19
+ observabilityMiddleware(this.observability, this.securityAudit, this.logger, this.safeMessage, this.slowMs),
18
20
  authorizeMiddleware(this.policyGate, this.accountAccessGate, this.operationAuthorizer),
19
21
  ], invokeHandler(this.handlers));
20
22
  }
@@ -32,10 +34,15 @@ function authorizeMiddleware(policyGate, accountAccessGate, operationAuthorizer)
32
34
  return async (operation, next) => {
33
35
  policyGate.assert(operation.tool);
34
36
  if (operation.context.origin === "relay") {
35
- const role = operation.request.authorization?.role;
37
+ const authorization = operation.request.authorization;
38
+ const role = authorization?.role;
36
39
  if (!role) throw new Error("relay tool call is missing an account role");
37
40
  accountAccessGate.assert(role, operation.tool);
38
- await operationAuthorizer?.authorize(operation);
41
+ operation.context.authority = accountAccessGate.authority(authorization, policyGate.policy, "relay");
42
+ const decision = await operationAuthorizer?.authorize(operation);
43
+ if (decision) operation.context.operationAuthorization = decision;
44
+ } else {
45
+ operation.context.authority = accountAccessGate.authority({}, policyGate.policy, "local");
39
46
  }
40
47
  return next(operation);
41
48
  };
@@ -61,7 +68,7 @@ function lifecycleMiddleware(callRegistry) {
61
68
  };
62
69
  }
63
70
 
64
- function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
71
+ function observabilityMiddleware(observability, securityAudit, logger, safeMessage, slowMs) {
65
72
  return async (operation, next) => {
66
73
  const started = performance.now();
67
74
  observability.start(operation.tool);
@@ -71,23 +78,30 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
71
78
  origin: operation.context.origin,
72
79
  }, "Tool call started");
73
80
  try {
74
- const result = await next(operation);
81
+ const normalizedResult = normalizeToolResult(await next(operation));
82
+ const result = normalizedResult.value;
75
83
  const durationMs = performance.now() - started;
76
84
  const slow = durationMs >= slowMs;
77
85
  observability.finish(operation.tool, { status: "completed", durationMs, slow });
86
+ await recordSecurityAudit(securityAudit, operation, { outcome: "completed", durationMs, outputBytes: normalizedResult.bytes });
78
87
  logger.event?.("debug", slow ? "tool.call.slow" : "tool.call.completed", {
79
88
  call_id: shortCallId(operation.context.callId), tool: operation.tool, origin: operation.context.origin, duration_ms: durationMs,
89
+ authority_role: operation.context.authority?.principal?.role || "local",
90
+ risk_category: operation.context.operationAuthorization?.category || "ordinary",
80
91
  }, slow ? "Tool call completed slowly" : "Tool call completed");
81
92
  return result;
82
93
  } catch (error) {
83
- const normalized = normalizeBridgeError(error, { safeMessage: () => safeMessage(error, operation.args) });
94
+ const normalized = normalizeBridgeError(error, { safeMessage: () => safeMessage(error, operation.args, operation.context) });
84
95
  const durationMs = performance.now() - started;
85
96
  const code = errorCode(normalized);
86
97
  const status = code === "cancelled" ? "cancelled" : code === "timeout" ? "timeout" : "failed";
87
98
  observability.finish(operation.tool, { status, durationMs, errorCode: code, slow: durationMs >= slowMs });
99
+ await recordSecurityAudit(securityAudit, operation, { outcome: status, durationMs, errorCode: code });
88
100
  logger.event?.("debug", "tool.call.failed", {
89
101
  call_id: shortCallId(operation.context.callId), tool: operation.tool, origin: operation.context.origin,
90
102
  duration_ms: durationMs, error_code: code, retryable: normalized.retryable,
103
+ authority_role: operation.context.authority?.principal?.role || "local",
104
+ risk_category: operation.context.operationAuthorization?.category || "ordinary",
91
105
  }, "Tool call failed");
92
106
  throw normalized;
93
107
  }
@@ -102,6 +116,25 @@ function invokeHandler(handlers) {
102
116
  };
103
117
  }
104
118
 
119
+
120
+ async function recordSecurityAudit(securityAudit, operation, outcome) {
121
+ if (!securityAudit?.record || operation.context.origin !== "relay") return;
122
+ const decision = operation.context.operationAuthorization || {};
123
+ const recorded = await securityAudit.record({
124
+ ...outcome,
125
+ tool: operation.tool,
126
+ riskCategory: decision.category || "ordinary operation",
127
+ targetHash: decision.targetHash || "",
128
+ principal: operation.context.authority?.principal || {},
129
+ inputBytes: safeByteLength(operation.args),
130
+ });
131
+ if (!recorded) operation.context.auditWarning = "security_audit_unavailable";
132
+ }
133
+
134
+ function safeByteLength(value) {
135
+ try { return Buffer.byteLength(JSON.stringify(value)); } catch { return 0; }
136
+ }
137
+
105
138
  function shortCallId(value) {
106
139
  return String(value || "").slice(0, 20);
107
140
  }
@@ -0,0 +1,51 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { BridgeError } from "./errors.mjs";
3
+
4
+ export const MAX_TOOL_RESULT_BYTES = 7 * 1024 * 1024;
5
+
6
+ export function normalizeToolResult(value, options = {}) {
7
+ const maximumBytes = positiveInteger(options.maximumBytes, MAX_TOOL_RESULT_BYTES);
8
+ let serialized;
9
+ try {
10
+ serialized = JSON.stringify(value, rejectUnsupportedJsonValue);
11
+ } catch (error) {
12
+ if (error instanceof BridgeError) throw error;
13
+ throw new BridgeError("internal_error", "tool returned a result that could not be serialized", {
14
+ cause: error instanceof Error ? error : undefined,
15
+ expose: false,
16
+ });
17
+ }
18
+ if (typeof serialized !== "string") {
19
+ throw new BridgeError("internal_error", "tool returned an unsupported result", { expose: false });
20
+ }
21
+ const bytes = Buffer.byteLength(serialized);
22
+ if (bytes > maximumBytes) {
23
+ throw new BridgeError("limit_exceeded", "tool result exceeded the relay response limit", {
24
+ retryable: false,
25
+ details: { maximum_bytes: maximumBytes, actual_bytes: bytes },
26
+ });
27
+ }
28
+ try {
29
+ return Object.freeze({ value: JSON.parse(serialized), bytes });
30
+ } catch (error) {
31
+ throw new BridgeError("internal_error", "tool result normalization failed", {
32
+ cause: error instanceof Error ? error : undefined,
33
+ expose: false,
34
+ });
35
+ }
36
+ }
37
+
38
+ function rejectUnsupportedJsonValue(_key, current) {
39
+ if (typeof current === "bigint" || typeof current === "function" || typeof current === "symbol" || typeof current === "undefined") {
40
+ throw new BridgeError("internal_error", "tool returned a non-JSON value", { expose: false });
41
+ }
42
+ if (typeof current === "number" && !Number.isFinite(current)) {
43
+ throw new BridgeError("internal_error", "tool returned a non-finite number", { expose: false });
44
+ }
45
+ return current;
46
+ }
47
+
48
+ function positiveInteger(value, fallback) {
49
+ const parsed = Number(value);
50
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
51
+ }
@@ -10,6 +10,7 @@ export {
10
10
  allToolNames,
11
11
  assertCanonicalFullPolicy,
12
12
  assertToolAllowed,
13
+ intersectPolicies,
13
14
  isCanonicalFullPolicy,
14
15
  normalizePolicy,
15
16
  policyAllowsAvailability,
@@ -0,0 +1,53 @@
1
+ import { accessSync, constants as fsConstants, realpathSync, statSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { BridgeError } from "./errors.mjs";
5
+
6
+ export function resolveTrustedExecutable(options = {}) {
7
+ const platform = String(options.platform || process.platform);
8
+ const candidates = Array.isArray(options.candidates) ? options.candidates : [];
9
+ const deniedRoots = [options.workspace, options.stateRoot, options.runtimeDir, options.home || safeHome()]
10
+ .filter(Boolean).map((value) => canonicalOrResolved(String(value), options.realpath || realpathSync));
11
+ const realpath = options.realpath || realpathSync;
12
+ const stat = options.stat || statSync;
13
+ const access = options.access || accessSync;
14
+ for (const candidate of candidates) {
15
+ if (!path.isAbsolute(String(candidate || ""))) continue;
16
+ let canonical;
17
+ let info;
18
+ try {
19
+ canonical = realpath(String(candidate));
20
+ info = stat(canonical);
21
+ if (!info.isFile()) continue;
22
+ if (platform !== "win32") access(canonical, fsConstants.X_OK);
23
+ } catch { continue; }
24
+ if (platform !== "win32" && (Number(info.mode) & 0o022) !== 0) continue;
25
+ if (deniedRoots.some((root) => isWithin(canonical, root, platform))) continue;
26
+ return canonical;
27
+ }
28
+ const label = String(options.label || "required executable").replace(/[^A-Za-z0-9._ -]/g, "").trim() || "required executable";
29
+ const reason = String(options.reason || "trusted_executable_unavailable").replace(/[^A-Za-z0-9_-]/g, "_");
30
+ throw new BridgeError("unavailable", `trusted ${label} is unavailable`, { details: { reason, platform } });
31
+ }
32
+
33
+ export function createTrustedExecutableResolver(options = {}) {
34
+ const injected = typeof options.resolve === "function" ? options.resolve : null;
35
+ let cached = "";
36
+ return () => {
37
+ if (cached) return cached;
38
+ cached = String(injected ? injected() : resolveTrustedExecutable(options));
39
+ if (!path.isAbsolute(cached)) throw new BridgeError("unavailable", `trusted ${options.label || "executable"} is unavailable`);
40
+ return cached;
41
+ };
42
+ }
43
+
44
+ function canonicalOrResolved(value, realpath) {
45
+ try { return realpath(path.resolve(value)); } catch { return path.resolve(value); }
46
+ }
47
+ function isWithin(candidate, root, platform) {
48
+ const left = platform === "win32" ? candidate.toLowerCase() : candidate;
49
+ const right = platform === "win32" ? root.toLowerCase() : root;
50
+ const relative = path.relative(right, left);
51
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
52
+ }
53
+ function safeHome() { try { return os.homedir(); } catch { return ""; } }
@@ -0,0 +1,34 @@
1
+ import path from "node:path";
2
+ import { createTrustedExecutableResolver, resolveTrustedExecutable } from "./trusted-executable.mjs";
3
+
4
+ export function resolveTrustedGitExecutable(options = {}) {
5
+ const platform = String(options.platform || process.platform);
6
+ return resolveTrustedExecutable({
7
+ ...options,
8
+ platform,
9
+ label: "Git executable",
10
+ reason: "trusted_git_executable_unavailable",
11
+ candidates: Array.isArray(options.candidates) ? options.candidates : defaultGitCandidates(platform, options.env || process.env),
12
+ });
13
+ }
14
+
15
+ export function createTrustedGitResolver(options = {}) {
16
+ const platform = String(options.platform || process.platform);
17
+ return createTrustedExecutableResolver({
18
+ ...options,
19
+ platform,
20
+ label: "Git executable",
21
+ reason: "trusted_git_executable_unavailable",
22
+ candidates: Array.isArray(options.candidates) ? options.candidates : defaultGitCandidates(platform, options.env || process.env),
23
+ });
24
+ }
25
+
26
+ function defaultGitCandidates(platform, env) {
27
+ if (platform === "win32") {
28
+ return [env.ProgramFiles, env["ProgramFiles(x86)"]]
29
+ .filter(Boolean)
30
+ .flatMap((root) => [path.join(root, "Git", "cmd", "git.exe"), path.join(root, "Git", "bin", "git.exe")]);
31
+ }
32
+ if (platform === "darwin") return ["/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git"];
33
+ return ["/usr/bin/git", "/bin/git", "/usr/local/bin/git"];
34
+ }
@@ -0,0 +1,24 @@
1
+ import path from "node:path";
2
+ import { resolveTrustedExecutable } from "./trusted-executable.mjs";
3
+
4
+ export function resolveTrustedGithubCli(options = {}) {
5
+ const platform = String(options.platform || process.platform);
6
+ return resolveTrustedExecutable({
7
+ ...options,
8
+ platform,
9
+ label: "GitHub CLI executable",
10
+ reason: "trusted_github_cli_unavailable",
11
+ candidates: Array.isArray(options.candidates) ? options.candidates : githubCliCandidates(platform, options.env || process.env),
12
+ });
13
+ }
14
+
15
+ function githubCliCandidates(platform, env) {
16
+ if (platform === "win32") {
17
+ return [
18
+ env.ProgramFiles && path.join(env.ProgramFiles, "GitHub CLI", "gh.exe"),
19
+ env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "GitHub CLI", "gh.exe"),
20
+ ].filter(Boolean);
21
+ }
22
+ if (platform === "darwin") return ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"];
23
+ return ["/usr/bin/gh", "/usr/local/bin/gh", "/snap/bin/gh"];
24
+ }
@@ -50,6 +50,20 @@ export async function startWindowsTask(logger = console, options = {}) {
50
50
  const before = await statusWindowsTask({ ...options, run });
51
51
  if (before.installed === null) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, reason: "task_status_unavailable", status: before };
52
52
  if (before.installed === false) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, reason: "not_installed", status: before };
53
+ if (before.active === true) {
54
+ logger.info?.("Windows Scheduled Task is already running");
55
+ return {
56
+ ok: true,
57
+ provider: "schtasks",
58
+ task: WINDOWS_TASK,
59
+ installed: true,
60
+ active_before: true,
61
+ active: true,
62
+ already_running: true,
63
+ reason: "already_running",
64
+ status: before,
65
+ };
66
+ }
53
67
  const command = await run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
54
68
  const after = await waitForWindowsStatus(
55
69
  status => status.active === true || completedSince(before, status),
@@ -68,6 +82,12 @@ export async function startWindowsTask(logger = console, options = {}) {
68
82
  reason: after.active === true ? "started" : completed ? "completed" : "start_not_observed",
69
83
  };
70
84
  }
85
+ export async function restartWindowsTask(logger = console, options = {}) {
86
+ const stopped = await stopWindowsTask(logger, options);
87
+ if (!stopped.ok) return { ok: false, provider: "schtasks", task: WINDOWS_TASK, reason: "stop_failed", stop: stopped };
88
+ const started = await startWindowsTask(logger, options);
89
+ return { ...started, restarted: started.ok === true, stop: stopped };
90
+ }
71
91
 
72
92
  export async function stopWindowsTask(logger = console, options = {}) {
73
93
  const run = requiredRun(options.run);
@@ -2,7 +2,7 @@ import { createHmac } from "node:crypto";
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import path, { resolve } from "node:path";
4
4
  import { runWrangler } from "./shell.mjs";
5
- import { packageRoot, saveState } from "./state.mjs";
5
+ import { deploymentDeviceIdentity, packageRoot, saveState } from "./state.mjs";
6
6
  import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
7
7
  import { publicDeviceJwkJson } from "./device-identity.mjs";
8
8
  import {
@@ -81,8 +81,7 @@ export async function ensureWorkerDeployment(state, args = {}, options = {}) {
81
81
  export function workerDeploymentFingerprint(state, options = {}) {
82
82
  const root = resolve(options.packageRoot || packageRoot);
83
83
  const keyMaterial = [
84
- String(state.worker.accountAdminSecret || ""),
85
- publicDeviceJwkJson(state.worker.deviceIdentity),
84
+ publicDeviceJwkJson(deploymentDeviceIdentity(state)),
86
85
  String(state.worker.oauthTokenVersion || ""),
87
86
  ].join("\0");
88
87
  const fingerprint = createHmac("sha256", keyMaterial);
@@ -119,7 +118,7 @@ export function workerUrlMatchesName(workerUrl, workerName) {
119
118
  }
120
119
 
121
120
  function hasCompleteWorkerState(worker = {}) {
122
- return Boolean(worker.url && worker.mcpServerUrl && worker.accountAdminSecret && worker.deviceIdentity && worker.oauthTokenVersion && worker.name);
121
+ return Boolean(worker.url && worker.mcpServerUrl && worker.deviceIdentity && worker.oauthTokenVersion && worker.name);
123
122
  }
124
123
 
125
124
  function workerDeployHashFiles(root) {
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
4
4
  import { createExclusiveFileSync } from "./exclusive-file.mjs";
5
5
  import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
6
6
  import { publicDeviceJwkJson } from "./device-identity.mjs";
7
+ import { deploymentDeviceIdentity } from "./state.mjs";
7
8
  import { chmodRegularFileSync, ensureOwnerOnlyDirectorySync } from "./secure-file.mjs";
8
9
 
9
10
  const SECRET_FILE_PATTERN = /^worker-secrets-(\d+)-(\d+)(?:-p(\d+))?(?:-([a-f0-9]+))?\.json$/;
@@ -17,8 +18,7 @@ export async function withWorkerSecretsFile(state, callback, options = {}) {
17
18
  const random = (options.randomBytes || randomBytes)(6).toString("hex");
18
19
  const tempPath = resolve(dir, `worker-secrets-${process.pid}-${createdAt}-p${processStartedAt}-${random}.json`);
19
20
  const payload = {
20
- ACCOUNT_ADMIN_SECRET: state.worker.accountAdminSecret,
21
- DAEMON_DEVICE_PUBLIC_KEY: publicDeviceJwkJson(state.worker.deviceIdentity),
21
+ DAEMON_DEVICE_PUBLIC_KEY: publicDeviceJwkJson(deploymentDeviceIdentity(state)),
22
22
  OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
23
23
  };
24
24