@hasna/recordings 0.2.10 → 0.2.13

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 (167) hide show
  1. package/README.md +350 -35
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1922 -77
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1990 -29
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -0,0 +1,624 @@
1
+ import Darwin
2
+ import CryptoKit
3
+ import Foundation
4
+ import RecordingsUpdateProtocol
5
+ import RecordingsVerifierLauncher
6
+
7
+ @main
8
+ enum RecordingsUpdateBrokerMain {
9
+ static func main() {
10
+ guard geteuid() == 0 else { Darwin.exit(78) }
11
+ do {
12
+ let policy = try RootTrustStore.readPolicy()
13
+ // Recovery runs to a terminal exact-digest state before the Mach service
14
+ // accepts any peer. Ambiguous journals keep launchd fail-closed.
15
+ try BrokerStartupRecovery.recoverInterruptedTransactions()
16
+ let delegate = BrokerListenerDelegate(policy: policy)
17
+ withExtendedLifetime(delegate) {
18
+ let listener = NSXPCListener(machServiceName: RecordingsUpdateConstants.machServiceName)
19
+ listener.delegate = delegate
20
+ listener.resume()
21
+ RunLoop.current.run()
22
+ }
23
+ } catch {
24
+ Darwin.exit(78)
25
+ }
26
+ }
27
+ }
28
+
29
+ final class BrokerListenerDelegate: NSObject, NSXPCListenerDelegate {
30
+ private let policy: BrokerPolicy
31
+ private let peerPolicy: PeerIdentityPolicy
32
+
33
+ init(policy: BrokerPolicy) {
34
+ self.policy = policy
35
+ peerPolicy = PeerIdentityPolicy(policy: policy)
36
+ }
37
+
38
+ func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
39
+ do {
40
+ let peer = try peerPolicy.authenticate(connection)
41
+ let session = UpdateBrokerSession(peer: peer, policy: policy)
42
+ connection.exportedInterface = makeRecordingsUpdateXPCInterface()
43
+ connection.exportedObject = session
44
+ connection.invalidationHandler = { _ = session }
45
+ connection.resume()
46
+ return true
47
+ } catch {
48
+ connection.invalidate()
49
+ return false
50
+ }
51
+ }
52
+ }
53
+
54
+ final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
55
+ private let peer: AuthenticatedPeer
56
+ private let policy: BrokerPolicy
57
+ private let queue = DispatchQueue(label: "com.hasna.recordings.updater.transaction")
58
+
59
+ init(peer: AuthenticatedPeer, policy: BrokerPolicy) {
60
+ self.peer = peer
61
+ self.policy = policy
62
+ }
63
+
64
+ func install(
65
+ archive: FileHandle,
66
+ manifest: FileHandle,
67
+ envelope: FileHandle,
68
+ withReply reply: @escaping (NSDictionary) -> Void
69
+ ) {
70
+ queue.async { [peer, policy] in
71
+ do {
72
+ reply(try Self.performInstall(
73
+ archive: archive,
74
+ manifest: manifest,
75
+ envelope: envelope,
76
+ peer: peer,
77
+ policy: policy
78
+ ))
79
+ } catch {
80
+ if Self.requiresRecoveryRestart(after: error) {
81
+ // Never accept another release after an activation/journal
82
+ // durability failure. launchd restarts the broker, whose startup
83
+ // gate must reconcile exact journal and highest-seen state first.
84
+ Darwin.exit(75)
85
+ }
86
+ reply(Self.failureReply(for: error))
87
+ }
88
+ }
89
+ }
90
+
91
+ func queryStatus(withReply reply: @escaping (NSDictionary) -> Void) {
92
+ queue.async { [policy] in
93
+ do {
94
+ let state = try MonotonicReleaseStateStore().currentState()
95
+ let committed = state?.phase == "committed"
96
+ let statusMessage: String
97
+ if state == nil {
98
+ statusMessage = "No release committed"
99
+ } else if committed {
100
+ statusMessage = "Release committed"
101
+ } else if state?.phase == "aborted" {
102
+ statusMessage = "Release rolled back; a newer release is required"
103
+ } else {
104
+ statusMessage = "Release seen; retry or recovery required"
105
+ }
106
+ reply([
107
+ RecordingsUpdateReplyKey.success: true,
108
+ RecordingsUpdateReplyKey.code: "ok",
109
+ RecordingsUpdateReplyKey.message: statusMessage,
110
+ RecordingsUpdateReplyKey.releaseID: state?.releaseID ?? "none",
111
+ RecordingsUpdateReplyKey.installedVersion: committed ? (state?.version ?? "none") : "none",
112
+ RecordingsUpdateReplyKey.lifecycle: policy.lifecycle,
113
+ RecordingsUpdateReplyKey.rootMaintenanceSupported: policy.rootMaintenanceSupported,
114
+ RecordingsUpdateReplyKey.keyRotationSupported: policy.keyRotationSupported,
115
+ RecordingsUpdateReplyKey.keyEpoch: state?.keyEpoch ?? policy.initialKeyEpoch,
116
+ ])
117
+ } catch {
118
+ reply(Self.failureReply(for: error))
119
+ }
120
+ }
121
+ }
122
+
123
+ private static func performInstall(
124
+ archive: FileHandle,
125
+ manifest: FileHandle,
126
+ envelope: FileHandle,
127
+ peer: AuthenticatedPeer,
128
+ policy: BrokerPolicy
129
+ ) throws -> NSDictionary {
130
+ // Peer signing is admission control, not update authorization. A process running
131
+ // as the login UID can invoke the signed client; only this independently verified,
132
+ // monotonic signed envelope can authorize mutation.
133
+ guard policy.allowedClientIdentifiers.contains(peer.signingIdentifier),
134
+ peer.signingTeamIdentifier == policy.signingTeamIdentifier
135
+ else {
136
+ throw BrokerOperationError.unauthorizedPeer
137
+ }
138
+ let rootState = try RootUpdateState()
139
+ return try rootState.withExclusiveTransactionLock {
140
+ try rootState.ensureTransactionQuota()
141
+ let staged = try ArtifactIngestor(state: rootState).stage(
142
+ archive: archive,
143
+ manifest: manifest,
144
+ envelope: envelope
145
+ )
146
+ do {
147
+ let result = try performStagedInstall(staged: staged, policy: policy)
148
+ try cleanupIfTerminalOrUnjournaled(staged: staged, rootState: rootState)
149
+ return result
150
+ } catch {
151
+ if Self.requiresRecoveryRestart(after: error) {
152
+ // A state rename whose directory fsync failed is not durable proof
153
+ // of commit or abort even when an immediate reread sees the new
154
+ // phase. Preserve the exact journal for startup reconciliation.
155
+ throw error
156
+ }
157
+ do {
158
+ try cleanupIfTerminalOrUnjournaled(staged: staged, rootState: rootState)
159
+ } catch {
160
+ throw error
161
+ }
162
+ throw error
163
+ }
164
+ }
165
+ }
166
+
167
+ private static func performStagedInstall(
168
+ staged: StagedUpdate,
169
+ policy: BrokerPolicy
170
+ ) throws -> NSDictionary {
171
+ guard let envelopeData = staged.envelope.data,
172
+ let signedEnvelope = try? JSONDecoder().decode(SignedReleaseEnvelope.self, from: envelopeData)
173
+ else {
174
+ throw BrokerOperationError.invalidEnvelope
175
+ }
176
+ // The envelope may request only the key pinned by the immutable installed
177
+ // cohort. Never select a root trust file from an unverified payload field.
178
+ let publicKey = try RootTrustStore.readEnvelopePublicKey(epoch: policy.initialKeyEpoch)
179
+ let payload: ReleaseEnvelopePayload
180
+ do {
181
+ payload = try signedEnvelope.verify(publicKeyData: publicKey)
182
+ } catch let validationError as ReleaseEnvelopeValidationError {
183
+ if case .brokerTooOld = validationError {
184
+ throw BrokerOperationError.unsupportedLifecycle
185
+ }
186
+ throw BrokerOperationError.signatureRejected
187
+ } catch {
188
+ throw BrokerOperationError.signatureRejected
189
+ }
190
+ let hostProductVersion = try HostOSProductVersionReader.read()
191
+ try HostOSVersionPolicy.validate(
192
+ candidateMinimumOSVersion: payload.minimumOSVersion,
193
+ hostProductVersion: hostProductVersion
194
+ )
195
+ guard payload.keyEpoch == policy.initialKeyEpoch else {
196
+ throw MonotonicStateError.immutableCohortChange
197
+ }
198
+ guard payload.signingTeamIdentifier == policy.signingTeamIdentifier,
199
+ payload.artifactSHA256 == staged.archive.sha256,
200
+ payload.artifactByteCount == UInt64(staged.archive.byteCount),
201
+ payload.manifestSHA256 == staged.manifest.sha256,
202
+ payload.manifestByteCount == UInt64(staged.manifest.byteCount)
203
+ else {
204
+ throw BrokerOperationError.artifactMismatch
205
+ }
206
+ do {
207
+ try ReleaseCodeValidator.validateProtectedComponents(payload: payload)
208
+ } catch {
209
+ guard payload.purpose == "bootstrap" else {
210
+ throw BrokerOperationError.unsupportedLifecycle
211
+ }
212
+ throw error
213
+ }
214
+ let marker = try RootTrustStore.readBootstrapMarker()
215
+ let markerDigest = SHA256.hash(data: marker)
216
+ .map { String(format: "%02x", $0) }
217
+ .joined()
218
+ guard markerDigest == payload.bootstrapMarkerSHA256 else {
219
+ if payload.purpose == "update" {
220
+ throw BrokerOperationError.unsupportedLifecycle
221
+ }
222
+ throw BrokerOperationError.artifactMismatch
223
+ }
224
+ if payload.purpose == "bootstrap" {
225
+ _ = try ReleaseCodeValidator.validateCandidate(
226
+ at: RecordingsUpdateConstants.applicationPath,
227
+ payload: payload,
228
+ policy: policy
229
+ )
230
+ let stateStore = try MonotonicReleaseStateStore()
231
+ var requiresJournalFinalization = false
232
+ let result = try stateStore.perform(
233
+ payload: payload,
234
+ policy: policy,
235
+ prepare: { decision in
236
+ switch decision {
237
+ case .advance:
238
+ try prepareBootstrapCommit(staged: staged, payload: payload)
239
+ requiresJournalFinalization = true
240
+ case .resumeSeen:
241
+ _ = try requirePreparedBootstrapCommit(staged: staged, payload: payload)
242
+ requiresJournalFinalization = true
243
+ case .alreadyCommitted:
244
+ break
245
+ }
246
+ }
247
+ ) { decision in
248
+ switch decision {
249
+ case .advance, .resumeSeen, .alreadyCommitted:
250
+ if requiresJournalFinalization {
251
+ var journal = try requirePreparedBootstrapCommit(
252
+ staged: staged,
253
+ payload: payload
254
+ )
255
+ journal.phase = "bootstrap-commit-pending"
256
+ try DurableInstallJournal(transactionDirectory: staged.directory).write(journal)
257
+ }
258
+ }
259
+ return updateSuccessReply(
260
+ transactionID: staged.transactionID.uuidString.lowercased(),
261
+ releaseID: payload.releaseID,
262
+ installedVersion: payload.version
263
+ )
264
+ }
265
+ if requiresJournalFinalization {
266
+ try finalizeBootstrapCommit(staged: staged, payload: payload, stateStore: stateStore)
267
+ }
268
+ return result
269
+ }
270
+ let verifierOutput = try ArtifactVerifierRunner().materialize(stagedUpdate: staged)
271
+ let candidateRoot = staged.directory + "/candidate"
272
+ guard mkdir(candidateRoot, 0o700) == 0 else {
273
+ throw BrokerOperationError.candidateRejected
274
+ }
275
+ let candidateApplication = candidateRoot + "/Recordings.app"
276
+ let verifierIDs = try verifierAccountIDs()
277
+ try CanonicalTreeCopier.copyApplication(
278
+ from: verifierOutput,
279
+ to: candidateApplication,
280
+ verifierUserID: verifierIDs.user
281
+ )
282
+ _ = try ReleaseCodeValidator.validateCandidate(
283
+ at: candidateApplication,
284
+ payload: payload,
285
+ policy: policy
286
+ )
287
+ let activator = AtomicActivator()
288
+ return try MonotonicReleaseStateStore().perform(
289
+ payload: payload,
290
+ policy: policy,
291
+ prepare: { decision in
292
+ switch decision {
293
+ case .advance:
294
+ try activator.prepareActivation(
295
+ candidatePath: candidateApplication,
296
+ stagedUpdate: staged,
297
+ payload: payload,
298
+ excludingAuthenticatedClientPID: peer.processIdentifier
299
+ )
300
+ case .resumeSeen:
301
+ try activator.prepareActivation(
302
+ candidatePath: candidateApplication,
303
+ stagedUpdate: staged,
304
+ payload: payload,
305
+ excludingAuthenticatedClientPID: peer.processIdentifier
306
+ )
307
+ case .alreadyCommitted:
308
+ break
309
+ }
310
+ }
311
+ ) { decision in
312
+ switch decision {
313
+ case .alreadyCommitted:
314
+ _ = try ReleaseCodeValidator.validateCandidate(
315
+ at: RecordingsUpdateConstants.applicationPath,
316
+ payload: payload,
317
+ policy: policy
318
+ )
319
+ case .advance:
320
+ try activator.activatePrepared(
321
+ stagedUpdate: staged,
322
+ payload: payload,
323
+ excludingAuthenticatedClientPID: peer.processIdentifier
324
+ )
325
+ case .resumeSeen:
326
+ try activator.activatePrepared(
327
+ stagedUpdate: staged,
328
+ payload: payload,
329
+ excludingAuthenticatedClientPID: peer.processIdentifier
330
+ )
331
+ }
332
+ return updateSuccessReply(
333
+ transactionID: staged.transactionID.uuidString.lowercased(),
334
+ releaseID: payload.releaseID,
335
+ installedVersion: payload.version
336
+ )
337
+ }
338
+ }
339
+
340
+ private static func cleanupIfTerminalOrUnjournaled(
341
+ staged: StagedUpdate,
342
+ rootState: RootUpdateState
343
+ ) throws {
344
+ let journal = try DurableInstallJournal(transactionDirectory: staged.directory).read()
345
+ if let journal, journal.resolvedOperation == .bootstrapCommit {
346
+ guard journal.phase == "bootstrap-committed" else { return }
347
+ let state: PersistedReleaseState?
348
+ do {
349
+ state = try MonotonicReleaseStateStore().currentState()
350
+ } catch {
351
+ throw IngestError.couldNotCleanup
352
+ }
353
+ let candidateTreeSHA256: String
354
+ do {
355
+ candidateTreeSHA256 = try CanonicalTree.digest(at: journal.applicationPath)
356
+ } catch {
357
+ throw IngestError.couldNotCleanup
358
+ }
359
+ guard let state,
360
+ state.purpose == journal.expectedPurpose,
361
+ state.phase == "committed",
362
+ state.keyEpoch == journal.keyEpoch,
363
+ state.releaseSequence == journal.releaseSequence,
364
+ state.releaseID == journal.releaseID,
365
+ state.cohortPackageSHA256 == journal.cohortPackageSHA256,
366
+ state.envelopePayloadSHA256 == journal.envelopePayloadSHA256
367
+ else {
368
+ throw IngestError.couldNotCleanup
369
+ }
370
+ guard candidateTreeSHA256 == journal.candidateTreeSHA256 else {
371
+ throw IngestError.couldNotCleanup
372
+ }
373
+ try rootState.removeTransactionDirectory(id: staged.transactionID)
374
+ return
375
+ }
376
+ if let journal, journal.phase == "committed" {
377
+ let state: PersistedReleaseState?
378
+ do {
379
+ state = try MonotonicReleaseStateStore().currentState()
380
+ } catch {
381
+ throw IngestError.couldNotCleanup
382
+ }
383
+ guard let state,
384
+ state.phase == "committed",
385
+ state.keyEpoch == journal.keyEpoch,
386
+ state.releaseSequence == journal.releaseSequence,
387
+ state.releaseID == journal.releaseID,
388
+ state.cohortPackageSHA256 == journal.cohortPackageSHA256,
389
+ state.envelopePayloadSHA256 == journal.envelopePayloadSHA256
390
+ else {
391
+ // A committed physical journal with only highest-seen state is
392
+ // still required by startup recovery and must not be pruned.
393
+ return
394
+ }
395
+ } else if let journal {
396
+ if journal.phase != "rolled-back" { return }
397
+ let state: PersistedReleaseState?
398
+ do {
399
+ state = try MonotonicReleaseStateStore().currentState()
400
+ } catch {
401
+ throw IngestError.couldNotCleanup
402
+ }
403
+ if let state,
404
+ state.phase == "seen",
405
+ state.keyEpoch == journal.keyEpoch,
406
+ state.releaseSequence == journal.releaseSequence,
407
+ state.releaseID == journal.releaseID,
408
+ state.cohortPackageSHA256 == journal.cohortPackageSHA256,
409
+ state.envelopePayloadSHA256 == journal.envelopePayloadSHA256 {
410
+ // Recovery needs this exact rollback proof to close the seen barrier
411
+ // as aborted before the protected transaction can be pruned.
412
+ return
413
+ }
414
+ }
415
+ try rootState.removeTransactionDirectory(id: staged.transactionID)
416
+ }
417
+
418
+ private static func prepareBootstrapCommit(
419
+ staged: StagedUpdate,
420
+ payload: ReleaseEnvelopePayload
421
+ ) throws {
422
+ let durableJournal = DurableInstallJournal(transactionDirectory: staged.directory)
423
+ if let _ = try durableJournal.read() {
424
+ throw InstallJournalError.invalidJournal
425
+ }
426
+ let binding = try bootstrapApplicationBinding(expectedTreeSHA256: payload.candidateTreeSHA256)
427
+ let journal = BrokerInstallJournal(
428
+ schemaVersion: RecordingsUpdateConstants.protocolVersion,
429
+ transactionID: staged.transactionID.uuidString.lowercased(),
430
+ phase: "bootstrap-prepared",
431
+ releaseID: payload.releaseID,
432
+ releaseSequence: payload.releaseSequence,
433
+ keyEpoch: payload.keyEpoch,
434
+ envelopePayloadSHA256: try envelopeDigest(payload),
435
+ artifactSHA256: staged.archive.sha256,
436
+ manifestSHA256: staged.manifest.sha256,
437
+ cohortPackageSHA256: payload.packageSHA256,
438
+ candidateTreeSHA256: binding.treeSHA256,
439
+ minimumOSVersion: payload.minimumOSVersion,
440
+ candidateApplicationMode: binding.applicationMode,
441
+ candidateExecutableModes: binding.executableModes,
442
+ previousTreeSHA256: nil,
443
+ previousApplicationMode: nil,
444
+ previousExecutableModes: nil,
445
+ previousApplicationPath: nil,
446
+ candidateApplicationPath: RecordingsUpdateConstants.applicationPath,
447
+ applicationPath: RecordingsUpdateConstants.applicationPath,
448
+ operation: .bootstrapCommit,
449
+ transactionDirectory: staged.directory,
450
+ bootstrapPriorMonotonicState: "absent"
451
+ )
452
+ try durableJournal.write(journal)
453
+ }
454
+
455
+ private static func requirePreparedBootstrapCommit(
456
+ staged: StagedUpdate,
457
+ payload: ReleaseEnvelopePayload
458
+ ) throws -> BrokerInstallJournal {
459
+ let durableJournal = DurableInstallJournal(transactionDirectory: staged.directory)
460
+ let expectedEnvelopeDigest = try envelopeDigest(payload)
461
+ guard let journal = try durableJournal.read(),
462
+ journal.resolvedOperation == .bootstrapCommit,
463
+ journal.phase == "bootstrap-prepared" ||
464
+ journal.phase == "bootstrap-commit-pending",
465
+ journal.transactionID == staged.transactionID.uuidString.lowercased(),
466
+ journal.transactionDirectory == staged.directory,
467
+ journal.bootstrapPriorMonotonicState == "absent",
468
+ journal.expectedPurpose == payload.purpose,
469
+ journal.releaseID == payload.releaseID,
470
+ journal.releaseSequence == payload.releaseSequence,
471
+ journal.keyEpoch == payload.keyEpoch,
472
+ journal.artifactSHA256 == staged.archive.sha256,
473
+ journal.manifestSHA256 == staged.manifest.sha256,
474
+ journal.cohortPackageSHA256 == payload.packageSHA256,
475
+ journal.candidateTreeSHA256 == payload.candidateTreeSHA256,
476
+ journal.minimumOSVersion == payload.minimumOSVersion,
477
+ journal.envelopePayloadSHA256 == expectedEnvelopeDigest
478
+ else {
479
+ throw InstallJournalError.invalidJournal
480
+ }
481
+ let binding = try bootstrapApplicationBinding(expectedTreeSHA256: payload.candidateTreeSHA256)
482
+ guard journal.candidateApplicationMode == binding.applicationMode,
483
+ journal.candidateExecutableModes == binding.executableModes
484
+ else {
485
+ throw InstallJournalError.invalidJournal
486
+ }
487
+ return journal
488
+ }
489
+
490
+ private static func finalizeBootstrapCommit(
491
+ staged: StagedUpdate,
492
+ payload: ReleaseEnvelopePayload,
493
+ stateStore: MonotonicReleaseStateStore
494
+ ) throws {
495
+ var journal = try requirePreparedBootstrapCommit(staged: staged, payload: payload)
496
+ guard let current = try stateStore.currentState(),
497
+ current.purpose == journal.expectedPurpose,
498
+ current.phase == "committed",
499
+ current.keyEpoch == journal.keyEpoch,
500
+ current.releaseSequence == journal.releaseSequence,
501
+ current.releaseID == journal.releaseID,
502
+ current.cohortPackageSHA256 == journal.cohortPackageSHA256,
503
+ current.envelopePayloadSHA256 == journal.envelopePayloadSHA256
504
+ else {
505
+ throw MonotonicStateError.recoveryStateMismatch
506
+ }
507
+ journal.phase = "bootstrap-committed"
508
+ try DurableInstallJournal(transactionDirectory: staged.directory).write(journal)
509
+ }
510
+
511
+ private static func bootstrapApplicationBinding(
512
+ expectedTreeSHA256: String
513
+ ) throws -> (treeSHA256: String, applicationMode: UInt16, executableModes: [String: UInt16]) {
514
+ let applicationPath = RecordingsUpdateConstants.applicationPath
515
+ let before = try CanonicalTree.digest(at: applicationPath)
516
+ guard before == expectedTreeSHA256 else { throw InstallJournalError.invalidJournal }
517
+ let applicationMode = try bootstrapMode(
518
+ at: applicationPath,
519
+ expectedType: mode_t(S_IFDIR),
520
+ requireExecutable: true
521
+ )
522
+ let relativeExecutables = [
523
+ "Contents/MacOS/Recordings",
524
+ "Contents/Helpers/recordings",
525
+ "Contents/Helpers/recordings-update-client",
526
+ ]
527
+ var executableModes: [String: UInt16] = [:]
528
+ for relativePath in relativeExecutables {
529
+ executableModes[relativePath] = try bootstrapMode(
530
+ at: applicationPath + "/" + relativePath,
531
+ expectedType: mode_t(S_IFREG),
532
+ requireExecutable: true
533
+ )
534
+ }
535
+ let after = try CanonicalTree.digest(at: applicationPath)
536
+ guard before == after else { throw InstallJournalError.invalidJournal }
537
+ return (after, applicationMode, executableModes)
538
+ }
539
+
540
+ private static func bootstrapMode(
541
+ at path: String,
542
+ expectedType: mode_t,
543
+ requireExecutable: Bool
544
+ ) throws -> UInt16 {
545
+ var metadata = stat()
546
+ guard lstat(path, &metadata) == 0,
547
+ (metadata.st_mode & S_IFMT) == expectedType,
548
+ metadata.st_uid == 0,
549
+ (metadata.st_mode & 0o022) == 0,
550
+ (!requireExecutable || (metadata.st_mode & 0o500) == 0o500),
551
+ DarwinACLValidator.pathHasNoExtendedACL(path, directory: expectedType == S_IFDIR)
552
+ else {
553
+ throw InstallJournalError.invalidJournal
554
+ }
555
+ return UInt16(metadata.st_mode & 0o777)
556
+ }
557
+
558
+ private static func envelopeDigest(_ payload: ReleaseEnvelopePayload) throws -> String {
559
+ try SHA256.hash(data: payload.canonicalData())
560
+ .map { String(format: "%02x", $0) }
561
+ .joined()
562
+ }
563
+
564
+ private static func verifierAccountIDs() throws -> (user: uid_t, group: gid_t) {
565
+ var user: uid_t = 0
566
+ var group: gid_t = 0
567
+ let status = RecordingsUpdateConstants.artifactVerifierAccount.withCString {
568
+ recordings_lookup_verifier_account($0, &user, &group)
569
+ }
570
+ guard status == 0 else { throw BrokerOperationError.candidateRejected }
571
+ return (user, group)
572
+ }
573
+
574
+ private static func failureReply(for error: Error) -> NSDictionary {
575
+ switch error {
576
+ case is BrokerSecurityError, BrokerOperationError.unauthorizedPeer:
577
+ updateFailureReply(.unauthorizedPeer, message: "The update request is unauthorized")
578
+ case is ReleaseEnvelopeValidationError, BrokerOperationError.invalidEnvelope:
579
+ updateFailureReply(.invalidEnvelope, message: "The signed release envelope is invalid")
580
+ case BrokerOperationError.signatureRejected:
581
+ updateFailureReply(.signatureRejected, message: "The signed release envelope was rejected")
582
+ case MonotonicStateError.immutableCohortChange, BrokerOperationError.unsupportedLifecycle:
583
+ updateFailureReply(
584
+ .unsupportedLifecycle,
585
+ message: "This installation uses an immutable updater cohort. Only app-artifact updates are supported. Root updater or key changes require managed reprovisioning; no files were changed."
586
+ )
587
+ case is MonotonicStateError, BrokerOperationError.invalidBootstrapState:
588
+ updateFailureReply(.rollbackRejected, message: "The release would violate monotonic update policy")
589
+ case is CodeValidationError:
590
+ updateFailureReply(.codeIdentityRejected, message: "A signed release component was rejected")
591
+ case is IngestError:
592
+ updateFailureReply(.invalidDescriptor, message: "A retained update input was rejected")
593
+ case is HostOSVersionPolicyError, is HostOSProductVersionQueryError,
594
+ is VerifierRunnerError, is CanonicalCopyError, BrokerOperationError.candidateRejected:
595
+ updateFailureReply(.candidateRejected, message: "The isolated release candidate was rejected")
596
+ case is AtomicActivationError, is InstallJournalError:
597
+ updateFailureReply(.activationFailed, message: "The transactional application activation failed")
598
+ default:
599
+ updateFailureReply(.internalFailure, message: "The update broker failed closed")
600
+ }
601
+ }
602
+
603
+ private static func requiresRecoveryRestart(after error: Error) -> Bool {
604
+ if error is AtomicActivationError || error is InstallJournalError {
605
+ return true
606
+ }
607
+ if let ingestError = error as? IngestError, case .couldNotCleanup = ingestError {
608
+ return true
609
+ }
610
+ guard let stateError = error as? MonotonicStateError else { return false }
611
+ if case .couldNotPersist = stateError { return true }
612
+ return false
613
+ }
614
+ }
615
+
616
+ enum BrokerOperationError: Error {
617
+ case unauthorizedPeer
618
+ case invalidEnvelope
619
+ case signatureRejected
620
+ case artifactMismatch
621
+ case candidateRejected
622
+ case invalidBootstrapState
623
+ case unsupportedLifecycle
624
+ }
@@ -0,0 +1,63 @@
1
+ import Darwin
2
+ import Foundation
3
+ import RecordingsVerifierLauncher
4
+
5
+ enum CanonicalTreeCopier {
6
+ static func copyApplication(
7
+ from verifierRoot: String,
8
+ to rootCandidatePath: String,
9
+ verifierUserID: uid_t
10
+ ) throws {
11
+ let source = Darwin.open(verifierRoot, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
12
+ var sourceMetadata = stat()
13
+ guard source >= 0,
14
+ fstat(source, &sourceMetadata) == 0,
15
+ (sourceMetadata.st_mode & S_IFMT) == S_IFDIR,
16
+ sourceMetadata.st_uid == 0,
17
+ (sourceMetadata.st_mode & 0o077) == 0,
18
+ DarwinACLValidator.descriptorHasNoExtendedACL(source)
19
+ else {
20
+ if source >= 0 { Darwin.close(source) }
21
+ throw CanonicalCopyError.unsafeSourceEntry
22
+ }
23
+ defer { Darwin.close(source) }
24
+ let destinationRoot = URL(fileURLWithPath: rootCandidatePath).deletingLastPathComponent().path
25
+ guard URL(fileURLWithPath: rootCandidatePath).lastPathComponent == "Recordings.app" else {
26
+ throw CanonicalCopyError.nonCanonicalRoot
27
+ }
28
+ let destination = Darwin.open(
29
+ destinationRoot,
30
+ O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW
31
+ )
32
+ var destinationMetadata = stat()
33
+ guard destination >= 0,
34
+ fstat(destination, &destinationMetadata) == 0,
35
+ (destinationMetadata.st_mode & S_IFMT) == S_IFDIR,
36
+ destinationMetadata.st_uid == 0,
37
+ (destinationMetadata.st_mode & 0o077) == 0,
38
+ DarwinACLValidator.descriptorHasNoExtendedACL(destination)
39
+ else {
40
+ if destination >= 0 { Darwin.close(destination) }
41
+ throw CanonicalCopyError.couldNotCreateDestination
42
+ }
43
+ defer { Darwin.close(destination) }
44
+ let status = recordings_copy_canonical_application_tree(source, destination, verifierUserID)
45
+ guard status == 0 else { throw CanonicalCopyError.copyRejected(status) }
46
+ }
47
+ }
48
+
49
+ enum CanonicalCopyError: Error, CustomStringConvertible {
50
+ case nonCanonicalRoot
51
+ case unsafeSourceEntry
52
+ case couldNotCreateDestination
53
+ case copyRejected(Int32)
54
+
55
+ var description: String {
56
+ switch self {
57
+ case .nonCanonicalRoot: "The verifier output does not contain the canonical application root"
58
+ case .unsafeSourceEntry: "The verifier output directory is unsafe"
59
+ case .couldNotCreateDestination: "The root-owned candidate directory is unsafe"
60
+ case let .copyRejected(status): "Descriptor-relative candidate copying was rejected (\(status))"
61
+ }
62
+ }
63
+ }