@hasna/recordings 0.2.14 → 0.3.2

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 (123) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2094 -280
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +19 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +635 -50
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +141 -57
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +188 -80
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +109 -50
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/ci-linux-suite.ts +403 -0
  59. package/scripts/ci-native-build.ts +402 -0
  60. package/scripts/enforce_identity_migration.sh +110 -0
  61. package/scripts/generate-sdk.ts +1 -1
  62. package/scripts/install_macos_app.sh +192 -31
  63. package/scripts/macos_artifact.ts +323 -43
  64. package/scripts/native/realtime-settle-probe/README.md +39 -0
  65. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  66. package/scripts/policy/local-only-approved-targets.txt +21 -0
  67. package/scripts/read_local_only_targets.sh +203 -0
  68. package/scripts/scan-artifact.ts +40 -0
  69. package/scripts/set-version.ts +145 -0
  70. package/scripts/smoke_macos_app.sh +62 -16
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
  81. package/src/native/Recordings/App/RecordingsApp.swift +37 -1
  82. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  83. package/src/native/Recordings/App/SidebarView.swift +1 -1
  84. package/src/native/Recordings/Package.swift +24 -5
  85. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  86. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  87. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  88. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  89. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  90. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  91. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
  92. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  93. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  94. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  95. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  96. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  97. package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
  98. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  99. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  100. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  101. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  102. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  103. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  104. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  105. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  106. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  107. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  108. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  109. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  110. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  111. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  112. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  113. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  114. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  115. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  116. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  117. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  118. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  119. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  120. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  121. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  122. package/src/native/Recordings/build.sh +226 -20
  123. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -3,29 +3,59 @@ import Foundation
3
3
  import Testing
4
4
  @testable import RecordingsLib
5
5
 
6
- /// A recorder that starts instantly and records when it was asked to. Lets the production
7
- /// `startRecording` path run without microphone hardware or TCC grants.
6
+ /// A recorder that starts instantly and records when it was asked to, and that can deliver its
7
+ /// first PCM callback on demand. Lets the production `startRecording` path run without
8
+ /// microphone hardware or TCC grants, including the warm-up window between `start()` returning
9
+ /// and the first sample arriving.
8
10
  private final class FakePCMRecorder: PCMRecordingSource, @unchecked Sendable {
9
11
  private let lock = NSLock()
10
12
  private var startedFlag = false
13
+ private var stoppedFlag = false
14
+ private var onPCM: (@Sendable (Data) -> Void)?
11
15
 
12
16
  var started: Bool {
13
17
  lock.withLock { startedFlag }
14
18
  }
15
19
 
20
+ /// Whether `stop()` was called. The microphone staying open is the worst outcome of a
21
+ /// botched teardown — it lights the macOS in-use indicator — so every abandon test asserts
22
+ /// this.
23
+ var isStopped: Bool {
24
+ lock.withLock { stoppedFlag }
25
+ }
26
+
27
+ func attach(onPCM: @escaping @Sendable (Data) -> Void) {
28
+ lock.withLock { self.onPCM = onPCM }
29
+ }
30
+
31
+ /// Delivers the recorder's first callback the way a real `AVAudioEngine` tap does, roughly
32
+ /// 100 ms after `start()` has already returned.
33
+ ///
34
+ /// `bytes: 0` is a deliberate test seam: it drives the engine's first-chunk promotion —
35
+ /// which is what the ordering tests below are about — while leaving the stream pipe empty,
36
+ /// so the pipeline still ends in the deterministic no-audio state instead of writing a WAV
37
+ /// and shelling out to the CLI. `PCMStreamPipe` skips empty chunks, and the production
38
+ /// recorder never emits one.
39
+ func emitFirstChunk(bytes: Int = 0) {
40
+ let callback = lock.withLock { onPCM }
41
+ callback?(Data(repeating: 0, count: bytes))
42
+ }
43
+
16
44
  func start() throws {
17
45
  lock.withLock { startedFlag = true }
18
46
  }
19
47
 
20
- func stop() {}
48
+ func stop() {
49
+ lock.withLock { stoppedFlag = true }
50
+ }
21
51
  }
22
52
 
23
53
  @MainActor
24
54
  private func makeStartableEngine(
25
55
  recorder: FakePCMRecorder,
26
- selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken?
56
+ selectionCapture: @escaping @Sendable (pid_t) -> AccessibilitySelectionToken? = { _ in nil }
27
57
  ) -> RecordingEngine {
28
- let engine = RecordingEngine()
58
+ let engine = RecordingEngine(homePath: makeIsolatedTestHome("start-timing-tests"))
29
59
  engine.openAIAPIKeyProvider = { "" }
30
60
  engine.microphoneAuthorization = { .authorized }
31
61
  engine.accessibilityTrustCheck = { true }
@@ -33,10 +63,14 @@ private func makeStartableEngine(
33
63
  engine.frontmostAppSnapshot = {
34
64
  FrontmostAppSnapshot(pid: 99_999, bundleIdentifier: "com.example.editor", launchDate: Date())
35
65
  }
36
- engine.recorderFactory = { _ in recorder }
66
+ engine.recorderFactory = { onPCM in
67
+ recorder.attach(onPCM: onPCM)
68
+ return recorder
69
+ }
37
70
  engine.selectionCapture = selectionCapture
38
71
  engine.focusedWindowTitleLookup = { _ in nil }
39
72
  engine.pasteInterceptorForTesting = { _, _, _ in }
73
+ engine.commandCLI = { _, _, _ in "ERROR: command CLI must not run in this test" }
40
74
  return engine
41
75
  }
42
76
 
@@ -53,10 +87,50 @@ private func waitUntil(
53
87
  return condition()
54
88
  }
55
89
 
90
+ /// Drives the capture to live the way the recorder does, and waits for the MainActor hop that
91
+ /// promotes it.
92
+ @MainActor
93
+ private func confirmCapture(_ engine: RecordingEngine, _ recorder: FakePCMRecorder) async -> Bool {
94
+ recorder.emitFirstChunk()
95
+ return await waitUntil { engine.isRecording }
96
+ }
97
+
98
+ /// Full teardown checklist for an abandoned attempt. Kept in one place because a partial
99
+ /// assertion here is indistinguishable from a pass: an abandon that forgets `recorder.stop()`
100
+ /// leaves the microphone open with the macOS indicator lit, and one that forgets
101
+ /// `isWarmingUpCapture = false` wedges the engine so the start gate refuses every later press.
102
+ @MainActor
103
+ private func expectFullyTornDown(
104
+ _ engine: RecordingEngine,
105
+ _ recorder: FakePCMRecorder,
106
+ sourceLocation: SourceLocation = #_sourceLocation
107
+ ) {
108
+ #expect(recorder.isStopped, "the microphone must be released, not left open", sourceLocation: sourceLocation)
109
+ #expect(!engine.isWarmingUpCapture, "a warming flag left set wedges the start gate", sourceLocation: sourceLocation)
110
+ #expect(!engine.isRecording, sourceLocation: sourceLocation)
111
+ #expect(!engine.captureIsActive, sourceLocation: sourceLocation)
112
+ #expect(!engine.isTranscribing, "an empty buffer must never enter the transcription pipeline", sourceLocation: sourceLocation)
113
+ #expect(engine.canStartRecording, "the engine must be immediately ready to try again", sourceLocation: sourceLocation)
114
+ }
115
+
116
+ /// The disclosure an empty attempt owes the user. Asserted on `blockedReason` rather than
117
+ /// `statusMessage` alone: only `blockedReason` reaches the always-visible menu-bar glyph, and
118
+ /// `statusMessage` is rewritten on every return to idle.
119
+ @MainActor
120
+ private func expectEmptyAttemptDisclosed(
121
+ _ engine: RecordingEngine,
122
+ _ alert: RecordingAttemptAlert,
123
+ sourceLocation: SourceLocation = #_sourceLocation
124
+ ) {
125
+ #expect(engine.blockedReason == alert.message, "the outcome must reach the always-on surface", sourceLocation: sourceLocation)
126
+ #expect(engine.statusMessage == alert.message, sourceLocation: sourceLocation)
127
+ #expect(engine.canStartRecording, "a disclosure must not disable Start", sourceLocation: sourceLocation)
128
+ }
129
+
56
130
  @MainActor
57
131
  struct RecordingStartTimingTests {
58
132
  @Test("the recorder starts on keydown even while the AX selection capture is blocked")
59
- func recorderStartDoesNotWaitOnSelectionCapture() {
133
+ func recorderStartDoesNotWaitOnSelectionCapture() async {
60
134
  let captureGate = DispatchSemaphore(value: 0)
61
135
  let recorder = FakePCMRecorder()
62
136
  let engine = makeStartableEngine(recorder: recorder) { _ in
@@ -72,9 +146,13 @@ struct RecordingStartTimingTests {
72
146
  // ordering the recorder is live before the capture has produced anything.
73
147
  engine.startRecording(trigger: .manual)
74
148
  #expect(recorder.started, "recorder must start while AX capture is still pending")
75
- #expect(engine.isRecording)
149
+ #expect(engine.isWarmingUpCapture, "start() returning is warm-up, not captured audio")
150
+ #expect(engine.captureIsActive)
76
151
  #expect(engine.flowPhase == .listening)
77
152
 
153
+ #expect(await confirmCapture(engine, recorder))
154
+ #expect(!engine.isWarmingUpCapture)
155
+
78
156
  captureGate.signal()
79
157
  engine.cancelRecording()
80
158
  #expect(engine.flowPhase == .idle)
@@ -90,7 +168,7 @@ struct RecordingStartTimingTests {
90
168
  }
91
169
 
92
170
  engine.startRecording(trigger: .manual)
93
- #expect(engine.isRecording)
171
+ #expect(await confirmCapture(engine, recorder))
94
172
  engine.stopAndTranscribe()
95
173
  #expect(engine.isTranscribing)
96
174
 
@@ -111,7 +189,7 @@ struct RecordingStartTimingTests {
111
189
  }
112
190
 
113
191
  @Test("a released key before the capture resolves still cancels cleanly")
114
- func cancelDuringPendingCapture() {
192
+ func cancelDuringPendingCapture() async {
115
193
  let captureGate = DispatchSemaphore(value: 0)
116
194
  let recorder = FakePCMRecorder()
117
195
  let engine = makeStartableEngine(recorder: recorder) { _ in
@@ -119,7 +197,7 @@ struct RecordingStartTimingTests {
119
197
  return nil
120
198
  }
121
199
  engine.startRecording(trigger: .manual)
122
- #expect(engine.isRecording)
200
+ #expect(await confirmCapture(engine, recorder))
123
201
  engine.cancelRecording()
124
202
  #expect(!engine.isRecording)
125
203
  #expect(engine.flowPhase == .idle)
@@ -127,3 +205,121 @@ struct RecordingStartTimingTests {
127
205
  #expect(engine.canStartRecording)
128
206
  }
129
207
  }
208
+
209
+ /// The defect this suite exists for: `recorder.start()` returns roughly 100 ms before the
210
+ /// microphone delivers a sample, so a trigger released inside that window captured nothing at
211
+ /// all. The engine already had the right branch for it — "released before recording started;
212
+ /// cancelling pending start" — and it was simply unreachable, because `isRecording` flipped on
213
+ /// `start()` returning. A 539 ms hold on the owner's machine missed the branch by 20 ms and ran
214
+ /// the whole transcription pipeline over an empty buffer, silently.
215
+ @MainActor
216
+ struct RecordingCaptureWarmUpTests {
217
+ @Test("a trigger released between recorder start and the first PCM chunk cancels instead of transcribing")
218
+ func releaseDuringWarmUpCancelsInsteadOfTranscribing() {
219
+ let recorder = FakePCMRecorder()
220
+ let engine = makeStartableEngine(recorder: recorder)
221
+
222
+ engine.startRecording(trigger: .fnKey)
223
+ #expect(recorder.started)
224
+ #expect(engine.isWarmingUpCapture)
225
+ #expect(!engine.isRecording, "no audio has arrived, so nothing is being recorded yet")
226
+
227
+ engine.handleTriggerRelease(.fnKey)
228
+
229
+ expectFullyTornDown(engine, recorder)
230
+ expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
231
+ }
232
+
233
+ @Test("the configurable shortcut takes the same path as fn")
234
+ func releaseDuringWarmUpCancelsForTheKeyboardShortcut() {
235
+ let recorder = FakePCMRecorder()
236
+ let engine = makeStartableEngine(recorder: recorder)
237
+
238
+ engine.startRecording(trigger: .keyboardShortcut)
239
+ #expect(engine.isWarmingUpCapture)
240
+ engine.handleTriggerRelease(.keyboardShortcut)
241
+
242
+ expectFullyTornDown(engine, recorder)
243
+ expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
244
+ }
245
+
246
+ @Test("a release after the first PCM chunk transcribes as before")
247
+ func releaseAfterFirstChunkTranscribes() async {
248
+ let recorder = FakePCMRecorder()
249
+ let engine = makeStartableEngine(recorder: recorder)
250
+
251
+ engine.startRecording(trigger: .fnKey)
252
+ #expect(await confirmCapture(engine, recorder))
253
+ #expect(!engine.isWarmingUpCapture)
254
+
255
+ engine.handleTriggerRelease(.fnKey)
256
+ #expect(engine.isTranscribing, "audio existed, so the pipeline must run")
257
+ #expect(engine.blockedReason == nil, "a recording that captured audio discloses nothing")
258
+ }
259
+
260
+ @Test("a PCM chunk that lands after the attempt was abandoned cannot resurrect it")
261
+ func lateChunkCannotResurrectAnAbandonedAttempt() async {
262
+ let recorder = FakePCMRecorder()
263
+ let engine = makeStartableEngine(recorder: recorder)
264
+
265
+ engine.startRecording(trigger: .fnKey)
266
+ engine.handleTriggerRelease(.fnKey)
267
+ expectFullyTornDown(engine, recorder)
268
+
269
+ // The recorder's delivery queue can still hand over a buffer that was in flight when
270
+ // the tap was torn down.
271
+ recorder.emitFirstChunk()
272
+ try? await Task.sleep(for: .milliseconds(150))
273
+
274
+ expectFullyTornDown(engine, recorder)
275
+ }
276
+
277
+ @Test("Stop clicked during warm-up abandons instead of transcribing silence")
278
+ func stopDuringWarmUpAbandons() {
279
+ let recorder = FakePCMRecorder()
280
+ let engine = makeStartableEngine(recorder: recorder)
281
+
282
+ engine.startRecording(trigger: .manual)
283
+ #expect(engine.isWarmingUpCapture)
284
+ engine.stopAndTranscribe()
285
+
286
+ expectFullyTornDown(engine, recorder)
287
+ expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
288
+ }
289
+
290
+ @Test("Discard during warm-up tears down without raising an alert the user does not need")
291
+ func discardDuringWarmUpIsSilent() {
292
+ let recorder = FakePCMRecorder()
293
+ let engine = makeStartableEngine(recorder: recorder)
294
+
295
+ engine.startRecording(trigger: .manual)
296
+ engine.cancelRecording()
297
+
298
+ expectFullyTornDown(engine, recorder)
299
+ #expect(engine.blockedReason == nil, "the user asked for the discard; do not alarm them")
300
+ #expect(engine.flowPhase == .idle)
301
+ #expect(engine.statusMessage == "Ready")
302
+ }
303
+
304
+ @Test("warm-up blocks a second start, and a new start clears the previous alert")
305
+ func warmUpBlocksASecondStartAndANewStartClearsTheAlert() {
306
+ let recorder = FakePCMRecorder()
307
+ let engine = makeStartableEngine(recorder: recorder)
308
+
309
+ engine.startRecording(trigger: .manual)
310
+ #expect(engine.isWarmingUpCapture)
311
+ #expect(!engine.canStartRecording, "the surface must not offer Start during warm-up")
312
+ #expect(!RecordingEngine.canBeginRecording(
313
+ isRecording: false,
314
+ isTranscribing: false,
315
+ isWarmingUpCapture: true
316
+ ))
317
+
318
+ // `.manual` has no key to release; Stop is the only way out of a warming manual start.
319
+ engine.stopAndTranscribe()
320
+ expectEmptyAttemptDisclosed(engine, .releasedBeforeAudio)
321
+
322
+ engine.startRecording(trigger: .manual)
323
+ #expect(engine.blockedReason == nil, "a live recording must not sit under a stale disclosure")
324
+ }
325
+ }
@@ -481,40 +481,51 @@ struct IntentFlowStateTests {
481
481
 
482
482
  @Test("delivery only runs ahead of persistence for locally-screened plain dictation")
483
483
  func pasteBeforePersistenceIsRouteAware() {
484
+ // `enhanceTriggersJSON` has no default by design (review F2 on #30): "[]" decodes to
485
+ // "no configured triggers" and would fail OPEN for a caller that omitted it. These five
486
+ // cases are all `off`/`always`, where the screen is never consulted, so they state the
487
+ // empty list explicitly rather than relying on an absent default.
484
488
  // Plain dictation with cleanup off: paste first, persist milliseconds later.
485
489
  #expect(RecordingEngine.shouldPasteBeforePersistence(
486
490
  postProcessingMode: PostProcessingMode.off.rawValue,
487
491
  transcript: "meet me at noon by the north entrance",
488
492
  hasSelection: false,
489
- intentDetectionEnabled: true
493
+ intentDetectionEnabled: true,
494
+ enhanceTriggersJSON: "[]"
490
495
  ))
491
496
  // Command-shaped speech with a selection may become a rewrite — persist first.
492
497
  #expect(!RecordingEngine.shouldPasteBeforePersistence(
493
498
  postProcessingMode: PostProcessingMode.off.rawValue,
494
499
  transcript: "rewrite this to be more formal",
495
500
  hasSelection: true,
496
- intentDetectionEnabled: true
501
+ intentDetectionEnabled: true,
502
+ enhanceTriggersJSON: "[]"
497
503
  ))
498
504
  // Question-shaped speech may become a conversation — persist first.
499
505
  #expect(!RecordingEngine.shouldPasteBeforePersistence(
500
506
  postProcessingMode: PostProcessingMode.off.rawValue,
501
507
  transcript: "what's the capital of France?",
502
508
  hasSelection: false,
503
- intentDetectionEnabled: true
509
+ intentDetectionEnabled: true,
510
+ enhanceTriggersJSON: "[]"
504
511
  ))
505
512
  // Detection off restores pure dictation: always paste first when cleanup is off.
506
513
  #expect(RecordingEngine.shouldPasteBeforePersistence(
507
514
  postProcessingMode: PostProcessingMode.off.rawValue,
508
515
  transcript: "what's the capital of France?",
509
516
  hasSelection: false,
510
- intentDetectionEnabled: false
517
+ intentDetectionEnabled: false,
518
+ enhanceTriggersJSON: "[]"
511
519
  ))
512
- // Any cleanup mode other than off always persists first.
520
+ // `always` mode persists first whatever the transcript looks like. This used to read "any
521
+ // cleanup mode other than off", which this PR makes false: `auto` now pastes first when
522
+ // EnhancementScreen proves the helper cannot rewrite the text.
513
523
  #expect(!RecordingEngine.shouldPasteBeforePersistence(
514
524
  postProcessingMode: PostProcessingMode.always.rawValue,
515
525
  transcript: "meet me at noon",
516
526
  hasSelection: false,
517
- intentDetectionEnabled: true
527
+ intentDetectionEnabled: true,
528
+ enhanceTriggersJSON: "[]"
518
529
  ))
519
530
  }
520
531
 
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /// A throwaway `~` for one `RecordingEngine` under test.
4
+ ///
5
+ /// `RecordingEngine` roots everything it owns at `<home>/.hasna/recordings`: the audio spool,
6
+ /// `config.json` (which holds the operator's live API keys), and `Recordings.log`. Its
7
+ /// `homePath` default is the real home, so before that seam existed every engine a test built
8
+ /// wrote there — on 2026-07-27 a suite run appended 11 fixture lines
9
+ /// (`target=com.example.editor pid=99999`, `accessibility=false`) to the log of a machine the
10
+ /// owner dictates on, corrupting the only record the recording investigations read, and
11
+ /// rewrote that machine's real `config.json`.
12
+ ///
13
+ /// Same shape as `NativeAppDiagnosticsTests.makeHome()`, the existing precedent for a temp
14
+ /// home behind a `homePath:` parameter: a fresh `$TMPDIR` directory per call. It is left for
15
+ /// the OS to reap rather than torn down, because the callers are engine factories that hand
16
+ /// back only the engine and so have no scope to remove it from; `CLIRunnerTests` instead
17
+ /// passes the temp home it already creates and already deletes.
18
+ func makeIsolatedTestHome(_ label: String) -> String {
19
+ let url = FileManager.default.temporaryDirectory
20
+ .appendingPathComponent("recordings-\(label)-\(UUID().uuidString)")
21
+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
22
+ return url.path
23
+ }
@@ -3,6 +3,12 @@ import Foundation
3
3
  import RecordingsUpdateProtocol
4
4
 
5
5
  enum ApplicationProcessQuiescence {
6
+ /// `<sys/proc_info.h>` defines `PROC_PIDPATHINFO_MAXSIZE` as `(4 * MAXPATHLEN)`. Swift's
7
+ /// ClangImporter imports the libproc *functions* but not compound macro expressions, so
8
+ /// the value is restated here. `proc_pidpath` rejects a buffer larger than this, so it
9
+ /// must track the header rather than being rounded up to a convenient power of two.
10
+ private static let processPathBufferSize = 4 * Int(MAXPATHLEN)
11
+
6
12
  /// Reject activation while any executable loaded from the live bundle is still
7
13
  /// running. The one authenticated update client servicing this XPC request is
8
14
  /// exempt only when its kernel-reported executable path is the fixed client path.
@@ -20,7 +26,7 @@ enum ApplicationProcessQuiescence {
20
26
  let authenticatedClientPath = RecordingsUpdateConstants.applicationPath + "/"
21
27
  + RecordingsUpdateConstants.updateClientRelativePath
22
28
  for processIdentifier in processIdentifiers.prefix(Int(count)) where processIdentifier > 0 {
23
- var pathBytes = [CChar](repeating: 0, count: Int(PROC_PIDPATHINFO_MAXSIZE))
29
+ var pathBytes = [CChar](repeating: 0, count: Self.processPathBufferSize)
24
30
  let pathLength = pathBytes.withUnsafeMutableBufferPointer { buffer in
25
31
  proc_pidpath(
26
32
  processIdentifier,
@@ -25,16 +25,16 @@ struct AtomicActivator {
25
25
  let applicationPath = RecordingsUpdateConstants.applicationPath
26
26
  let previousPath = stagedUpdate.directory + "/previous.app"
27
27
  let failedCandidatePath = stagedUpdate.directory + "/failed-candidate.app"
28
- let previousDigest = try digestIfPresent(applicationPath)
29
- let candidateMode = try applicationMode(candidatePath)
30
- let previousMode = previousDigest == nil ? nil : try applicationMode(applicationPath)
31
- let candidateExecutableModes = try applicationExecutableModes(candidatePath)
28
+ let previousDigest = try Self.digestIfPresent(applicationPath)
29
+ let candidateMode = try Self.applicationMode(candidatePath)
30
+ let previousMode = previousDigest == nil ? nil : try Self.applicationMode(applicationPath)
31
+ let candidateExecutableModes = try Self.applicationExecutableModes(candidatePath)
32
32
  let previousExecutableModes = previousDigest == nil
33
33
  ? nil
34
- : try applicationExecutableModes(applicationPath)
35
- guard try digestIfPresent(candidatePath) == payload.candidateTreeSHA256,
36
- try digestIfPresent(previousPath) == nil,
37
- try digestIfPresent(failedCandidatePath) == nil
34
+ : try Self.applicationExecutableModes(applicationPath)
35
+ guard try Self.digestIfPresent(candidatePath) == payload.candidateTreeSHA256,
36
+ try Self.digestIfPresent(previousPath) == nil,
37
+ try Self.digestIfPresent(failedCandidatePath) == nil
38
38
  else {
39
39
  throw AtomicActivationError.invalidPreparedState
40
40
  }
@@ -83,7 +83,7 @@ struct AtomicActivator {
83
83
  journal.cohortPackageSHA256 == payload.packageSHA256,
84
84
  journal.candidateTreeSHA256 == payload.candidateTreeSHA256,
85
85
  journal.minimumOSVersion == payload.minimumOSVersion,
86
- journal.envelopePayloadSHA256 == envelopeDigest(payload)
86
+ journal.envelopePayloadSHA256 == (try envelopeDigest(payload))
87
87
  else {
88
88
  throw AtomicActivationError.recoveryRequired
89
89
  }
@@ -171,7 +171,7 @@ struct AtomicActivator {
171
171
  journal.phase = "launch-barrier-releasing"
172
172
  try durableJournal.write(journal)
173
173
  try namespace.releaseCommittedLaunchBarrier(journal: journal)
174
- try Self.requireCommittedNamespaceState(journal)
174
+ try Self.requireCommittedNamespaceState(journal: journal)
175
175
  journal.phase = "launch-barrier-released"
176
176
  try durableJournal.write(journal)
177
177
  journal.phase = "activated"
@@ -144,7 +144,7 @@ final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
144
144
  envelope: envelope
145
145
  )
146
146
  do {
147
- let result = try performStagedInstall(staged: staged, policy: policy)
147
+ let result = try performStagedInstall(staged: staged, peer: peer, policy: policy)
148
148
  try cleanupIfTerminalOrUnjournaled(staged: staged, rootState: rootState)
149
149
  return result
150
150
  } catch {
@@ -166,6 +166,10 @@ final class UpdateBrokerSession: NSObject, RecordingsUpdateXPCProtocol {
166
166
 
167
167
  private static func performStagedInstall(
168
168
  staged: StagedUpdate,
169
+ // The activation quiescence gate must exempt exactly the one authenticated client
170
+ // servicing this request, so the verified peer has to reach it rather than being
171
+ // re-derived here.
172
+ peer: AuthenticatedPeer,
169
173
  policy: BrokerPolicy
170
174
  ) throws -> NSDictionary {
171
175
  guard let envelopeData = staged.envelope.data,
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import RecordingsUpdateProtocol
2
3
 
3
4
  enum HostOSProductVersionQueryError: Error {
4
5
  case unavailable
@@ -149,11 +149,56 @@ enum RootTrustStore {
149
149
  }
150
150
  }
151
151
 
152
+ /// PRIVATE API, DELIBERATE — RISK ACCEPTED.
153
+ ///
154
+ /// The peer's audit token is the only race-free identity for an XPC connection: it names the
155
+ /// exact process that opened the connection, whereas a PID can be reused between the time
156
+ /// the kernel reports it and the time we resolve its code signature. `NSXPCConnection`
157
+ /// exposes `-auditToken` in the runtime but declares it in no public header (the public
158
+ /// header offers only `processIdentifier`, `effectiveUserIdentifier` and
159
+ /// `auditSessionIdentifier`), so this declaration is the only contract we have and Apple may
160
+ /// change or remove it in any macOS update.
161
+ ///
162
+ /// Two things keep that risk bounded, and both must stay:
163
+ /// 1. `authenticate` checks `responds(to:)` before casting, so a macOS that REMOVES the property
164
+ /// makes peer authentication throw rather than read a garbage struct. That is the limit of what
165
+ /// guard 1 proves: it establishes only that a selector of that name exists, and says nothing
166
+ /// about its return type, so a macOS that CHANGED the return type would pass it.
167
+ /// 2. `authenticate` cross-checks the token's PID and EUID against the *public*
168
+ /// `processIdentifier` and `effectiveUserIdentifier`. This is the guard that catches ABI drift,
169
+ /// including the changed-return-type case guard 1 cannot see: a layout that no longer decodes to
170
+ /// the same process disagrees with the public properties and is rejected, so the failure mode is
171
+ /// a refused peer, never a mis-identified one.
172
+ ///
173
+ /// The `@objc` attribute below is load-bearing. Without it the protocol carries no Objective-C
174
+ /// witness and the `unsafeBitCast` still compiles, but the first authentication traps instead of
175
+ /// dispatching to the real getter. Pinned by native-updater-broker-contract.test.ts.
176
+ ///
177
+ /// The supported long-term replacement is `-[NSXPCListener setConnectionCodeSigningRequirement:]`
178
+ /// (macOS 13+), which moves requirement enforcement into XPC itself. Migrating is tracked
179
+ /// separately because this code also reads signing flags and entitlements back off the
180
+ /// resolved `SecCode`, which that API does not provide.
181
+ @objc private protocol XPCConnectionAuditTokenProviding {
182
+ var auditToken: audit_token_t { get }
183
+ }
184
+
152
185
  struct PeerIdentityPolicy {
153
186
  let policy: BrokerPolicy
154
187
 
155
188
  func authenticate(_ connection: NSXPCConnection) throws -> AuthenticatedPeer {
156
- var token = connection.auditToken
189
+ // See XPCConnectionAuditTokenProviding: fail closed if the private property is gone
190
+ // instead of reinterpreting whatever the cast would land on.
191
+ guard connection.responds(to: NSSelectorFromString("auditToken")) else {
192
+ throw BrokerSecurityError.peerAuditTokenUnavailable
193
+ }
194
+ var token = unsafeBitCast(connection, to: XPCConnectionAuditTokenProviding.self).auditToken
195
+ // The audit token is authoritative, but it must agree with the two public
196
+ // properties. Disagreement means the private ABI moved under us; refuse the peer.
197
+ guard audit_token_to_pid(token) == connection.processIdentifier,
198
+ audit_token_to_euid(token) == connection.effectiveUserIdentifier
199
+ else {
200
+ throw BrokerSecurityError.peerAuditTokenUnavailable
201
+ }
157
202
  let tokenData = withUnsafeBytes(of: &token) { Data($0) }
158
203
  let attributes = [kSecGuestAttributeAudit as String: tokenData] as CFDictionary
159
204
  var guest: SecCode?
@@ -191,8 +236,13 @@ struct PeerIdentityPolicy {
191
236
  }
192
237
 
193
238
  var signingInformation: CFDictionary?
239
+ // `SecCodeCopySigningInformation` is typed against `SecStaticCode`. In C a
240
+ // `SecCodeRef` is accepted directly (a dynamic code is a static code plus running
241
+ // state) but Swift imports the two as unrelated CF types, so the relationship has
242
+ // to be restated. This is a representation cast only — no ownership transfer.
243
+ let staticGuest = unsafeBitCast(guest, to: SecStaticCode.self)
194
244
  let informationStatus = SecCodeCopySigningInformation(
195
- guest,
245
+ staticGuest,
196
246
  SecCSFlags(rawValue: kSecCSSigningInformation | kSecCSRequirementInformation),
197
247
  &signingInformation
198
248
  )
@@ -229,7 +279,10 @@ struct PeerIdentityPolicy {
229
279
  guard let flags = information[kSecCodeInfoFlags as String] as? NSNumber else {
230
280
  return false
231
281
  }
232
- return flags.uint32Value & UInt32(kSecCodeSignatureRuntime) != 0
282
+ // `kSecCodeSignatureRuntime` is a member of the `CF_OPTIONS(uint32_t,
283
+ // SecCodeSignatureFlags)` set in CSCommon.h, so Swift exposes it as
284
+ // `SecCodeSignatureFlags.runtime` and not as a bare `kSec…` global.
285
+ return flags.uint32Value & SecCodeSignatureFlags.runtime.rawValue != 0
233
286
  }
234
287
 
235
288
  private static func hasNoDangerousDebugOrLoaderEntitlements(
@@ -259,6 +312,7 @@ enum BrokerSecurityError: Error, CustomStringConvertible {
259
312
  case invalidPolicy(String)
260
313
  case invalidTrustFile
261
314
  case peerCodeUnavailable(OSStatus)
315
+ case peerAuditTokenUnavailable
262
316
  case peerRejected(OSStatus)
263
317
  case peerMetadataRejected
264
318
 
@@ -267,6 +321,8 @@ enum BrokerSecurityError: Error, CustomStringConvertible {
267
321
  case let .invalidPolicy(reason): "Invalid root-owned broker policy: \(reason)"
268
322
  case .invalidTrustFile: "A root-owned broker trust file is missing or unsafe"
269
323
  case let .peerCodeUnavailable(status): "Could not resolve the XPC peer code identity (\(status))"
324
+ case .peerAuditTokenUnavailable:
325
+ "Could not read a trustworthy audit token for the XPC peer"
270
326
  case let .peerRejected(status): "The XPC peer code identity was rejected (\(status))"
271
327
  case .peerMetadataRejected: "The XPC peer signing metadata was rejected"
272
328
  }
@@ -102,17 +102,36 @@ public protocol RecordingsUpdateXPCProtocol {
102
102
  func queryStatus(withReply reply: @escaping (NSDictionary) -> Void)
103
103
  }
104
104
 
105
+ /// Builds the secure-coding allowlist that `NSXPCInterface` expects for one argument.
106
+ ///
107
+ /// `-[NSXPCInterface setClasses:forSelector:argumentIndex:ofReply:]` takes an
108
+ /// `NSSet<Class>`, which Swift imports as `Set<AnyHashable>` because Objective-C class
109
+ /// objects do not conform to Swift's `Hashable`. A set literal of metatypes therefore
110
+ /// does not type-check, and the members must stay *class objects*: XPC reads each one
111
+ /// as a `Class`. Satisfying the type checker some other way is not a workaround —
112
+ /// `AnyHashable(ObjectIdentifier(cls))` compiles but yields a set in which no member is
113
+ /// a class, which crashes inside `setClasses`. Bridging an `NSSet` of the class objects
114
+ /// is the only shape that both compiles and preserves the intended allowlist.
115
+ private func xpcAllowedClasses(_ classes: [AnyClass]) -> Set<AnyHashable> {
116
+ guard let allowed = NSSet(array: classes) as? Set<AnyHashable> else {
117
+ // Fail closed rather than install a widened or empty allowlist on the
118
+ // privileged updater interface.
119
+ preconditionFailure("NSXPCInterface class allowlist failed to bridge from NSSet")
120
+ }
121
+ return allowed
122
+ }
123
+
105
124
  /// XPC decoding is intentionally limited to retained file handles on request and
106
125
  /// scalar dictionaries on reply. No path-bearing or arbitrary option object is accepted.
107
126
  public func makeRecordingsUpdateXPCInterface() -> NSXPCInterface {
108
127
  let interface = NSXPCInterface(with: RecordingsUpdateXPCProtocol.self)
109
128
  let installSelector = NSSelectorFromString("installWithArchive:manifest:envelope:reply:")
110
129
  let statusSelector = NSSelectorFromString("queryStatusWithReply:")
111
- let fileHandleClasses: Set<AnyHashable> = [FileHandle.self]
130
+ let fileHandleClasses = xpcAllowedClasses([FileHandle.self])
112
131
  for index in 0..<3 {
113
132
  interface.setClasses(fileHandleClasses, for: installSelector, argumentIndex: index, ofReply: false)
114
133
  }
115
- let replyClasses: Set<AnyHashable> = [NSDictionary.self, NSString.self, NSNumber.self]
134
+ let replyClasses = xpcAllowedClasses([NSDictionary.self, NSString.self, NSNumber.self])
116
135
  interface.setClasses(replyClasses, for: installSelector, argumentIndex: 0, ofReply: true)
117
136
  interface.setClasses(replyClasses, for: statusSelector, argumentIndex: 0, ofReply: true)
118
137
  return interface