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
@@ -1,8 +1,15 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign, webcrypto } from "node:crypto";
2
2
  import { DAEMON_AUTH_SCHEME, DAEMON_PREFLIGHT_SCHEME, daemonAuthTranscript, daemonPreflightTranscript } from "../shared/daemon-auth.mjs";
3
+ import {
4
+ DEVICE_SESSION_CERTIFICATE_SCHEME,
5
+ DEVICE_SESSION_MAX_LIFETIME_SECONDS,
6
+ canonicalPublicJwk,
7
+ deviceSessionCertificateTranscript,
8
+ } from "../shared/device-session-auth.mjs";
3
9
 
4
10
  const DEVICE_KEY_TYPE = "EC";
5
11
  const DEVICE_CURVE = "P-256";
12
+ const SESSION_CERTIFICATE_HEADER = "X-Bridge-Device-Certificate";
6
13
 
7
14
  export function createDeviceIdentity() {
8
15
  const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
@@ -19,90 +26,192 @@ export function createDeviceIdentity() {
19
26
  };
20
27
  }
21
28
 
29
+ export function createDeviceSessionIdentity(rootIdentity, workerOrigin, server, version, now = Date.now()) {
30
+ validateDeviceIdentity(rootIdentity);
31
+ const draft = createDeviceSessionDraft(rootIdentity, workerOrigin, server, version, now);
32
+ return finalizeDeviceSessionIdentity(draft, signDeviceTranscript(rootIdentity, draft.transcript));
33
+ }
34
+
35
+ export function createDeviceSessionDraft(rootIdentity, workerOrigin, server, version, now = Date.now()) {
36
+ validatePublicDeviceRoot(rootIdentity);
37
+ const issuedAt = Math.floor(Number(now) / 1000);
38
+ if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0) throw new Error("device session timestamp is invalid");
39
+ const expiresAt = issuedAt + DEVICE_SESSION_MAX_LIFETIME_SECONDS;
40
+ const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
41
+ const privateJwk = privateKey.export({ format: "jwk" });
42
+ const publicJwk = publicKey.export({ format: "jwk" });
43
+ validatePrivateDeviceJwk(privateJwk);
44
+ validatePublicDeviceJwk(publicJwk);
45
+ const nonce = randomBytes(24).toString("base64url");
46
+ const certificateBody = {
47
+ scheme: DEVICE_SESSION_CERTIFICATE_SCHEME,
48
+ root_key_id: rootIdentity.keyId,
49
+ public_jwk: canonicalPublicJwk(publicJwk),
50
+ issued_at: issuedAt,
51
+ expires_at: expiresAt,
52
+ nonce,
53
+ };
54
+ const transcript = deviceSessionCertificateTranscript({
55
+ workerOrigin,
56
+ server,
57
+ version,
58
+ rootKeyId: rootIdentity.keyId,
59
+ publicJwk,
60
+ issuedAt,
61
+ expiresAt,
62
+ nonce,
63
+ });
64
+ return {
65
+ transcript,
66
+ certificateBody,
67
+ session: {
68
+ scheme: DAEMON_AUTH_SCHEME,
69
+ privateJwk,
70
+ publicJwk,
71
+ keyId: deviceKeyId(publicJwk),
72
+ createdAt: new Date(Number(now)).toISOString(),
73
+ expiresAt: new Date(expiresAt * 1000).toISOString(),
74
+ },
75
+ };
76
+ }
77
+
78
+ export function finalizeDeviceSessionIdentity(draft, signature) {
79
+ if (!draft || typeof draft !== "object" || !draft.session || !draft.certificateBody) throw new Error("device session draft is invalid");
80
+ if (!/^[A-Za-z0-9_-]{86}$/.test(String(signature || ""))) throw new Error("device session root signature is invalid");
81
+ const identity = {
82
+ ...draft.session,
83
+ certificate: { ...draft.certificateBody, signature },
84
+ };
85
+ return validateDeviceSessionIdentity(identity);
86
+ }
87
+
88
+ export function validatePublicDeviceRoot(identity) {
89
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error("device root identity is missing");
90
+ if (identity.scheme !== DAEMON_AUTH_SCHEME) throw new Error("device root identity scheme is invalid");
91
+ validatePublicDeviceJwk(identity.publicJwk);
92
+ if (identity.keyId !== deviceKeyId(identity.publicJwk)) throw new Error("device root identity key id is invalid");
93
+ if (!Number.isFinite(Date.parse(String(identity.createdAt || "")))) throw new Error("device root identity creation time is invalid");
94
+ return identity;
95
+ }
96
+
22
97
  export function publicDeviceJwkJson(identity) {
23
98
  const publicJwk = identity?.publicJwk;
24
99
  validatePublicDeviceJwk(publicJwk);
25
100
  return JSON.stringify(publicJwk);
26
101
  }
27
102
 
28
-
29
103
  export function createDaemonPreflightHeaders(identity, workerOrigin, server, version, now = Date.now()) {
30
- validatePrivateDeviceJwk(identity?.privateJwk);
104
+ validateDeviceSessionIdentity(identity, now);
31
105
  const issuedAt = Math.floor(Number(now) / 1000);
32
106
  if (!Number.isSafeInteger(issuedAt) || issuedAt <= 0) throw new Error("device preflight timestamp is invalid");
33
107
  const nonce = randomBytes(24).toString("base64url");
34
108
  const transcript = daemonPreflightTranscript({ workerOrigin, server, version, nonce, issuedAt });
35
- const key = createPrivateKey({ key: identity.privateJwk, format: "jwk" });
36
- const signature = sign("sha256", Buffer.from(transcript, "utf8"), { key, dsaEncoding: "ieee-p1363" }).toString("base64url");
109
+ const signature = signDeviceTranscript(identity, transcript);
37
110
  return {
38
111
  "X-Bridge-Device-Scheme": DAEMON_PREFLIGHT_SCHEME,
39
- "X-Bridge-Device-Key": String(identity.keyId || deviceKeyId(identity.publicJwk)),
112
+ "X-Bridge-Device-Key": identity.keyId,
40
113
  "X-Bridge-Device-Nonce": nonce,
41
114
  "X-Bridge-Device-Time": String(issuedAt),
42
115
  "X-Bridge-Device-Signature": signature,
116
+ [SESSION_CERTIFICATE_HEADER]: encodeDeviceSessionCertificate(identity),
43
117
  };
44
118
  }
45
119
 
120
+ export function encodeDeviceSessionCertificate(identity) {
121
+ validateDeviceSessionIdentity(identity);
122
+ return Buffer.from(JSON.stringify(identity.certificate), "utf8").toString("base64url");
123
+ }
124
+
125
+ export function signWithDeviceSessionIdentity(identity, transcript) {
126
+ validateDeviceSessionIdentity(identity);
127
+ const text = String(transcript || "");
128
+ if (!text || Buffer.byteLength(text) > 64 * 1024) throw new Error("device session signing transcript is empty or too large");
129
+ return signDeviceTranscript(identity, text);
130
+ }
131
+
46
132
  export async function createDaemonAuthentication(identity, welcome, instanceId) {
47
- validatePrivateDeviceJwk(identity?.privateJwk);
133
+ validateDeviceSessionIdentity(identity);
48
134
  const auth = welcome?.authentication;
49
- if (!auth || auth.scheme !== DAEMON_AUTH_SCHEME) throw new Error("Worker did not request supported device authentication");
50
- const workerOrigin = new URL(String(welcome.worker_origin || "")).origin;
135
+ if (!auth || auth.scheme !== DAEMON_AUTH_SCHEME) throw new Error("Worker did not provide a supported device challenge");
136
+ const challenge = String(auth.challenge || "");
137
+ const issuedAt = Number(auth.issued_at);
138
+ const expiresAt = Number(auth.expires_at);
139
+ if (!/^daemon_challenge_[A-Za-z0-9_-]{40,96}$/.test(challenge)) throw new Error("Worker device challenge is invalid");
140
+ if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt) || expiresAt <= issuedAt) throw new Error("Worker device challenge lifetime is invalid");
141
+ if (Math.floor(Date.now() / 1000) > expiresAt) throw new Error("Worker device challenge expired");
51
142
  const transcript = daemonAuthTranscript({
52
- challenge: auth.challenge,
53
- workerOrigin,
54
- server: welcome.server,
55
- version: welcome.version,
143
+ challenge,
144
+ workerOrigin: String(welcome.worker_origin || ""),
145
+ server: String(welcome.server || ""),
146
+ version: String(welcome.version || ""),
56
147
  instanceId,
57
- issuedAt: auth.issued_at,
148
+ issuedAt,
58
149
  });
59
- const privateKey = await webcrypto.subtle.importKey(
60
- "jwk",
61
- identity.privateJwk,
62
- { name: "ECDSA", namedCurve: DEVICE_CURVE },
63
- false,
64
- ["sign"],
65
- );
66
- const signature = await webcrypto.subtle.sign(
67
- { name: "ECDSA", hash: "SHA-256" },
68
- privateKey,
69
- new TextEncoder().encode(transcript),
70
- );
71
150
  return {
72
151
  scheme: DAEMON_AUTH_SCHEME,
73
- challenge: String(auth.challenge),
74
- issued_at: Number(auth.issued_at),
75
- key_id: String(identity.keyId || deviceKeyId(identity.publicJwk)),
76
- signature: Buffer.from(signature).toString("base64url"),
152
+ key_id: identity.keyId,
153
+ challenge,
154
+ issued_at: issuedAt,
155
+ signature: signDeviceTranscript(identity, transcript),
77
156
  };
78
157
  }
79
158
 
80
- export function deviceKeyId(publicJwk) {
81
- validatePublicDeviceJwk(publicJwk);
82
- const canonical = JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y });
83
- return `device_${createHash("sha256").update(canonical).digest("base64url").slice(0, 32)}`;
84
- }
85
-
86
159
  export function validateDeviceIdentity(identity) {
87
160
  if (!identity || typeof identity !== "object" || Array.isArray(identity)) throw new Error("device identity is missing");
88
- if (identity.scheme !== DAEMON_AUTH_SCHEME) throw new Error("device identity scheme is unsupported");
161
+ if (identity.scheme !== DAEMON_AUTH_SCHEME) throw new Error("device identity scheme is invalid");
89
162
  validatePrivateDeviceJwk(identity.privateJwk);
90
163
  validatePublicDeviceJwk(identity.publicJwk);
91
- const expectedKeyId = deviceKeyId(identity.publicJwk);
92
- if (identity.keyId !== expectedKeyId) throw new Error("device identity key id does not match its public key");
164
+ const expectedPublic = publicFromPrivate(identity.privateJwk);
165
+ if (!samePublicJwk(expectedPublic, identity.publicJwk)) throw new Error("device identity public and private keys do not match");
166
+ if (identity.keyId !== deviceKeyId(identity.publicJwk)) throw new Error("device identity key id is invalid");
167
+ if (!Number.isFinite(Date.parse(String(identity.createdAt || "")))) throw new Error("device identity creation time is invalid");
93
168
  return identity;
94
169
  }
95
170
 
96
- function validatePrivateDeviceJwk(jwk) {
97
- validatePublicDeviceJwk(jwk);
98
- if (typeof jwk.d !== "string" || !/^[A-Za-z0-9_-]{40,48}$/.test(jwk.d)) throw new Error("device private key is invalid");
171
+ export function validateDeviceSessionIdentity(identity, now = Date.now()) {
172
+ validateDeviceIdentity(identity);
173
+ const certificate = identity.certificate;
174
+ if (!certificate || typeof certificate !== "object" || Array.isArray(certificate)) throw new Error("device session certificate is missing");
175
+ if (certificate.scheme !== DEVICE_SESSION_CERTIFICATE_SCHEME) throw new Error("device session certificate scheme is invalid");
176
+ if (identity.keyId !== deviceKeyId(certificate.public_jwk) || !samePublicJwk(identity.publicJwk, certificate.public_jwk)) {
177
+ throw new Error("device session certificate key mismatch");
178
+ }
179
+ const expiresAt = Number(certificate.expires_at);
180
+ if (!Number.isSafeInteger(expiresAt) || Math.floor(Number(now) / 1000) > expiresAt) throw new Error("device session certificate expired");
181
+ if (!/^[A-Za-z0-9_-]{86}$/.test(String(certificate.signature || ""))) throw new Error("device session certificate signature is invalid");
182
+ return identity;
99
183
  }
100
184
 
101
- function validatePublicDeviceJwk(jwk) {
102
- if (!jwk || typeof jwk !== "object" || Array.isArray(jwk)) throw new Error("device public key is invalid");
103
- if (jwk.kty !== DEVICE_KEY_TYPE || jwk.crv !== DEVICE_CURVE) throw new Error("device public key must use P-256");
104
- if (typeof jwk.x !== "string" || typeof jwk.y !== "string") throw new Error("device public key coordinates are invalid");
105
- if (!/^[A-Za-z0-9_-]{42,44}$/.test(jwk.x) || !/^[A-Za-z0-9_-]{42,44}$/.test(jwk.y)) {
106
- throw new Error("device public key coordinates are invalid");
107
- }
185
+ export function deviceKeyId(publicJwk) {
186
+ validatePublicDeviceJwk(publicJwk);
187
+ const canonical = JSON.stringify(canonicalPublicJwk(publicJwk));
188
+ return `device_${createHash("sha256").update(canonical).digest("base64url").slice(0, 32)}`;
189
+ }
190
+
191
+ function signDeviceTranscript(identity, transcript) {
192
+ validatePrivateDeviceJwk(identity?.privateJwk);
193
+ const key = createPrivateKey({ key: identity.privateJwk, format: "jwk" });
194
+ return sign("sha256", Buffer.from(transcript, "utf8"), { key, dsaEncoding: "ieee-p1363" }).toString("base64url");
195
+ }
196
+
197
+ function publicFromPrivate(privateJwk) {
198
+ const privateKey = createPrivateKey({ key: privateJwk, format: "jwk" });
199
+ return privateKey.export({ format: "jwk" });
108
200
  }
201
+
202
+ function validatePrivateDeviceJwk(value) {
203
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("device private key is invalid");
204
+ if (value.kty !== DEVICE_KEY_TYPE || value.crv !== DEVICE_CURVE || typeof value.d !== "string") throw new Error("device private key is invalid");
205
+ validatePublicDeviceJwk(value);
206
+ }
207
+
208
+ function validatePublicDeviceJwk(value) {
209
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("device public key is invalid");
210
+ if (value.kty !== DEVICE_KEY_TYPE || value.crv !== DEVICE_CURVE || typeof value.x !== "string" || typeof value.y !== "string") throw new Error("device public key is invalid");
211
+ }
212
+
213
+ function samePublicJwk(left, right) {
214
+ return left?.kty === right?.kty && left?.crv === right?.crv && left?.x === right?.x && left?.y === right?.y;
215
+ }
216
+
217
+ export const deviceWebCrypto = webcrypto;
@@ -0,0 +1,87 @@
1
+ import {
2
+ createDeviceIdentity,
3
+ createDeviceSessionDraft,
4
+ createDeviceSessionIdentity,
5
+ finalizeDeviceSessionIdentity,
6
+ publicDeviceJwkJson,
7
+ validateDeviceIdentity,
8
+ validatePublicDeviceRoot,
9
+ } from "./device-identity.mjs";
10
+ import {
11
+ configuredMacosTrustBrokerPath,
12
+ ensureMacosSecureDeviceRoot,
13
+ isMacosSecureDeviceRoot,
14
+ signWithMacosSecureDeviceRoot,
15
+ } from "./macos-trust-broker.mjs";
16
+
17
+ export function validateDeviceRootIdentity(identity) {
18
+ if (isMacosSecureDeviceRoot(identity)) return identity;
19
+ return validateDeviceIdentity(identity);
20
+ }
21
+
22
+ export function publicDeviceRootJwkJson(identity) {
23
+ validateDeviceRootIdentity(identity);
24
+ return publicDeviceJwkJson(identity);
25
+ }
26
+
27
+ export async function ensurePreferredDeviceRoot({
28
+ profileDir,
29
+ workspaceHash,
30
+ existing = null,
31
+ rotate = false,
32
+ platform = process.platform,
33
+ brokerPath = undefined,
34
+ env = process.env,
35
+ secureRootFactory = ensureMacosSecureDeviceRoot,
36
+ } = {}) {
37
+ const configuredBroker = brokerPath === undefined ? configuredMacosTrustBrokerPath(env) : brokerPath;
38
+ const selectedBroker = configuredBroker || (isMacosSecureDeviceRoot(existing) ? existing.brokerPath : null);
39
+ if (platform === "darwin" && selectedBroker) {
40
+ return secureRootFactory({
41
+ profileDir,
42
+ workspaceHash,
43
+ existing,
44
+ rotate,
45
+ brokerPath: selectedBroker,
46
+ });
47
+ }
48
+ if (!rotate && existing) return validateDeviceRootIdentity(existing);
49
+ return createDeviceIdentity();
50
+ }
51
+
52
+ export async function createDeviceSessionForRoot(identity, workerOrigin, server, version, {
53
+ profileDir,
54
+ now = Date.now(),
55
+ reason = "Authorize Machine Bridge startup",
56
+ } = {}) {
57
+ if (!isMacosSecureDeviceRoot(identity)) return createDeviceSessionIdentity(validateDeviceIdentity(identity), workerOrigin, server, version, now);
58
+ validatePublicDeviceRoot(identity);
59
+ const draft = createDeviceSessionDraft(identity, workerOrigin, server, version, now);
60
+ const signature = signWithMacosSecureDeviceRoot(identity, draft.transcript, { profileDir, reason });
61
+ return finalizeDeviceSessionIdentity(draft, signature);
62
+ }
63
+
64
+ export function deviceRootProviderStatus(identity, { env = process.env } = {}) {
65
+ if (isMacosSecureDeviceRoot(identity)) {
66
+ return {
67
+ provider: identity.provider,
68
+ private_key_exportable: false,
69
+ root_storage: "Secure Enclave",
70
+ broker_provisioned: true,
71
+ session_signing: "one user-presence operation per daemon start",
72
+ reconnect_signing: "ephemeral in-memory key",
73
+ key_id: identity.keyId,
74
+ };
75
+ }
76
+ validateDeviceIdentity(identity);
77
+ return {
78
+ provider: "portable-jwk-v1",
79
+ private_key_exportable: true,
80
+ root_storage: "owner-only local state",
81
+ broker_provisioned: Boolean(String(env?.MBM_MACOS_TRUST_BROKER || "").trim()),
82
+ secure_enclave_enrollment: "requires a provisioning-profile-validated app-like broker configured through MBM_MACOS_TRUST_BROKER",
83
+ session_signing: "local file-backed root",
84
+ reconnect_signing: "ephemeral in-memory key",
85
+ key_id: identity.keyId,
86
+ };
87
+ }
@@ -6,6 +6,9 @@ const ERROR_CODES = new Set([
6
6
  ]);
7
7
 
8
8
  const RETRYABLE_CODES = new Set(["timeout", "network_error", "unavailable"]);
9
+ const MAX_PUBLIC_ERROR_MESSAGE_CHARS = 2000;
10
+ const MAX_PUBLIC_ERROR_DETAILS_BYTES = 512 * 1024;
11
+ const UNSAFE_ERROR_CONTROLS = /[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
9
12
 
10
13
  export class BridgeError extends Error {
11
14
  constructor(code, message, options = {}) {
@@ -29,7 +32,7 @@ export function normalizeBridgeError(error, options = {}) {
29
32
  : safeFallbackMessage(error, code);
30
33
  return new BridgeError(code, message, {
31
34
  cause: error instanceof Error ? error : undefined,
32
- expose: options.expose !== false,
35
+ expose: options.expose === true,
33
36
  retryable: options.retryable,
34
37
  });
35
38
  }
@@ -50,11 +53,12 @@ export function errorCode(error, fallback = "execution_failed") {
50
53
 
51
54
  export function publicError(error, options = {}) {
52
55
  const normalized = normalizeBridgeError(error, options);
56
+ const details = normalized.expose ? publicErrorDetails(normalized.details) : undefined;
53
57
  return {
54
58
  code: normalized.code,
55
- message: normalized.expose ? normalized.message : defaultMessage(normalized.code),
59
+ message: publicErrorMessage(normalized.expose ? normalized.message : defaultMessage(normalized.code)),
56
60
  retryable: normalized.retryable,
57
- ...(normalized.expose && normalized.details ? { details: normalized.details } : {}),
61
+ ...(details ? { details } : {}),
58
62
  };
59
63
  }
60
64
 
@@ -106,3 +110,21 @@ function defaultMessage(code) {
106
110
  function isRecord(value) {
107
111
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
108
112
  }
113
+
114
+ function publicErrorMessage(value) {
115
+ const normalized = String(value || "").replace(UNSAFE_ERROR_CONTROLS, " ").replace(/\s+/g, " ").trim();
116
+ return (normalized || "operation failed").slice(0, MAX_PUBLIC_ERROR_MESSAGE_CHARS);
117
+ }
118
+
119
+ function publicErrorDetails(value) {
120
+ if (!isRecord(value)) return undefined;
121
+ try {
122
+ const serialized = JSON.stringify(value, (_key, current) => {
123
+ if (["bigint", "function", "symbol", "undefined"].includes(typeof current)) throw new TypeError("unsupported error detail");
124
+ if (typeof current === "number" && !Number.isFinite(current)) throw new TypeError("unsupported error detail");
125
+ return current;
126
+ });
127
+ if (!serialized || Buffer.byteLength(serialized) > MAX_PUBLIC_ERROR_DETAILS_BYTES) return undefined;
128
+ return JSON.parse(serialized);
129
+ } catch { return undefined; }
130
+ }
@@ -4,10 +4,11 @@ import { BridgeError } from "./errors.mjs";
4
4
  import { clampInteger } from "./numbers.mjs";
5
5
 
6
6
  export class GitService {
7
- constructor({ resolveExistingPath, displayPath, runProcess, maximumBytes }) {
7
+ constructor({ resolveExistingPath, displayPath, runInternalProcess, gitExecutable, maximumBytes }) {
8
8
  this.resolveExistingPath = resolveExistingPath;
9
9
  this.displayPath = displayPath;
10
- this.runProcess = runProcess;
10
+ this.runInternalProcess = runInternalProcess;
11
+ this.gitExecutable = typeof gitExecutable === "function" ? gitExecutable : () => String(gitExecutable || "");
11
12
  this.maximumBytes = maximumBytes;
12
13
  }
13
14
 
@@ -16,54 +17,54 @@ export class GitService {
16
17
  if (!git.ok) return git.result;
17
18
  const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "status", "--short", "--branch"];
18
19
  if (git.pathspec) commandArgs.push("--", git.pathspec);
19
- const result = await this.runProcess("git", commandArgs, 30_000, true, 512 * 1024, context);
20
- return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
20
+ const result = await this.runInternalProcess(this.gitExecutable(), commandArgs, 30_000, true, 512 * 1024, context);
21
+ return { ...result, path: this.displayPath(git.target, context), gitRoot: this.displayPath(git.root, context) };
21
22
  }
22
23
 
23
24
  async diff(args = {}, context = {}) {
24
25
  const maxBytes = clampInteger(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
25
26
  const git = await this.context(args.path || ".", context);
26
- if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
27
+ if (!git.ok) return { ...git.result, path: this.displayPath(git.target, context) };
27
28
  const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "diff", "--no-ext-diff", "--no-textconv"];
28
29
  if (args.staged) commandArgs.push("--cached");
29
30
  if (git.pathspec) commandArgs.push("--", git.pathspec);
30
- const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
31
- return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root), staged: args.staged === true };
31
+ const result = await this.runInternalProcess(this.gitExecutable(), commandArgs, 60_000, true, maxBytes, context);
32
+ return { ...result, path: this.displayPath(git.target, context), gitRoot: this.displayPath(git.root, context), staged: args.staged === true };
32
33
  }
33
34
 
34
35
  async log(args = {}, context = {}) {
35
36
  const git = await this.context(args.path || ".", context);
36
- if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
37
+ if (!git.ok) return { ...git.result, path: this.displayPath(git.target, context) };
37
38
  const maxCount = clampInteger(args.max_count, 20, 1, 100);
38
39
  const format = "%H%x1f%h%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e";
39
40
  const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "log", `--max-count=${maxCount}`, `--format=${format}`];
40
41
  if (git.pathspec) commandArgs.push("--", git.pathspec);
41
- const result = await this.runProcess("git", commandArgs, 30_000, true, 1024 * 1024, context);
42
+ const result = await this.runInternalProcess(this.gitExecutable(), commandArgs, 30_000, true, 1024 * 1024, context);
42
43
  const commits = result.stdout.split("\x1e").map((record) => record.trim()).filter(Boolean).map((record) => {
43
44
  const [hash, short, authored_at, author_name, author_email, subject] = record.split("\x1f");
44
45
  const commit = { hash, short, authored_at, author_name, subject };
45
46
  if (args.include_author_email === true) commit.author_email = author_email;
46
47
  return commit;
47
48
  });
48
- return { code: result.code, stderr: result.stderr, commits, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
49
+ return { code: result.code, stderr: result.stderr, commits, path: this.displayPath(git.target, context), gitRoot: this.displayPath(git.root, context) };
49
50
  }
50
51
 
51
52
  async show(args = {}, context = {}) {
52
53
  const git = await this.context(args.path || ".", context);
53
- if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
54
+ if (!git.ok) return { ...git.result, path: this.displayPath(git.target, context) };
54
55
  const revision = validateRevision(args.revision || "HEAD");
55
56
  const maxBytes = clampInteger(args.max_bytes, 1024 * 1024, 1, this.maximumBytes);
56
57
  const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "show", "--no-ext-diff", "--no-textconv", "--decorate=no", revision];
57
58
  if (git.pathspec) commandArgs.push("--", git.pathspec);
58
- const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
59
- return { ...result, revision, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
59
+ const result = await this.runInternalProcess(this.gitExecutable(), commandArgs, 60_000, true, maxBytes, context);
60
+ return { ...result, revision, path: this.displayPath(git.target, context), gitRoot: this.displayPath(git.root, context) };
60
61
  }
61
62
 
62
63
  async context(inputPath, context = {}) {
63
- const target = await this.resolveExistingPath(inputPath);
64
+ const target = await this.resolveExistingPath(inputPath, context);
64
65
  const info = await stat(target);
65
66
  const cwd = info.isDirectory() ? target : dirname(target);
66
- const result = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
67
+ const result = await this.runInternalProcess(this.gitExecutable(), ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
67
68
  if (result.code !== 0) return { ok: false, result, target };
68
69
  const root = result.stdout.trim();
69
70
  const repoRelative = relative(root, target);
@@ -9,6 +9,7 @@ import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomically
9
9
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
10
10
  import { currentProcessStartTimeMs } from "./process-identity.mjs";
11
11
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
12
+ import { persistManagedJobTerminal } from "./managed-job-terminal.mjs";
12
13
 
13
14
  const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
14
15
  const TEMP_TOKEN = /\{\{temp:([a-z][a-z0-9._-]{0,63})\}\}/g;
@@ -55,14 +56,15 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
55
56
 
56
57
  const initial = readJson(statusFile, MAX_STATUS_BYTES);
57
58
  assertLaunchState(initial);
58
- createExclusiveFileSync(runnerPidFile, `${JSON.stringify({ pid: process.pid, processStartedAt: RUNNER_PROCESS_STARTED_AT })}\n`, { mode: 0o600 });
59
59
  try {
60
+ createExclusiveFileSync(runnerPidFile, `${JSON.stringify({ pid: process.pid, processStartedAt: RUNNER_PROCESS_STARTED_AT })}\n`, { mode: 0o600 });
60
61
  if (recover) await releaseRecoveryClaim();
61
62
  const plan = readJson(planFile, 1024 * 1024);
62
63
  assertPlanIntegrity(plan, initial);
63
64
  await main(plan, initial);
64
65
  } catch (error) {
65
66
  recordFatalRunnerError(error);
67
+ process.exitCode = 1;
66
68
  }
67
69
 
68
70
  async function releaseRecoveryClaim() {
@@ -125,7 +127,6 @@ async function main(plan, initial) {
125
127
  } catch (error) {
126
128
  cleanupError ||= error;
127
129
  }
128
- rmSync(runtimeDir, { recursive: true, force: true });
129
130
  }
130
131
 
131
132
  const cancelled = mainError instanceof JobCancelledError || existsSync(cancelFile);
@@ -148,17 +149,16 @@ async function main(plan, initial) {
148
149
  capture_remaining_bytes: captureBudget.remaining,
149
150
  finished_at: new Date().toISOString(),
150
151
  };
151
- writeJson(resultFile, result, MAX_RESULT_BYTES);
152
- updateStatus(status, {
153
- status: finalStatus,
154
- current_phase: null,
155
- current_step: null,
156
- finished_at: result.finished_at,
157
- error_class: result.error_class || result.cleanup_error_class,
152
+ const terminal = persistManagedJobTerminal({
153
+ statusFile, resultFile,
154
+ artifacts: [runtimeDir, planFile, runnerPidFile, cancelFile],
155
+ status: { ...status, runner_pid: process.pid, runner_process_started_at: RUNNER_PROCESS_STARTED_AT },
156
+ result, writeJson,
157
+ removeFile: (file) => rmSync(file, { recursive: true, force: true }),
158
+ maxStatusBytes: MAX_STATUS_BYTES, maxResultBytes: MAX_RESULT_BYTES,
158
159
  });
159
- try { rmSync(planFile, { force: true }); } catch {}
160
- try { rmSync(runnerPidFile, { force: true }); } catch {}
161
- try { rmSync(cancelFile, { force: true }); } catch {}
160
+ Object.assign(status, terminal.status);
161
+ reportTerminalPersistenceFailure(terminal);
162
162
  }
163
163
 
164
164
  function assertLaunchState(status) {
@@ -176,7 +176,6 @@ function assertPlanIntegrity(plan, status) {
176
176
  }
177
177
 
178
178
  function recordFatalRunnerError(error) {
179
- rmSync(runtimeDir, { recursive: true, force: true });
180
179
  const now = new Date().toISOString();
181
180
  let status = {};
182
181
  try { status = readJson(statusFile, MAX_STATUS_BYTES); } catch {}
@@ -192,24 +191,33 @@ function recordFatalRunnerError(error) {
192
191
  cleanup_error_class: recover ? classifyError(error) : null,
193
192
  finished_at: now,
194
193
  };
195
- try { writeJson(resultFile, result, MAX_RESULT_BYTES); } catch {}
196
- try {
197
- writeJson(statusFile, {
194
+ const terminal = persistManagedJobTerminal({
195
+ statusFile, resultFile,
196
+ artifacts: [runtimeDir, planFile, runnerPidFile, cancelFile],
197
+ status: {
198
198
  ...status,
199
- status: finalStatus,
200
- current_phase: null,
201
- current_step: null,
202
199
  runner_pid: process.pid,
203
200
  runner_process_started_at: RUNNER_PROCESS_STARTED_AT,
204
- updated_at: now,
205
- finished_at: now,
206
- error_class: result.error_class,
207
201
  cleanup_guarantee: "best-effort-finally-and-recovery",
208
- }, MAX_STATUS_BYTES);
209
- } catch {}
210
- try { rmSync(planFile, { force: true }); } catch {}
211
- try { rmSync(runnerPidFile, { force: true }); } catch {}
212
- try { rmSync(cancelFile, { force: true }); } catch {}
202
+ },
203
+ result, writeJson,
204
+ removeFile: (file) => rmSync(file, { recursive: true, force: true }),
205
+ maxStatusBytes: MAX_STATUS_BYTES, maxResultBytes: MAX_RESULT_BYTES,
206
+ });
207
+ reportTerminalPersistenceFailure(terminal);
208
+ }
209
+
210
+ function reportTerminalPersistenceFailure(terminal) {
211
+ if (terminal.statusPersisted && terminal.artifactsScrubbed && !terminal.statusErrorClass) return;
212
+ const fields = [
213
+ `status_persisted=${terminal.statusPersisted}`,
214
+ `result_persisted=${terminal.resultPersisted}`,
215
+ `artifacts_scrubbed=${terminal.artifactsScrubbed}`,
216
+ `status_error=${terminal.statusErrorClass || "none"}`,
217
+ `result_error=${terminal.resultErrorClass || "none"}`,
218
+ `cleanup_error=${terminal.cleanupErrorClass || "none"}`,
219
+ ];
220
+ process.stderr.write(`managed job terminal persistence incomplete: ${fields.join(" ")}\n`);
213
221
  }
214
222
 
215
223
  async function runStep(step, index, phase, plan, resourceContext, cancellationAware, captureBudget) {
package/src/local/log.mjs CHANGED
@@ -46,9 +46,21 @@ export function createLogger(options = {}) {
46
46
 
47
47
  const write = (stream, level, label, color, message, fields) => {
48
48
  if (LEVEL_RANK[level] < LEVEL_RANK[minimumLevel]) return;
49
+ const sanitizedMessage = sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS);
50
+ if (options.format === "json") {
51
+ const entry = sanitizeLogValue({
52
+ timestamp: new Date().toISOString(),
53
+ level: level === "success" ? "info" : level,
54
+ component,
55
+ message: sanitizedMessage,
56
+ ...(fields && typeof fields === "object" ? fields : {}),
57
+ });
58
+ stream.write(`${JSON.stringify(entry)}\n`);
59
+ return;
60
+ }
49
61
  const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
50
62
  const suffix = formatFields(fields);
51
- stream.write(`${prefix} ${component}: ${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}${suffix}\n`);
63
+ stream.write(`${prefix} ${component}: ${sanitizedMessage}${suffix}\n`);
52
64
  };
53
65
 
54
66
  const event = (level, name, fields = {}, message = "") => {
@@ -83,6 +95,7 @@ export function createLogger(options = {}) {
83
95
  return {
84
96
  verbose: minimumLevel === "debug",
85
97
  level: minimumLevel,
98
+ format: options.format === "json" ? "json" : "text",
86
99
  child(childComponent) {
87
100
  return createLogger({ ...options, component: childComponent });
88
101
  },