@hasna/recordings 0.2.13 → 0.3.1

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 (120) 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 +2078 -277
  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 +10 -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 +634 -49
  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 +140 -56
  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 +187 -79
  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 +108 -49
  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/build_native_fs_guard.sh +15 -2
  59. package/scripts/ci-linux-suite.ts +403 -0
  60. package/scripts/ci-native-build.ts +402 -0
  61. package/scripts/enforce_identity_migration.sh +110 -0
  62. package/scripts/generate-sdk.ts +1 -1
  63. package/scripts/install_macos_app.sh +138 -24
  64. package/scripts/macos_artifact.ts +205 -33
  65. package/scripts/native/realtime-settle-probe/README.md +39 -0
  66. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  67. package/scripts/policy/local-only-approved-targets.txt +21 -0
  68. package/scripts/read_local_only_targets.sh +203 -0
  69. package/scripts/scan-artifact.ts +40 -0
  70. package/scripts/set-version.ts +145 -0
  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 +14 -7
  81. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  82. package/src/native/Recordings/App/SidebarView.swift +1 -1
  83. package/src/native/Recordings/Package.swift +24 -5
  84. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  85. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  86. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  87. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  88. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  89. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  90. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  91. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  92. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  93. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  94. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  95. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  96. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  97. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  98. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  99. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  100. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  101. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  102. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  103. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  104. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  105. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  106. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  107. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  108. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  109. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  110. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  111. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  112. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  113. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  114. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  115. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  116. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  117. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  118. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  119. package/src/native/Recordings/build.sh +75 -9
  120. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -146,6 +146,63 @@ require_absolute_canonical_owned_home() {
146
146
  }
147
147
  }
148
148
 
149
+ # Every accepted argument, because until now there was no usage output at all and an
150
+ # unrecognized argument printed only "Unknown argument". That mattered most for
151
+ # --allow-adhoc-identity-migration: the identity-migration gate refuses a local-only
152
+ # reinstall by default, and an operator repairing a station had no way to discover the
153
+ # approval flag except by reading the refusal on stderr or the script itself.
154
+ print_usage() {
155
+ cat <<'USAGE'
156
+ Usage: install_macos_app.sh --artifact <zip> --manifest <json> [options]
157
+
158
+ Installs a Recordings macOS app artifact transactionally. Normally invoked through
159
+ `recordings app install`, which forwards these arguments.
160
+
161
+ Artifact and version:
162
+ --artifact <path> Finalized artifact archive to install
163
+ --manifest <path> Artifact manifest describing it
164
+ --manifest-sha256 <sha256> Authenticated digest of the manifest
165
+ --expected-source-sha <sha> Approved 40-character source commit
166
+ --expected-version <version> Version the artifact must declare
167
+ --expected-team-id <team-id> Required Apple Developer team identifier
168
+
169
+ Target selection:
170
+ --expected-hostname <name> Require this live short hostname before mutating
171
+ --artifact-policy <release|local-only>
172
+ Trust policy to evaluate the artifact under
173
+ --approved-target <name> Approved target for a local-only artifact
174
+ --approved-target-identity-kind <kind>
175
+ hardware_uuid_sha256 or tailscale_node_id_sha256
176
+ --approved-target-identity-sha256 <sha256>
177
+ Authenticated digest of the target identity
178
+
179
+ Identity migration. Replacing an installed app with one signed by a different identity
180
+ voids the Microphone and Accessibility grants the installed app holds; macOS keys those
181
+ grants to code identity, and they cannot be restored by the installer. Both approvals are
182
+ one-shot and deliberately distinct: approving a release signer rotation is not approving
183
+ an ad-hoc replacement of a certificate-rooted install.
184
+ --allow-signing-identity-migration Approve one reviewed release signer change
185
+ --expected-old-identity-sha256 <sha256>
186
+ Exact installed identity approved for migration
187
+ --expected-new-identity-sha256 <sha256>
188
+ Exact candidate identity approved for migration
189
+ --allow-adhoc-identity-migration Approve replacing an installed app with an ad-hoc
190
+ signed local-only build. Required for essentially
191
+ every local-only reinstall, including a repair
192
+ install of the same version, because each ad-hoc
193
+ rebuild produces a new CDHash and is therefore a
194
+ real identity migration. Accepts that Microphone
195
+ and Accessibility must be granted again afterwards.
196
+ --acknowledge-local-signing-and-permissions
197
+ Acknowledge local-only ad-hoc signing up front
198
+
199
+ Launch and help:
200
+ --launch Launch the app after installing
201
+ --launch-timeout <seconds> Seconds to wait for the launch to settle
202
+ --help, -h Print this message and exit 0
203
+ USAGE
204
+ }
205
+
149
206
  ARTIFACT_PATH=""
150
207
  MANIFEST_PATH=""
151
208
  EXPECTED_TEAM_ID="${RECORDINGS_EXPECTED_TEAM_IDENTIFIER:-}"
@@ -162,6 +219,7 @@ APPROVED_TARGET_IDENTITY_KIND=""
162
219
  APPROVED_TARGET_IDENTITY_SHA256="none"
163
220
  ACKNOWLEDGE_LOCAL_SIGNING_AND_PERMISSIONS=0
164
221
  ALLOW_IDENTITY_MIGRATION=0
222
+ ALLOW_ADHOC_IDENTITY_MIGRATION=0
165
223
  LAUNCH_APP=0
166
224
  LAUNCH_TIMEOUT_SECONDS="${RECORDINGS_LAUNCH_TIMEOUT_SECONDS:-10}"
167
225
 
@@ -232,6 +290,12 @@ while [ "$#" -gt 0 ]; do
232
290
  ALLOW_IDENTITY_MIGRATION=1
233
291
  shift
234
292
  ;;
293
+ # Distinct from --allow-signing-identity-migration: approving a release signer
294
+ # rotation is not approving an ad-hoc replacement of a certificate-rooted install.
295
+ --allow-adhoc-identity-migration)
296
+ ALLOW_ADHOC_IDENTITY_MIGRATION=1
297
+ shift
298
+ ;;
235
299
  --launch)
236
300
  LAUNCH_APP=1
237
301
  shift
@@ -240,8 +304,13 @@ while [ "$#" -gt 0 ]; do
240
304
  LAUNCH_TIMEOUT_SECONDS="${2:-}"
241
305
  shift 2
242
306
  ;;
307
+ --help | -h)
308
+ print_usage
309
+ exit 0
310
+ ;;
243
311
  *)
244
312
  echo "Unknown argument: $1" >&2
313
+ echo "Run with --help for the accepted arguments." >&2
245
314
  exit 2
246
315
  ;;
247
316
  esac
@@ -310,6 +379,19 @@ PACKAGE_ROOT="$(cd "$("$DIRNAME_EXECUTABLE" "${BASH_SOURCE[0]}")/.." && pwd)"
310
379
  ARTIFACT_TOOL="${PACKAGE_ROOT}/scripts/macos_artifact.ts"
311
380
  RUNTIME_SMOKE="${PACKAGE_ROOT}/scripts/smoke_macos_app.sh"
312
381
  TAILSCALE_RESOLVER="${PACKAGE_ROOT}/scripts/resolve_tailscale_cli.sh"
382
+ # Sourced here, before either policy branch, so a missing or unusable guard denies every
383
+ # install rather than only the ones that happen to reach it.
384
+ IDENTITY_MIGRATION_GUARD="${PACKAGE_ROOT}/scripts/enforce_identity_migration.sh"
385
+ [ -f "$IDENTITY_MIGRATION_GUARD" ] && [ ! -L "$IDENTITY_MIGRATION_GUARD" ] || {
386
+ echo "Packaged identity-migration guard is missing." >&2
387
+ exit 2
388
+ }
389
+ # shellcheck source=/dev/null
390
+ . "$IDENTITY_MIGRATION_GUARD"
391
+ declare -F recordings_enforce_identity_migration >/dev/null || {
392
+ echo "Packaged identity-migration guard does not define its enforcement function." >&2
393
+ exit 2
394
+ }
313
395
  if [ "$ARTIFACT_POLICY" = "release" ]; then
314
396
  if [ -z "$EXPECTED_TEAM_ID" ]; then
315
397
  echo "Release install requires --expected-team-id or RECORDINGS_EXPECTED_TEAM_IDENTIFIER." >&2
@@ -331,13 +413,36 @@ if [ "$ARTIFACT_POLICY" = "release" ]; then
331
413
  echo "Local-only acknowledgment cannot be supplied for a release artifact." >&2
332
414
  exit 2
333
415
  fi
416
+ if [ "$ALLOW_ADHOC_IDENTITY_MIGRATION" -eq 1 ]; then
417
+ echo "Ad-hoc identity-migration approval is not valid for a release artifact; use --allow-signing-identity-migration with the exact old/new identity digests." >&2
418
+ exit 2
419
+ fi
334
420
  else
335
- if [ -n "$EXPECTED_TEAM_ID" ]; then
336
- echo "Local-only artifacts do not accept --expected-team-id or a release TeamIdentifier environment value." >&2
421
+ # A local-station artifact may be Developer ID signed under a pinned team, because ad-hoc
422
+ # signing cannot hold a macOS TCC grant: TCC keys Accessibility to bundle id plus signing
423
+ # identity, and an ad-hoc CDHash changes on every rebuild, which is what silently killed the
424
+ # Fn/Globe hold-to-talk CGEventTap. A supplied team must therefore be a real Apple team, and
425
+ # it is matched against the manifest below exactly as a release team is.
426
+ if [ -n "$EXPECTED_TEAM_ID" ] && ! [[ "$EXPECTED_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then
427
+ echo "Local-only --expected-team-id must be a 10-character Developer ID team identifier." >&2
337
428
  exit 2
338
429
  fi
339
- if [ "$APPROVED_TARGET" != "station06" ]; then
340
- echo "Local-only install is currently restricted to --approved-target station06." >&2
430
+ # The approved local-only targets are policy data shared with the artifact tool
431
+ # and the builder, parsed by one sourced reader so all three agree.
432
+ LOCAL_TARGET_POLICY="${PACKAGE_ROOT}/scripts/policy/local-only-approved-targets.txt"
433
+ LOCAL_TARGET_READER="${PACKAGE_ROOT}/scripts/read_local_only_targets.sh"
434
+ [ -f "$LOCAL_TARGET_READER" ] && [ ! -L "$LOCAL_TARGET_READER" ] || {
435
+ echo "Packaged local-only approved target reader is missing." >&2
436
+ exit 2
437
+ }
438
+ # shellcheck source=/dev/null
439
+ . "$LOCAL_TARGET_READER"
440
+ APPROVED_TARGET_MATCHED=0
441
+ APPROVED_TARGET_LIST=""
442
+ read_local_only_targets \
443
+ "$LOCAL_TARGET_POLICY" APPROVED_TARGET_LIST APPROVED_TARGET_MATCHED "$APPROVED_TARGET" || exit 2
444
+ if [ "$APPROVED_TARGET_MATCHED" -ne 1 ]; then
445
+ echo "Local-only install requires an approved --approved-target (${APPROVED_TARGET_LIST})." >&2
341
446
  exit 2
342
447
  fi
343
448
  if ! [[ "$APPROVED_TARGET_IDENTITY_SHA256" =~ ^[a-f0-9]{64}$ ]]; then
@@ -386,12 +491,16 @@ else
386
491
  exit 1
387
492
  fi
388
493
  if [ "$ALLOW_IDENTITY_MIGRATION" -eq 1 ] || [ -n "$EXPECTED_OLD_IDENTITY_SHA256" ] || [ -n "$EXPECTED_NEW_IDENTITY_SHA256" ]; then
389
- echo "Release identity-migration flags are not valid for local-only artifacts." >&2
494
+ echo "Release identity-migration flags are not valid for local-only artifacts; an ad-hoc replacement is approved with --allow-adhoc-identity-migration." >&2
390
495
  exit 2
391
496
  fi
392
- EXPECTED_TEAM_ID="ADHOC"
497
+ if [ -z "$EXPECTED_TEAM_ID" ]; then
498
+ EXPECTED_TEAM_ID="ADHOC"
499
+ fi
393
500
  echo "WARNING: installing a non-notarized local-only artifact approved only for ${APPROVED_TARGET}." >&2
394
- echo "WARNING: code identity can change; Microphone or Accessibility may require manual reauthorization." >&2
501
+ if [ "$EXPECTED_TEAM_ID" = "ADHOC" ]; then
502
+ echo "WARNING: code identity can change; Microphone or Accessibility may require manual reauthorization." >&2
503
+ fi
395
504
  fi
396
505
  if [ "$ARTIFACT_POLICY" = "release" ] && [ "$ALLOW_IDENTITY_MIGRATION" -eq 1 ] && \
397
506
  { [ -z "$EXPECTED_OLD_IDENTITY_SHA256" ] || [ -z "$EXPECTED_NEW_IDENTITY_SHA256" ]; }; then
@@ -1621,7 +1730,10 @@ if [ "$ARTIFACT_POLICY" = "release" ] && [ -z "$candidate_requirement" ]; then
1621
1730
  exit 1
1622
1731
  fi
1623
1732
  identity_migration=0
1624
- candidate_identity_sha256="$("$BUN_EXECUTABLE" "$ARTIFACT_TOOL" requirement-digest --app "$CANDIDATE_APP" --artifact-policy "$ARTIFACT_POLICY")"
1733
+ # Pass the SAME pinned team already given to verify-archive and verify-app. Without it the
1734
+ # digest verifies the candidate against the ad-hoc shape, which rejects a correctly
1735
+ # Developer-ID-signed local-station bundle and, under `set -euo pipefail`, aborts the install.
1736
+ candidate_identity_sha256="$("$BUN_EXECUTABLE" "$ARTIFACT_TOOL" requirement-digest --app "$CANDIDATE_APP" --artifact-policy "$ARTIFACT_POLICY" --expected-team-id "$EXPECTED_TEAM_ID")"
1625
1737
  candidate_tree_sha256="$("$BUN_EXECUTABLE" "$ARTIFACT_TOOL" tree-digest --path "$CANDIDATE_APP")"
1626
1738
  [ "$candidate_identity_sha256" = "$("$BUN_EXECUTABLE" "$ARTIFACT_TOOL" manifest-get \
1627
1739
  --manifest "$MANIFEST_SNAPSHOT" \
@@ -1655,21 +1767,18 @@ for existing_app in ${MANAGEABLE_APPS[@]+"${MANAGEABLE_APPS[@]}"}; do
1655
1767
  identity_migration=1
1656
1768
  fi
1657
1769
  done
1658
- if [ "$ARTIFACT_POLICY" = "release" ] && [ "$identity_migration" -eq 1 ] && [ "$ALLOW_IDENTITY_MIGRATION" -ne 1 ]; then
1659
- echo "Candidate and existing app designated requirements are not mutually compatible; review the signer change and rerun once with --allow-signing-identity-migration." >&2
1660
- exit 1
1661
- fi
1662
- if [ "$ARTIFACT_POLICY" = "release" ] && [ "$identity_migration" -eq 1 ] && {
1663
- [ "$previous_identity_sha256" != "$EXPECTED_OLD_IDENTITY_SHA256" ] ||
1664
- [ "$candidate_identity_sha256" != "$EXPECTED_NEW_IDENTITY_SHA256" ];
1665
- }; then
1666
- echo "Signing identity migration does not match the exact operator-approved old/new identities." >&2
1667
- exit 1
1668
- fi
1669
- if [ "$ARTIFACT_POLICY" = "release" ] && [ "$identity_migration" -eq 0 ] && [ "$ALLOW_IDENTITY_MIGRATION" -eq 1 ]; then
1670
- echo "Identity migration approval was supplied but no identity migration is required." >&2
1671
- exit 1
1672
- fi
1770
+ # Enforced for every artifact policy. A local-only artifact is ad-hoc signed, so this is
1771
+ # the point at which an install would void the installed app's Microphone and
1772
+ # Accessibility grants; the printed warning further down is not a gate.
1773
+ recordings_enforce_identity_migration \
1774
+ "$ARTIFACT_POLICY" \
1775
+ "$identity_migration" \
1776
+ "$ALLOW_IDENTITY_MIGRATION" \
1777
+ "$ALLOW_ADHOC_IDENTITY_MIGRATION" \
1778
+ "$previous_identity_sha256" \
1779
+ "$candidate_identity_sha256" \
1780
+ "$EXPECTED_OLD_IDENTITY_SHA256" \
1781
+ "$EXPECTED_NEW_IDENTITY_SHA256" || exit 1
1673
1782
 
1674
1783
  TRANSACTION_DIR="$("$MKTEMP_EXECUTABLE" -d "${APP_PARENT}/.Recordings-transaction.XXXXXX")"
1675
1784
  TRANSACTION_NONCE="$("$BUN_EXECUTABLE" -e 'import { randomUUID } from "node:crypto"; process.stdout.write(randomUUID())')"
@@ -1962,7 +2071,12 @@ fi
1962
2071
 
1963
2072
  if [ "$ARTIFACT_POLICY" = "local_only" ]; then
1964
2073
  echo "Installed local-only Recordings.app for ${APPROVED_TARGET}; this artifact is ad-hoc signed and non-notarized."
1965
- echo "Microphone or Accessibility may require manual reauthorization after this code-identity change."
2074
+ if [ "$identity_migration" -eq 1 ]; then
2075
+ # Not a maybe: the designated requirement did change, under an explicit approval.
2076
+ echo "Code identity changed under --allow-adhoc-identity-migration; the Microphone and Accessibility grants held by the replaced app no longer apply and must be reauthorized."
2077
+ else
2078
+ echo "Microphone or Accessibility may require manual reauthorization if this code identity differs from the last authorized one."
2079
+ fi
1966
2080
  elif [ "$identity_migration" -eq 1 ]; then
1967
2081
  echo "Installed a new signing identity; macOS will require one-time permission approval for this migration."
1968
2082
  else
@@ -31,6 +31,7 @@ import {
31
31
  unlinkSync,
32
32
  } from "node:fs";
33
33
  import { tmpdir } from "node:os";
34
+ import { fileURLToPath } from "node:url";
34
35
  import { crc32, inflateRawSync } from "node:zlib";
35
36
  import {
36
37
  nativeFsGuard,
@@ -57,6 +58,97 @@ export const BUNDLE_ID = "com.hasna.recordings";
57
58
  export const PROVENANCE_FILENAME = "recordings-build-provenance.json";
58
59
  export const RELEASE_APPROVED_TARGET = "fleet";
59
60
  export const LEGACY_LOCAL_TARGET_IDENTITY_KIND = "hardware_uuid_sha256";
61
+ export const LOCAL_ONLY_APPROVED_TARGETS_POLICY_PATH = join(
62
+ dirname(fileURLToPath(import.meta.url)),
63
+ "policy",
64
+ "local-only-approved-targets.txt",
65
+ );
66
+
67
+ // The approved local-only targets are policy data, not code: one file is read by
68
+ // this validator and, through scripts/read_local_only_targets.sh, by both shell entry
69
+ // points, so a target is declared once. The shell reader is a separate implementation
70
+ // of these rules; equivalence is enforced by an executing contract test, not by shared
71
+ // code. Parsing stays deliberately strict — a malformed policy fails closed rather than
72
+ // silently widening the allowlist.
73
+ //
74
+ // This is an operator-integrity control, NOT an authorization boundary. The file sits in
75
+ // the same package root as the guards that read it, so anyone able to edit it can edit
76
+ // those guards. Widening it still grants nothing on its own: the install additionally
77
+ // requires a matching `hostname -s`, a matching authenticated Tailscale node digest, and
78
+ // a matching authenticated manifest SHA-256.
79
+ export function localOnlyApprovedTargets(
80
+ policyPath: string = LOCAL_ONLY_APPROVED_TARGETS_POLICY_PATH,
81
+ ): string[] {
82
+ // lstat, not readFileSync alone: readFileSync FOLLOWS a symlink, and the shell reader
83
+ // refuses one outright (`[ -L ]`). That divergence was real and it favoured the
84
+ // attacker — a policy symlinked at a widened allowlist was rejected by the shell gate
85
+ // and silently honoured here. Refusing every non-regular file matches the shell reader
86
+ // and keeps the wording it prints, so both sides fail closed identically.
87
+ let policyStats;
88
+ try {
89
+ policyStats = lstatSync(policyPath);
90
+ } catch {
91
+ throw new Error("local-only approved target policy is missing");
92
+ }
93
+ if (!policyStats.isFile()) {
94
+ throw new Error("local-only approved target policy is missing");
95
+ }
96
+ // An unreadable (mode 000) policy passes the lstat above and then threw a raw EACCES from
97
+ // here, while the shell reader emitted bash's own "Permission denied" and no reader
98
+ // message. Same fail-closed outcome, two unrecognizable errors; both now say this.
99
+ let rawContents: string;
100
+ try {
101
+ rawContents = readFileSync(policyPath, "utf8");
102
+ } catch {
103
+ throw new Error("local-only approved target policy is not readable");
104
+ }
105
+ // Reject a NUL anywhere, in the same order and with the same wording as the shell
106
+ // reader's pre-scan. This reader had no NUL check at all and only ever refused one that
107
+ // happened to land inside a would-be hostname: "# comment\0" was dropped as a comment,
108
+ // so "# comment\0\nstation03\n" was refused by the shell gate and ACCEPTED here.
109
+ if (rawContents.includes("\u0000")) {
110
+ throw new Error("local-only approved target policy contains a NUL byte");
111
+ }
112
+ // Strip a BOM at offset 0 only. A U+FEFF anywhere else is a zero-width no-break space,
113
+ // not a byte-order mark; the shell reader used to strip one per line and therefore
114
+ // accepted a BOM on line 2 that this reader rejected.
115
+ const contents = rawContents.replace(/^/, "");
116
+ const targets = contents
117
+ .split("\n")
118
+ // Deliberately NOT String.trim(): trim() also strips U+FEFF and U+00A0, and the
119
+ // shell reader trims neither, so a trailing NBSP or BOM would be accepted here and
120
+ // rejected there. ASCII space and tab only, matching read_local_only_targets.sh
121
+ // under its pinned LC_ALL=C; everything else is left for the hostname shape below.
122
+ .map((line) => line.replace(/\r$/, "").replace(/^[\t ]+|[\t ]+$/g, ""))
123
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
124
+ if (targets.length === 0) {
125
+ throw new Error("local-only approved target policy lists no targets");
126
+ }
127
+ for (const target of targets) {
128
+ if (!/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/.test(target)) {
129
+ throw new Error(`local-only approved target policy has an invalid target name: ${target}`);
130
+ }
131
+ }
132
+ if (new Set(targets).size !== targets.length) {
133
+ throw new Error("local-only approved target policy has duplicate targets");
134
+ }
135
+ if (targets.includes(RELEASE_APPROVED_TARGET)) {
136
+ throw new Error("local-only approved target policy must not list the release fleet target");
137
+ }
138
+ return targets;
139
+ }
140
+
141
+ export function isLocalOnlyApprovedTarget(
142
+ target: string | undefined,
143
+ policyPath?: string,
144
+ ): boolean {
145
+ if (target === undefined) return false;
146
+ return localOnlyApprovedTargets(policyPath).includes(target);
147
+ }
148
+
149
+ function localOnlyApprovedTargetsMessage(policyPath?: string): string {
150
+ return localOnlyApprovedTargets(policyPath).join(", ");
151
+ }
60
152
 
61
153
  export type ArtifactPolicy = "release" | "local_only";
62
154
  export type TargetIdentityKind =
@@ -95,7 +187,7 @@ export type MacOSArtifactManifest = BuildProvenance & {
95
187
  };
96
188
  provenance_sha256: string;
97
189
  signing: {
98
- mode?: "ad_hoc";
190
+ mode?: "ad_hoc" | "developer_id";
99
191
  authority: string;
100
192
  team_id: string;
101
193
  trusted_timestamp: string;
@@ -460,14 +552,43 @@ export function parseDesignatedRequirement(output: string): string {
460
552
  return requirement;
461
553
  }
462
554
 
555
+ /**
556
+ * A local-station artifact is signed one of two ways, and the expected team identifier is
557
+ * what says which: the sentinel `ADHOC`, or a real 10-character Apple team.
558
+ *
559
+ * Developer ID signing exists on this path because ad-hoc signing cannot hold a macOS TCC
560
+ * grant. TCC keys Accessibility (and Microphone) to bundle id plus signing identity, and an
561
+ * ad-hoc signature offers only a CDHash that changes on every rebuild, so the hold-to-talk
562
+ * CGEventTap in FnKeyMonitor.swift lost its permission on each reinstall. Ad-hoc remains
563
+ * supported for builders that hold no signing key.
564
+ */
565
+ export function isLocalOnlySigningTeam(expectedTeamId: string): boolean {
566
+ return expectedTeamId === "ADHOC" || /^[A-Z0-9]{10}$/.test(expectedTeamId);
567
+ }
568
+
569
+ export function localOnlySigningMode(expectedTeamId: string): "ad_hoc" | "developer_id" {
570
+ if (expectedTeamId === "ADHOC") return "ad_hoc";
571
+ if (!/^[A-Z0-9]{10}$/.test(expectedTeamId)) {
572
+ throw new Error(
573
+ "local-only Developer ID signing requires ADHOC or a 10-character Apple team identifier",
574
+ );
575
+ }
576
+ return "developer_id";
577
+ }
578
+
463
579
  export function designatedRequirementForPolicy(
464
580
  output: string,
465
581
  artifactPolicy: ArtifactPolicy,
466
582
  adHocSignatureVerified = false,
583
+ developerIdSignatureVerified = false,
467
584
  ): string {
468
585
  if (artifactPolicy === "release") return parseDesignatedRequirement(output);
586
+ // A Developer ID local-station artifact always carries a real designated requirement, and
587
+ // that requirement is the whole point of signing it: it is the stable identity a TCC grant
588
+ // attaches to. Never let it degrade to the ad-hoc sentinel.
589
+ if (developerIdSignatureVerified) return parseDesignatedRequirement(output);
469
590
  if (!adHocSignatureVerified) {
470
- throw new Error("local-only designated requirement evidence requires verified ad-hoc signing");
591
+ throw new Error("local-only designated requirement evidence requires verified signing");
471
592
  }
472
593
  try {
473
594
  return parseDesignatedRequirement(output);
@@ -558,11 +679,24 @@ function signingEvidence(
558
679
  if (!timestamp || timestamp.toLowerCase() === "none") {
559
680
  throw new Error(`${codePath} is missing a trusted signing timestamp`);
560
681
  }
682
+ } else if (localOnlySigningMode(expectedTeamId) === "developer_id") {
683
+ // Developer ID signed but deliberately NOT notarized: the notarization, single-target
684
+ // binding and builder attestation are what still separate this from a release. Only the
685
+ // signing identity is shared, and only because TCC cannot key a durable Accessibility
686
+ // grant to anything weaker (see localOnlySigningMode).
687
+ if (!authority.startsWith("Developer ID Application:")) {
688
+ throw new Error(`${codePath} is not signed by a Developer ID Application authority`);
689
+ }
690
+ if (teamId !== expectedTeamId) {
691
+ throw new Error(`${codePath} TeamIdentifier ${teamId || "missing"} does not match ${expectedTeamId}`);
692
+ }
693
+ if (rawSignature.toLowerCase() === "adhoc") {
694
+ throw new Error(`${codePath} is ad-hoc signed but a Developer ID team was pinned`);
695
+ }
696
+ // A trusted timestamp is a release-only claim; local-station builds never pass --timestamp.
697
+ timestamp = rawTimestamp && rawTimestamp.toLowerCase() !== "none" ? rawTimestamp : "none";
561
698
  } else {
562
699
  mode = "ad_hoc";
563
- if (expectedTeamId !== "ADHOC") {
564
- throw new Error("local-only code verification requires the ADHOC signing identity");
565
- }
566
700
  if (rawSignature.toLowerCase() !== "adhoc" || authority) {
567
701
  throw new Error(`${codePath} is not consistently ad-hoc signed for local-only use`);
568
702
  }
@@ -587,6 +721,7 @@ function signingEvidence(
587
721
  requirementOutput,
588
722
  artifactPolicy,
589
723
  mode === "ad_hoc",
724
+ artifactPolicy !== "release" && mode === "developer_id",
590
725
  );
591
726
  const entitlements = canonicalEntitlements(codePath);
592
727
  if (entitlements !== canonicalJson(expectedEntitlements)) {
@@ -1510,7 +1645,7 @@ export function assertManifestShape(manifest: MacOSArtifactManifest): void {
1510
1645
  } else {
1511
1646
  if (
1512
1647
  manifest.artifact_policy !== "local_only" ||
1513
- manifest.approved_target !== "station06" ||
1648
+ !isLocalOnlyApprovedTarget(manifest.approved_target) ||
1514
1649
  (manifest.approved_target_identity_kind !== undefined &&
1515
1650
  manifest.approved_target_identity_kind !== "tailscale_node_id_sha256") ||
1516
1651
  (manifest.approved_target_identity_kind === undefined) !==
@@ -1523,20 +1658,38 @@ export function assertManifestShape(manifest: MacOSArtifactManifest): void {
1523
1658
  !isHex(manifest.builder_identity_sha256, 64) ||
1524
1659
  manifest.builder_identity_sha256 === manifest.approved_target_identity_sha256
1525
1660
  ) {
1526
- throw new Error("local-only schema v3 requires exact station06 name and machine identity");
1661
+ throw new Error(
1662
+ "local-only schema v3 requires an approved target name " +
1663
+ `(${localOnlyApprovedTargetsMessage()}) and machine identity`,
1664
+ );
1527
1665
  }
1528
- if (
1529
- manifest.non_notarized !== true ||
1530
- manifest.team_id !== "ADHOC" ||
1531
- manifest.signing.mode !== "ad_hoc" ||
1532
- manifest.signing.authority !== "adhoc" ||
1533
- manifest.signing.helper_authority !== "adhoc" ||
1534
- manifest.signing.team_id !== "ADHOC" ||
1535
- manifest.signing.helper_team_id !== "ADHOC" ||
1536
- manifest.signing.trusted_timestamp !== "none" ||
1537
- manifest.signing.helper_trusted_timestamp !== "none"
1666
+ // A local-station manifest is internally consistent in exactly one of two shapes: fully
1667
+ // ad-hoc, or fully Developer ID under one pinned team. Mixing them (e.g. a Developer ID
1668
+ // app with an ad-hoc helper) would leave the bundle without one stable identity for TCC
1669
+ // to key on, which is the failure this whole path exists to prevent.
1670
+ if (manifest.non_notarized !== true || !isLocalOnlySigningTeam(manifest.team_id)) {
1671
+ throw new Error("local-only manifest requires a non-notarized ADHOC or Developer ID team");
1672
+ }
1673
+ if (manifest.team_id === "ADHOC") {
1674
+ if (
1675
+ manifest.signing.mode !== "ad_hoc" ||
1676
+ manifest.signing.authority !== "adhoc" ||
1677
+ manifest.signing.helper_authority !== "adhoc" ||
1678
+ manifest.signing.team_id !== "ADHOC" ||
1679
+ manifest.signing.helper_team_id !== "ADHOC" ||
1680
+ manifest.signing.trusted_timestamp !== "none" ||
1681
+ manifest.signing.helper_trusted_timestamp !== "none"
1682
+ ) {
1683
+ throw new Error("local-only manifest requires consistent ad-hoc signing evidence");
1684
+ }
1685
+ } else if (
1686
+ manifest.signing.mode !== "developer_id" ||
1687
+ !manifest.signing.authority.startsWith("Developer ID Application:") ||
1688
+ !manifest.signing.helper_authority.startsWith("Developer ID Application:") ||
1689
+ manifest.signing.team_id !== manifest.team_id ||
1690
+ manifest.signing.helper_team_id !== manifest.team_id
1538
1691
  ) {
1539
- throw new Error("local-only manifest requires consistent ad-hoc signing evidence");
1692
+ throw new Error("local-only manifest requires consistent Developer ID signing evidence");
1540
1693
  }
1541
1694
  if (
1542
1695
  manifest.notarization.status !== "Not Submitted" ||
@@ -1962,15 +2115,19 @@ function writeProvenance(
1962
2115
  throw new Error("release provenance has an invalid target or Team ID");
1963
2116
  }
1964
2117
  } else if (
1965
- expectedTeamId !== "ADHOC" ||
1966
- approvedTarget !== "station06" ||
2118
+ // ADHOC or a pinned Developer ID team; localOnlySigningMode rejects anything else.
2119
+ !isLocalOnlySigningTeam(expectedTeamId) ||
2120
+ !isLocalOnlyApprovedTarget(approvedTarget) ||
1967
2121
  approvedTargetIdentityKind !== "tailscale_node_id_sha256" ||
1968
2122
  builderIdentityKind !== "tailscale_node_id_sha256" ||
1969
2123
  !isHex(approvedTargetIdentitySha256, 64) ||
1970
2124
  !isHex(builderIdentitySha256, 64) ||
1971
2125
  approvedTargetIdentitySha256 === builderIdentitySha256
1972
2126
  ) {
1973
- throw new Error("new local-only provenance requires ADHOC and a Tailscale node-bound station06 identity");
2127
+ throw new Error(
2128
+ "new local-only provenance requires ADHOC or a pinned Developer ID team and a Tailscale " +
2129
+ `node-bound approved-target identity (${localOnlyApprovedTargetsMessage()})`,
2130
+ );
1974
2131
  } else {
1975
2132
  provenance.artifact_policy = "local_only";
1976
2133
  provenance.approved_target = approvedTarget;
@@ -2120,11 +2277,13 @@ function finalizeLocalArtifact(
2120
2277
  approvedTarget: string,
2121
2278
  approvedTargetIdentityKind: TargetIdentityKind,
2122
2279
  approvedTargetIdentitySha256: string,
2280
+ expectedTeamId: string = "ADHOC",
2123
2281
  ): void {
2124
2282
  assertCurrentSourceRevision(packageRoot, expectedSourceSha);
2125
2283
  if (approvedTargetIdentityKind !== "tailscale_node_id_sha256") {
2126
2284
  throw new Error("new local-only artifacts require a Tailscale node ID identity hash");
2127
2285
  }
2286
+ const localSigningMode = localOnlySigningMode(expectedTeamId);
2128
2287
  const executablePath = join(appPath, "Contents", "MacOS", "Recordings");
2129
2288
  const helperPath = join(appPath, "Contents", "Helpers", "recordings");
2130
2289
  const embeddedPath = provenancePath(appPath);
@@ -2137,26 +2296,26 @@ function finalizeLocalArtifact(
2137
2296
  provenance.approved_target_identity_sha256 !== approvedTargetIdentitySha256 ||
2138
2297
  provenance.builder_identity_kind !== "tailscale_node_id_sha256" ||
2139
2298
  provenance.non_notarized !== true ||
2140
- provenance.team_id !== "ADHOC"
2299
+ provenance.team_id !== expectedTeamId
2141
2300
  ) {
2142
2301
  throw new Error("embedded provenance is not approved for this local-only target");
2143
2302
  }
2144
2303
  assertExpectedCodeLayout(appPath);
2145
2304
  const outerSigning = signingEvidence(
2146
2305
  appPath,
2147
- "ADHOC",
2306
+ expectedTeamId,
2148
2307
  APP_ENTITLEMENTS,
2149
2308
  executablePath,
2150
2309
  "local_only",
2151
2310
  );
2152
2311
  const helperSigning = signingEvidence(
2153
2312
  helperPath,
2154
- "ADHOC",
2313
+ expectedTeamId,
2155
2314
  HELPER_ENTITLEMENTS,
2156
2315
  helperPath,
2157
2316
  "local_only",
2158
2317
  );
2159
- const items = nestedItems(appPath, "ADHOC", "local_only", outerSigning, helperSigning);
2318
+ const items = nestedItems(appPath, expectedTeamId, "local_only", outerSigning, helperSigning);
2160
2319
  const manifest: MacOSArtifactManifest = {
2161
2320
  ...provenance,
2162
2321
  artifact_type: "recordings-macos-app",
@@ -2164,7 +2323,7 @@ function finalizeLocalArtifact(
2164
2323
  binding: { bundle_tree_sha256: treeDigest(appPath) },
2165
2324
  provenance_sha256: sha256File(embeddedPath),
2166
2325
  signing: {
2167
- mode: "ad_hoc",
2326
+ mode: localSigningMode,
2168
2327
  authority: outerSigning.authority,
2169
2328
  team_id: outerSigning.teamId,
2170
2329
  trusted_timestamp: outerSigning.timestamp,
@@ -2208,7 +2367,7 @@ function finalizeLocalArtifact(
2208
2367
  appPath,
2209
2368
  archivePath,
2210
2369
  manifest,
2211
- "ADHOC",
2370
+ expectedTeamId,
2212
2371
  "local_only",
2213
2372
  approvedTarget,
2214
2373
  approvedTargetIdentitySha256,
@@ -2301,11 +2460,19 @@ function assertInstallTransition(
2301
2460
  assertVersionTransition(installedVersion, installedSource, manifest);
2302
2461
  }
2303
2462
 
2304
- function requirementDigest(appPath: string, artifactPolicy: ArtifactPolicy): void {
2463
+ function requirementDigest(
2464
+ appPath: string,
2465
+ artifactPolicy: ArtifactPolicy,
2466
+ expectedTeamId: string = "ADHOC",
2467
+ ): void {
2305
2468
  if (artifactPolicy === "local_only") {
2469
+ // The pinned team decides which local signing shape is verified. Hard-coding "ADHOC" here
2470
+ // rejected every correctly Developer-ID-signed local-station bundle, and because
2471
+ // install_macos_app.sh calls this for EVERY local install under `set -euo pipefail`, that
2472
+ // rejection aborted the install outright — making a properly signed build uninstallable.
2306
2473
  const evidence = signingEvidence(
2307
2474
  appPath,
2308
- "ADHOC",
2475
+ expectedTeamId,
2309
2476
  APP_ENTITLEMENTS,
2310
2477
  join(appPath, "Contents", "MacOS", "Recordings"),
2311
2478
  "local_only",
@@ -2369,8 +2536,8 @@ function verifyActiveApp(
2369
2536
  const environment: NodeJS.ProcessEnv = {
2370
2537
  HOME: contractHome,
2371
2538
  PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
2372
- HASNA_RECORDINGS_STORAGE_MODE: "local",
2373
- RECORDINGS_STORAGE_MODE: "local",
2539
+ HASNA_RECORDINGS_CLIENT_STORE: "sqlite",
2540
+ RECORDINGS_CLIENT_STORE: "sqlite",
2374
2541
  HASNA_RECORDINGS_DB_PATH: join(contractHome, "recordings.db"),
2375
2542
  RECORDINGS_AUDIO_DIR: join(contractHome, "audio"),
2376
2543
  };
@@ -2702,7 +2869,7 @@ function readJournal(path: string): InstallJournal {
2702
2869
  journal.approved_target_identity_sha256 !== "none" ||
2703
2870
  journal.builder_identity_kind !== "none")) ||
2704
2871
  (journal.artifact_policy === "local_only" &&
2705
- (journal.approved_target !== "station06" ||
2872
+ (!isLocalOnlyApprovedTarget(journal.approved_target) ||
2706
2873
  !isTargetIdentityKind(journal.approved_target_identity_kind) ||
2707
2874
  !isTargetIdentityKind(journal.builder_identity_kind) ||
2708
2875
  journal.approved_target_identity_kind !== journal.builder_identity_kind ||
@@ -5135,6 +5302,7 @@ function main(): void {
5135
5302
  argument("--approved-target"),
5136
5303
  targetIdentityKindArgument(undefined, true),
5137
5304
  argument("--approved-target-identity-sha256"),
5305
+ optionalArgument("--expected-team-id") ?? "ADHOC",
5138
5306
  );
5139
5307
  } else if (command === "verify-archive") {
5140
5308
  const teamId = argument("--team-id");
@@ -5207,7 +5375,11 @@ function main(): void {
5207
5375
  argument("--manifest-sha256"),
5208
5376
  );
5209
5377
  } else if (command === "requirement-digest") {
5210
- requirementDigest(argument("--app"), artifactPolicyArgument());
5378
+ requirementDigest(
5379
+ argument("--app"),
5380
+ artifactPolicyArgument(),
5381
+ optionalArgument("--expected-team-id") ?? "ADHOC",
5382
+ );
5211
5383
  } else if (command === "tailscale-node-id-sha256") {
5212
5384
  console.log(tailscaleNodeIdSha256(readFileSync(0, "utf8"), argument("--expected-hostname")));
5213
5385
  } else if (command === "verify-filesystem-tree") {