@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.
- package/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2094 -280
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +19 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -50
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +141 -57
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +188 -80
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +109 -50
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +192 -31
- package/scripts/macos_artifact.ts +323 -43
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/smoke_macos_app.sh +62 -16
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
- package/src/native/Recordings/App/RecordingsApp.swift +37 -1
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +226 -20
- 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
|
+
}
|