@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
@@ -0,0 +1,39 @@
1
+ # Realtime settle-latency probe
2
+
3
+ The standalone probe that produced the settle-latency tables in PR #30. It paces
4
+ real 24 kHz/16-bit/mono PCM through `RealtimeTranscriptionClient` exactly like the
5
+ capture pipeline (4,800-byte chunks every 100 ms, a periodic commit every 900 ms),
6
+ then measures the true post-release settle wait with a deliberately oversized finish
7
+ budget so the observed wait is the session's own settle latency, not a budget cutoff.
8
+
9
+ The in-tree `RecordingsTests/RealtimeSettleBenchmark.swift` measures the same thing
10
+ through the test harness; this probe exists so the numbers can be reproduced on a
11
+ machine where running the package test suite is not safe or not possible (it links
12
+ only the client and its logging dependency — no test host, no RecordingEngine, no
13
+ production paths; client logs go to an isolated temporary home).
14
+
15
+ ## Build (macOS)
16
+
17
+ cd scripts/native/realtime-settle-probe
18
+ swiftc -O -parse-as-library \
19
+ ../../../src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift \
20
+ ../../../src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift \
21
+ main.swift -o settleprobe
22
+
23
+ ## Run
24
+
25
+ RECORDINGS_BENCH_WAVS="s5.wav:s30.wav:s120.wav" \
26
+ RECORDINGS_BENCH_RUNS=5 \
27
+ RECORDINGS_BENCH_KEY_FILE="$HOME/.hasna/recordings/config.json" \
28
+ ./settleprobe
29
+
30
+ - `RECORDINGS_BENCH_WAVS` (required): colon-separated 24 kHz/16-bit/mono WAV paths.
31
+ - `RECORDINGS_BENCH_RUNS`: runs per WAV (default 5). Run 1 of the session is cold.
32
+ - `RECORDINGS_BENCH_FINISH_BUDGET_MS`: finish budget (default 10000).
33
+ - `RECORDINGS_BENCH_KEY_FILE` (required): JSON file with `openai_api_key`. The key is
34
+ read in-process and never exported into any environment.
35
+
36
+ One `BENCH_RESULT` line per run on stdout:
37
+
38
+ BENCH_RESULT wav=... run=1 cold=true audio_seconds=4.6 pcm_bytes=218834 \
39
+ budget_ms=10000 stream_start_ms=700 finish_wait_ms=777 settled=true chars=85 error=
@@ -0,0 +1,135 @@
1
+ import Foundation
2
+
3
+ // Standalone settle-latency probe: paces real PCM through RealtimeTranscriptionClient
4
+ // exactly like the capture pipeline (4,800-byte chunks / 100 ms, periodic commit /
5
+ // 900 ms), then measures the finish/settle wait. Compiled with swiftc against
6
+ // RealtimeTranscriptionClient.swift + NativeAppDiagnostics.swift only — no test
7
+ // harness, no RecordingEngine, no production paths: client logs go to a temp dir.
8
+
9
+ func pcmData(fromWavAtPath path: String) throws -> Data {
10
+ let data = try Data(contentsOf: URL(fileURLWithPath: path))
11
+ precondition(data.count > 44, "WAV too small: \(path)")
12
+ precondition(
13
+ String(data: data[0..<4], encoding: .ascii) == "RIFF"
14
+ && String(data: data[8..<12], encoding: .ascii) == "WAVE",
15
+ "not a RIFF/WAVE file: \(path)"
16
+ )
17
+ var index = 12
18
+ var pcm: Data?
19
+ while index + 8 <= data.count {
20
+ let chunkID = String(data: data[index..<index + 4], encoding: .ascii) ?? ""
21
+ let chunkSize = Int(
22
+ UInt32(data[index + 4]) | UInt32(data[index + 5]) << 8
23
+ | UInt32(data[index + 6]) << 16 | UInt32(data[index + 7]) << 24
24
+ )
25
+ let body = index + 8
26
+ guard body + chunkSize <= data.count else { break }
27
+ if chunkID == "fmt ", chunkSize >= 16 {
28
+ let format = UInt16(data[body]) | UInt16(data[body + 1]) << 8
29
+ let channels = UInt16(data[body + 2]) | UInt16(data[body + 3]) << 8
30
+ let sampleRate = UInt32(data[body + 4]) | UInt32(data[body + 5]) << 8
31
+ | UInt32(data[body + 6]) << 16 | UInt32(data[body + 7]) << 24
32
+ let bits = UInt16(data[body + 14]) | UInt16(data[body + 15]) << 8
33
+ precondition(
34
+ format == 1 && channels == 1 && sampleRate == 24_000 && bits == 16,
35
+ "probe WAV must be PCM/mono/24kHz/16-bit: \(path)"
36
+ )
37
+ } else if chunkID == "data" {
38
+ pcm = data.subdata(in: body..<body + chunkSize)
39
+ }
40
+ index = body + chunkSize + (chunkSize % 2)
41
+ }
42
+ guard let payload = pcm else { fatalError("no data chunk in \(path)") }
43
+ return payload
44
+ }
45
+
46
+ func monotonicMilliseconds() -> UInt64 {
47
+ UInt64(ProcessInfo.processInfo.systemUptime * 1_000)
48
+ }
49
+
50
+ @main
51
+ struct SettleProbe {
52
+ static func main() async {
53
+ let environment = ProcessInfo.processInfo.environment
54
+ let wavPaths = (environment["RECORDINGS_BENCH_WAVS"] ?? "").split(separator: ":").map(String.init)
55
+ precondition(!wavPaths.isEmpty, "RECORDINGS_BENCH_WAVS is required")
56
+ let runsPerWav = Int(environment["RECORDINGS_BENCH_RUNS"] ?? "") ?? 5
57
+ let finishBudget = UInt64(environment["RECORDINGS_BENCH_FINISH_BUDGET_MS"] ?? "") ?? 10_000
58
+ // The key is read in-process from the app's own config file and lives only in
59
+ // this process's memory — never exported into a shell environment, never
60
+ // printed. RECORDINGS_BENCH_KEY_FILE points at the config; no default writes.
61
+ let keyFile = environment["RECORDINGS_BENCH_KEY_FILE"] ?? ""
62
+ precondition(!keyFile.isEmpty, "RECORDINGS_BENCH_KEY_FILE is required")
63
+ let apiKey: String = {
64
+ guard let data = FileManager.default.contents(atPath: keyFile),
65
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
66
+ let key = json["openai_api_key"] as? String
67
+ else { return "" }
68
+ return key
69
+ }()
70
+ precondition(!apiKey.isEmpty, "no openai_api_key in \(keyFile)")
71
+
72
+ let benchHome = NSTemporaryDirectory() + "recordings-settle-probe-" + UUID().uuidString
73
+ try? FileManager.default.createDirectory(atPath: benchHome, withIntermediateDirectories: true)
74
+ print("BENCH_HOME \(benchHome)")
75
+
76
+ var coldRun = true
77
+ for wavPath in wavPaths {
78
+ guard let pcm = try? pcmData(fromWavAtPath: wavPath) else {
79
+ print("BENCH_ERROR wav=\(wavPath) reason=unreadable")
80
+ continue
81
+ }
82
+ let audioSeconds = Double(pcm.count) / 48_000.0
83
+ for run in 1...runsPerWav {
84
+ let client = RealtimeTranscriptionClient(apiKey: apiKey, homePath: benchHome)
85
+ let startBegan = monotonicMilliseconds()
86
+ await client.startStreaming(language: "en")
87
+ let streamStart = monotonicMilliseconds() - startBegan
88
+ guard client.isStreaming else {
89
+ let text = client.stop()
90
+ print(
91
+ "BENCH_RESULT wav=\(wavPath) run=\(run) cold=\(coldRun) "
92
+ + "audio_seconds=\(String(format: "%.1f", audioSeconds)) "
93
+ + "stream_start_ms=\(streamStart) finish_wait_ms=0 settled=false "
94
+ + "chars=\(text.count) error=\((client.error ?? "start_failed").replacingOccurrences(of: " ", with: "_"))"
95
+ )
96
+ coldRun = false
97
+ continue
98
+ }
99
+
100
+ let chunkSize = 4_800
101
+ var offset = 0
102
+ var lastCommitAt = monotonicMilliseconds()
103
+ while offset < pcm.count {
104
+ let end = min(offset + chunkSize, pcm.count)
105
+ client.sendAudio(pcm.subdata(in: offset..<end))
106
+ offset = end
107
+ try? await Task.sleep(for: .milliseconds(100))
108
+ let now = monotonicMilliseconds()
109
+ if now - lastCommitAt >= 900 {
110
+ if await client.commitInput(reason: "periodic") {
111
+ lastCommitAt = now
112
+ }
113
+ }
114
+ }
115
+
116
+ let finishBegan = monotonicMilliseconds()
117
+ let result = await client.finish(
118
+ timeoutMilliseconds: finishBudget,
119
+ pipelineID: UUID().uuidString,
120
+ pipelineStartedUptimeMilliseconds: finishBegan
121
+ )
122
+ let finishWait = monotonicMilliseconds() - finishBegan
123
+ print(
124
+ "BENCH_RESULT wav=\(wavPath) run=\(run) cold=\(coldRun) "
125
+ + "audio_seconds=\(String(format: "%.1f", audioSeconds)) "
126
+ + "pcm_bytes=\(pcm.count) budget_ms=\(finishBudget) "
127
+ + "stream_start_ms=\(streamStart) finish_wait_ms=\(finishWait) "
128
+ + "settled=\(result.settled) chars=\(result.text.count) "
129
+ + "error=\((result.error ?? "").replacingOccurrences(of: " ", with: "_"))"
130
+ )
131
+ coldRun = false
132
+ }
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,21 @@
1
+ # Single source of truth for the Macs approved as local-only artifact targets.
2
+ #
3
+ # One short hostname (`hostname -s`) per line. Blank lines and lines beginning
4
+ # with `#` are ignored. Both `scripts/macos_artifact.ts` and the two shell
5
+ # entry points (`scripts/install_macos_app.sh`, `src/native/Recordings/build.sh`)
6
+ # read this file, so a target is added in exactly one place.
7
+ #
8
+ # This file is an OPERATOR-INTEGRITY control, NOT an authorization boundary. It
9
+ # lives in the same package root as the scripts that read it, so anyone who can
10
+ # edit it can equally edit those guards; it is not ownership- or mode-checked
11
+ # beyond "regular file, not a symlink". Adding a name here therefore grants
12
+ # nothing by itself.
13
+ #
14
+ # Every local-only artifact is still ad-hoc signed and non-notarized, must be
15
+ # built on a Mac other than the target AND on a different authenticated Tailscale
16
+ # node, and stays bound to the target's authenticated machine-identity digest; the
17
+ # install additionally re-checks `hostname -s`, the live Tailscale node digest,
18
+ # and an authenticated manifest SHA-256. Release artifacts ignore this file
19
+ # entirely and use the fleet target policy.
20
+ station03
21
+ station06
@@ -0,0 +1,203 @@
1
+ # shellcheck shell=bash
2
+ # Shared reader for the local-only approved-target policy. Sourced by both
3
+ # scripts/install_macos_app.sh and src/native/Recordings/build.sh, so the two shell
4
+ # entry points cannot drift apart.
5
+ #
6
+ # This is a SECOND implementation of the same rules as localOnlyApprovedTargets() in
7
+ # scripts/macos_artifact.ts, not a shared one — shell cannot call that function without
8
+ # spawning bun inside argument validation. The two are kept behaviourally equivalent by
9
+ # an executing contract test ("the shell reader and the TypeScript reader agree on every
10
+ # policy shape"), which is the only thing that actually holds them together. If you
11
+ # change the rules here, change them there and extend that test.
12
+ #
13
+ # Rules: reject anything that is not a regular file (a symlinked policy is refused, not
14
+ # followed) or not readable, strip an optional UTF-8 BOM from the FIRST line only and a
15
+ # trailing CR from every line, trim ASCII space and tab only, reject any NUL byte, drop
16
+ # blank and comment lines, then require every target to be a well-formed short hostname,
17
+ # unique, and never the release fleet target. Anything else fails closed.
18
+ #
19
+ # Four of those rules exist because the two readers disagreed and the disagreement was
20
+ # provable, not theoretical:
21
+ # * SYMLINKS. `[ -L ]` here refused a symlinked policy while readFileSync() there
22
+ # followed it, so a symlink pointing at a widened allowlist was rejected by the
23
+ # shell and silently honoured by TypeScript. Both now refuse; a symlink is never a
24
+ # legitimate shape for package-local policy data shipped inside the tarball. Note the
25
+ # granularity: only the FINAL path component is checked, so a policy reached through a
26
+ # symlinked package root — which is what pnpm and yarn produce — still resolves.
27
+ # * ASCII-ONLY TRIM. This used to trim [[:space:]], while TypeScript trims /[\t ]/
28
+ # only. Enumerated, that split 21 of 36 whitespace rows, and in every one of them
29
+ # THIS reader accepted a target TypeScript rejects — build gate open, install
30
+ # validator closed. [[:space:]] covers VT (0x0b), FF (0x0c) and CR, and in a UTF-8
31
+ # locale it also matched U+1680, U+2000, U+2002, U+2009, U+205F, U+2028, U+2029 and
32
+ # U+3000. Both now trim space and tab only and leave everything else in place for the
33
+ # hostname shape to reject. This one change closes all 21 rows on its own.
34
+ # * BOM POSITION. This used to strip a BOM from every line while TypeScript strips one
35
+ # only at offset 0, so a BOM on the second line was accepted here and rejected there.
36
+ # A U+FEFF anywhere but the very start of the file is a zero-width no-break space,
37
+ # not a byte-order mark, and both readers now reject it.
38
+ # * PHASE ORDER. See the four numbered phases below.
39
+ #
40
+ # DO NOT DELETE the `local LC_ALL=C LANG=C` below. It is not decoration, and the platform
41
+ # where it matters is not the platform you are probably reading this on.
42
+ #
43
+ # `[a-z0-9-]` and `[!a-z]` are bracket RANGES, and range endpoints in a shell pattern are
44
+ # resolved by collation, not by byte value. Under a UTF-8 locale collation interleaves case
45
+ # (a A b B c C …), so `[a-z]` matches uppercase and `STATION07` — which the TypeScript
46
+ # reader rejects unconditionally — parses here as a valid target. That is this reader's
47
+ # whole failure mode: build gate open, install validator closed.
48
+ #
49
+ # The `globasciiranges` shopt forces ASCII semantics for ranges regardless of locale, which
50
+ # is what hides this on a modern Linux box. Be precise about WHICH bash, because the obvious
51
+ # summary is wrong: per bash's own NEWS, the option was INTRODUCED in 4.3 (§4.3 e.) but only
52
+ # became ENABLED BY DEFAULT in 5.0 (§5.0 hh.). So bash 4.3 and 4.4 are exposed exactly as 3.2
53
+ # is unless someone opts in, and it is only from 5.0 that the pin "looks like it does
54
+ # nothing". macOS ships /bin/bash 3.2.57, which has no such option at all — and
55
+ # src/native/Recordings/build.sh is `#!/bin/bash` and exports NO locale of its own, so on
56
+ # the Mac that actually builds artifacts this function-local pin is the only thing standing
57
+ # between the caller's LANG and the allowlist. (scripts/install_macos_app.sh:4-6 exports
58
+ # LC_ALL=C/LANG=C/TZ=UTC0, so the installer is covered independently; the builder is not.)
59
+ #
60
+ # Measured, emulating 3.2 range semantics with `shopt -u globasciiranges` under
61
+ # LC_ALL=en_US.UTF-8, on a policy containing STATION07:
62
+ # pinned -> "invalid target name: STATION07" (agrees with TypeScript)
63
+ # unpinned -> ACCEPTED, list=[STATION07], matched=1 (TypeScript still rejects)
64
+ # That case is an executing test: "the shell reader stays byte-exact under a hostile
65
+ # locale" in src/__tests__/local-only-target-policy.test.ts. An earlier revision of this
66
+ # comment claimed the pin was unguarded and protected nothing; that was measured only on
67
+ # bash 5.2 with globasciiranges on, which is the configuration that hides the problem.
68
+
69
+ # read_local_only_targets <policy-path> <out-list-var> <out-match-var> <requested-target>
70
+ # Sets <out-list-var> to a comma-separated list for error messages and
71
+ # <out-match-var> to 1 when <requested-target> is approved. Returns non-zero with a
72
+ # message on stderr when the policy itself is unusable.
73
+ read_local_only_targets() {
74
+ local LC_ALL=C LANG=C
75
+ local policy_path="$1"
76
+ local list_var="$2"
77
+ local match_var="$3"
78
+ local requested="$4"
79
+
80
+ if [ ! -f "$policy_path" ] || [ -L "$policy_path" ]; then
81
+ echo "Local-only approved target policy is missing." >&2
82
+ return 1
83
+ fi
84
+
85
+ # A mode-000 policy passes the test above (`-f` needs no read permission) and then made
86
+ # every redirection below fail with bash's own "Permission denied" and no reader message,
87
+ # while the TypeScript reader raised a raw EACCES out of readFileSync. Same fail-closed
88
+ # outcome, two unrecognizable errors; both readers now say this instead.
89
+ if [ ! -r "$policy_path" ]; then
90
+ echo "Local-only approved target policy is not readable." >&2
91
+ return 1
92
+ fi
93
+
94
+ # `read` silently discards NUL bytes, so "station03\0station99" would otherwise be
95
+ # accepted as the single target "station03station99" — quietly dropping station03 from
96
+ # the allowlist. `read -d ''` succeeds only when it finds a NUL, so this fails closed.
97
+ if IFS= read -r -d '' _ < "$policy_path"; then
98
+ echo "Local-only approved target policy contains a NUL byte." >&2
99
+ return 1
100
+ fi
101
+
102
+ local line target list="" matched=0 first_line=1
103
+ local blanks=$' \t'
104
+ local -a targets=()
105
+ # Counted as they are collected rather than asked of the array afterwards. See Phase 1.
106
+ local target_count=0
107
+ while IFS= read -r line || [ -n "$line" ]; do
108
+ if [ "$first_line" -eq 1 ]; then
109
+ line="${line#$'\xef\xbb\xbf'}"
110
+ first_line=0
111
+ fi
112
+ line="${line%$'\r'}"
113
+ # Trim ASCII space and tab only, so an indented entry or comment behaves the same
114
+ # here as it does in the TypeScript reader. Deliberately not [[:space:]]: that also
115
+ # covers VT and FF, which TypeScript's /[\t ]/ leaves in place to be rejected.
116
+ line="${line#"${line%%[!$blanks]*}"}"
117
+ line="${line%"${line##*[!$blanks]}"}"
118
+ case "$line" in '' | '#'*) continue ;; esac
119
+ targets+=("$line")
120
+ target_count=$((target_count + 1))
121
+ done < "$policy_path"
122
+
123
+ # The remaining rules run in FOUR SEPARATE PHASES, in this order, and the order is load
124
+ # bearing: it is the order localOnlyApprovedTargets() uses in scripts/macos_artifact.ts.
125
+ # This reader used to validate each line completely before reading the next, so a policy
126
+ # breaking two rules at once got whichever complaint came first by LINE while TypeScript
127
+ # got whichever came first by RULE — "fleet\nfleet\n" was "must not list the release
128
+ # fleet target" here and "has duplicate targets" there. Both still refused every such
129
+ # policy, so no gate ever opened on it, but two readers cannot be called one policy while
130
+ # they disagree about what is wrong, and the contract test that compares refusal reasons
131
+ # is only honest if the phase order matches. Measured over 1500 multi-violation policies:
132
+ # 160 reason divergences before this restructure, 0 after, 0 verdict divergences in both.
133
+
134
+ # Phase 1: any targets at all.
135
+ #
136
+ # A plain counter, not `${#targets[@]}`. This is the ONE test reachable with an empty
137
+ # array, and both callers run `set -euo pipefail` (src/native/Recordings/build.sh:5,
138
+ # scripts/install_macos_app.sh:2) on a platform whose /bin/bash is 3.2.57. Whether the
139
+ # LENGTH form of an empty-array expansion is safe under `set -u` in 3.2 is something
140
+ # nobody here can execute — there is no macOS and no bash 3.2 on this machine — so this
141
+ # sidesteps the question instead of asserting an answer to it. An earlier revision of
142
+ # this very file was talked into deleting a load-bearing line by a confident comment
143
+ # about behaviour nobody had run; a counter costs nothing and needs no such claim.
144
+ if [ "$target_count" -eq 0 ]; then
145
+ echo "Local-only approved target policy lists no targets." >&2
146
+ return 1
147
+ fi
148
+
149
+ # Phase 2: every name well formed. Shape and length are one regex on the TypeScript side,
150
+ # so they share this message and are checked together, target by target in order.
151
+ #
152
+ # `${targets[@]+"${targets[@]}"}` and not the plainer `"${targets[@]}"`: both callers run
153
+ # `set -euo pipefail` (src/native/Recordings/build.sh:5, scripts/install_macos_app.sh:2) and
154
+ # macOS ships /bin/bash 3.2.57, where expanding an EMPTY array under `set -u` is an
155
+ # "unbound variable" error rather than an empty list. Phase 1 above already returned for the
156
+ # empty case, so no loop here is reachable with an empty array today — this keeps the
157
+ # property PROVABLE rather than dependent on that ordering surviving the next edit. If it
158
+ # ever did fire, the caller would die with bash's `unbound variable` instead of
159
+ # "lists no targets", which is precisely the unrecognizable-error class this reader exists
160
+ # to eliminate. The remaining `${#targets[@]}` uses are inside Phase 3's arithmetic, which
161
+ # this function only reaches once Phase 1 has proven the array non-empty.
162
+ for target in ${targets[@]+"${targets[@]}"}; do
163
+ case "$target" in
164
+ *[!a-z0-9-]* | [!a-z]* | *-)
165
+ echo "Local-only approved target policy has an invalid target name: ${target}" >&2
166
+ return 1
167
+ ;;
168
+ esac
169
+ if [ "${#target}" -lt 3 ] || [ "${#target}" -gt 32 ]; then
170
+ echo "Local-only approved target policy has an invalid target name: ${target}" >&2
171
+ return 1
172
+ fi
173
+ done
174
+
175
+ # Phase 3: no duplicates.
176
+ local index other
177
+ for ((index = 0; index < ${#targets[@]}; index++)); do
178
+ for ((other = index + 1; other < ${#targets[@]}; other++)); do
179
+ if [ "${targets[index]}" = "${targets[other]}" ]; then
180
+ echo "Local-only approved target policy has duplicate targets." >&2
181
+ return 1
182
+ fi
183
+ done
184
+ done
185
+
186
+ # Phase 4: never the release fleet target.
187
+ for target in ${targets[@]+"${targets[@]}"}; do
188
+ if [ "$target" = "fleet" ]; then
189
+ echo "Local-only approved target policy must not list the release fleet target." >&2
190
+ return 1
191
+ fi
192
+ done
193
+
194
+ for target in ${targets[@]+"${targets[@]}"}; do
195
+ list="${list:+${list}, }${target}"
196
+ if [ "$target" = "$requested" ]; then
197
+ matched=1
198
+ fi
199
+ done
200
+
201
+ printf -v "$list_var" '%s' "$list"
202
+ printf -v "$match_var" '%s' "$matched"
203
+ }
@@ -0,0 +1,40 @@
1
+ // Pack this repository as it would be published, then scan the actual tarball.
2
+ // `--ignore-scripts` prevents this prepack gate from recursively re-entering
3
+ // prepack while it creates the artifact to inspect.
4
+
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { isAbsolute, join } from "node:path";
8
+ import {
9
+ formatArtifactScanReport,
10
+ resolveAssetInventoryWaivers,
11
+ scanPublishedArtifact,
12
+ } from "@hasna/contracts/artifact-scan";
13
+
14
+ function run(command: string[], cwd: string): string {
15
+ const result = Bun.spawnSync(command, { cwd, stdout: "pipe", stderr: "pipe" });
16
+ const stdout = new TextDecoder().decode(result.stdout).trim();
17
+ if (result.exitCode !== 0) {
18
+ const stderr = new TextDecoder().decode(result.stderr).trim();
19
+ throw new Error(`${command.join(" ")} exited ${result.exitCode}\n${stdout}\n${stderr}`);
20
+ }
21
+ return stdout;
22
+ }
23
+
24
+ const repoRoot = join(import.meta.dir, "..");
25
+ const workspace = mkdtempSync(join(tmpdir(), "recordings-artifact-scan-"));
26
+
27
+ try {
28
+ const packed = run(
29
+ [process.execPath, "pm", "pack", "--destination", workspace, "--ignore-scripts", "--quiet"],
30
+ repoRoot,
31
+ );
32
+ const archive = isAbsolute(packed) ? packed : join(workspace, packed);
33
+ const waivers = resolveAssetInventoryWaivers(join(repoRoot, "hasna.contract.json"));
34
+ for (const note of waivers.notes) console.log(`waiver: ${note}`);
35
+ const report = scanPublishedArtifact(archive, { waivedKinds: waivers.kinds });
36
+ console.log(formatArtifactScanReport(report));
37
+ if (!report.ok) process.exitCode = 1;
38
+ } finally {
39
+ rmSync(workspace, { recursive: true, force: true });
40
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env bun
2
+ // Single writer for every copy of the release version. package.json is the
3
+ // authority because scripts/build_companion_cli.sh derives EXPECTED_VERSION from
4
+ // it and rejects a compiled CLI whose --version disagrees, which aborts
5
+ // src/native/Recordings/build.sh under set -euo pipefail.
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export type VersionSite = {
11
+ /** Repo-relative file carrying a copy of the release version. */
12
+ file: string;
13
+ /** Label used in check output. */
14
+ label: string;
15
+ /** Group 2 is the version; groups 1 and 3 are preserved verbatim on write. */
16
+ pattern: RegExp;
17
+ };
18
+
19
+ export const versionSites: VersionSite[] = [
20
+ {
21
+ file: "package.json",
22
+ label: "package.json version",
23
+ pattern: /("version"\s*:\s*")([^"]+)(")/,
24
+ },
25
+ {
26
+ file: "src/version.ts",
27
+ label: "src/version.ts VERSION",
28
+ pattern: /(export const VERSION = ")([^"]+)(")/,
29
+ },
30
+ {
31
+ file: "src/native/Recordings/RecordingsLib/Info.plist",
32
+ label: "Info.plist CFBundleShortVersionString",
33
+ pattern: /(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
34
+ },
35
+ {
36
+ file: "src/native/Recordings/RecordingsLib/Info.plist",
37
+ label: "Info.plist CFBundleVersion",
38
+ pattern: /(<key>CFBundleVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
39
+ },
40
+ ];
41
+
42
+ export type SiteReading = {
43
+ site: VersionSite;
44
+ version: string;
45
+ };
46
+
47
+ export type VersionCheck = {
48
+ expected: string;
49
+ readings: SiteReading[];
50
+ mismatched: SiteReading[];
51
+ };
52
+
53
+ // The authority is whichever site the build script reads, so it stays first.
54
+ const authoritySite = versionSites[0];
55
+
56
+ export function repoRoot(): string {
57
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
58
+ }
59
+
60
+ // Rejects a leading "v" the same way prepareReleaseInstallInputs does, so a
61
+ // bump cannot introduce a form the release path already refuses.
62
+ export function assertReleaseVersion(version: string): string {
63
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
64
+ throw new Error(`invalid release version: ${version} (expected MAJOR.MINOR.PATCH)`);
65
+ }
66
+ return version;
67
+ }
68
+
69
+ function readSite(root: string, site: VersionSite): SiteReading {
70
+ const match = readFileSync(join(root, site.file), "utf8").match(site.pattern);
71
+ if (!match) {
72
+ throw new Error(`${site.file}: no version found for ${site.label}`);
73
+ }
74
+ return { site, version: match[2] };
75
+ }
76
+
77
+ export function checkVersionSites(root: string = repoRoot()): VersionCheck {
78
+ const readings = versionSites.map((site) => readSite(root, site));
79
+ const authority = readings.find((reading) => reading.site === authoritySite);
80
+ if (!authority) {
81
+ throw new Error(`missing authority version site: ${authoritySite.label}`);
82
+ }
83
+ const expected = authority.version;
84
+ return {
85
+ expected,
86
+ readings,
87
+ mismatched: readings.filter((reading) => reading.version !== expected),
88
+ };
89
+ }
90
+
91
+ export function applyVersion(version: string, root: string = repoRoot()): SiteReading[] {
92
+ assertReleaseVersion(version);
93
+
94
+ // Group by file so two sites in one file (the plist) do not clobber each other.
95
+ const files = [...new Set(versionSites.map((site) => site.file))];
96
+ for (const file of files) {
97
+ const path = join(root, file);
98
+ let contents = readFileSync(path, "utf8");
99
+ for (const site of versionSites.filter((candidate) => candidate.file === file)) {
100
+ if (!site.pattern.test(contents)) {
101
+ throw new Error(`${file}: no version found for ${site.label}`);
102
+ }
103
+ contents = contents.replace(site.pattern, `$1${version}$3`);
104
+ }
105
+ writeFileSync(path, contents);
106
+ }
107
+
108
+ return versionSites.map((site) => ({ site, version }));
109
+ }
110
+
111
+ function main(argv: string[]): void {
112
+ const [command] = argv;
113
+
114
+ if (command === undefined || command === "--check" || command === "check") {
115
+ const { expected, readings, mismatched } = checkVersionSites();
116
+ if (mismatched.length > 0) {
117
+ console.error(`Version sites disagree (${authoritySite.label} says ${expected}):`);
118
+ for (const reading of mismatched) {
119
+ console.error(` ${reading.site.label}: ${reading.version}`);
120
+ }
121
+ console.error("Fix with: bun run version:set <version>");
122
+ process.exit(1);
123
+ }
124
+ console.log(`Version sites agree at ${expected} (${readings.length} sites).`);
125
+ return;
126
+ }
127
+
128
+ if (command === "--help" || command === "-h") {
129
+ console.log("Usage: bun run scripts/set-version.ts [--check | <version>]");
130
+ return;
131
+ }
132
+
133
+ for (const reading of applyVersion(command)) {
134
+ console.log(` ${reading.site.label} -> ${reading.version}`);
135
+ }
136
+ }
137
+
138
+ if (import.meta.main) {
139
+ try {
140
+ main(process.argv.slice(2));
141
+ } catch (error) {
142
+ console.error(error instanceof Error ? error.message : String(error));
143
+ process.exit(1);
144
+ }
145
+ }