@hasna/recordings 0.2.9 → 0.2.11

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/README.md CHANGED
@@ -11,6 +11,41 @@ Speech-to-text recording tool with MCP and CLI — records, transcribes, and opt
11
11
  npm install -g @hasna/recordings
12
12
  ```
13
13
 
14
+ ## macOS app, code signing & permissions
15
+
16
+ On macOS the package builds a native `Recordings.app` (into
17
+ `~/.hasna/recordings/Recordings.app`) during `postinstall` and requires
18
+ Microphone and Accessibility grants. Those grants are bound by macOS (TCC) to
19
+ the app's **code-signing designated requirement**, so signing has to stay stable
20
+ across updates or macOS re-prompts on every install.
21
+
22
+ - **Stable signing by default — no configuration needed.** If
23
+ `RECORDINGS_CODESIGN_IDENTITY` is set (e.g. a Developer ID or a station signing
24
+ certificate), it is used. Otherwise the build creates, once, a per-machine
25
+ self-signed code-signing certificate named **"Hasna Recordings Signing"** and
26
+ reuses it for every subsequent build. Because the same certificate is reused,
27
+ the designated requirement is certificate-based and constant across rebuilds,
28
+ so your Microphone/Accessibility grants survive updates. The build never falls
29
+ back to ad-hoc signing (which would change the requirement on every rebuild and
30
+ force re-authorization).
31
+ - **Works headless (over SSH).** The signing identity lives in a **dedicated**
32
+ keychain (`~/.hasna/recordings/signing/recordings-signing.keychain-db`) whose
33
+ password is generated once and stored in the secrets vault
34
+ (`hasna/machine/<host>/recordings/signing/keychain_password`). The build
35
+ unlocks that keychain with the known password and runs
36
+ `security set-key-partition-list -S apple-tool:,apple:` so `codesign` gets
37
+ non-interactive key access with no prompt — it never depends on the login
38
+ keychain being unlocked. Requires the `secrets` vault CLI on the machine.
39
+ - **Fails closed without entitlements.** If `RecordingsLib/Recordings.entitlements`
40
+ is missing the build aborts rather than shipping an unsigned/unentitled app.
41
+ - **Rebuild only on real changes.** Reinstalling the same version does not
42
+ rebuild the app: the installer compares a hash of the native sources plus the
43
+ package version (recorded at `~/.hasna/recordings/.recordings-source-hash`) and
44
+ skips the rebuild when nothing changed, leaving the already-granted app
45
+ untouched. A genuine update rebuilds and re-signs with the same certificate, so
46
+ grants still survive. The installer never resets TCC permissions.
47
+ - **Force a rebuild** with `RECORDINGS_FORCE_APP_REINSTALL=1 recordings app install`.
48
+
14
49
  ## CLI Usage
15
50
 
16
51
  ```bash
package/dist/cli/index.js CHANGED
@@ -1518,7 +1518,7 @@ function setAgentFocus(idOrName, projectId, db) {
1518
1518
  }
1519
1519
 
1520
1520
  // src/version.ts
1521
- var VERSION = "0.2.9";
1521
+ var VERSION = "0.2.11";
1522
1522
 
1523
1523
  // src/db/feedback.ts
1524
1524
  function saveFeedback(input) {
package/dist/index.js CHANGED
@@ -777,7 +777,7 @@ function setAgentFocus(idOrName, projectId, db) {
777
777
  }
778
778
 
779
779
  // src/version.ts
780
- var VERSION = "0.2.9";
780
+ var VERSION = "0.2.11";
781
781
 
782
782
  // src/db/feedback.ts
783
783
  function saveFeedback(input) {
package/dist/mcp/index.js CHANGED
@@ -4651,7 +4651,7 @@ function setAgentFocus(idOrName, projectId, db) {
4651
4651
  }
4652
4652
 
4653
4653
  // src/version.ts
4654
- var VERSION = "0.2.9";
4654
+ var VERSION = "0.2.11";
4655
4655
 
4656
4656
  // src/db/feedback.ts
4657
4657
  function saveFeedback(input) {
@@ -18,7 +18,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  var __require = import.meta.require;
19
19
 
20
20
  // src/version.ts
21
- var VERSION = "0.2.9";
21
+ var VERSION = "0.2.11";
22
22
 
23
23
  // src/db/remote-storage.ts
24
24
  import pg from "pg";
@@ -343,9 +343,21 @@ async function createRecording(pg2, input) {
343
343
  if (typeof input.raw_text !== "string" || !input.raw_text) {
344
344
  throw new ValidationError("raw_text is required");
345
345
  }
346
+ let resolvedAgentId = null;
347
+ if (input.agent_id) {
348
+ const agent = await getAgent(pg2, input.agent_id);
349
+ resolvedAgentId = agent ? agent.id : (await registerAgent(pg2, input.agent_id)).id;
350
+ }
351
+ let resolvedProjectId = null;
352
+ if (input.project_id) {
353
+ const project = await getProject(pg2, input.project_id);
354
+ if (!project)
355
+ throw new ProjectNotFoundError(input.project_id);
356
+ resolvedProjectId = project.id;
357
+ }
346
358
  const id = shortUuid();
347
359
  await pg2.run(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
348
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, JSON.stringify(input.tags || []), input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, JSON.stringify(input.metadata || {}));
360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, JSON.stringify(input.tags || []), resolvedAgentId, resolvedProjectId, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, JSON.stringify(input.metadata || {}));
349
361
  if (input.tags && input.tags.length > 0) {
350
362
  for (const tag of input.tags) {
351
363
  await pg2.run("INSERT INTO recording_tags (recording_id, tag) VALUES (?, ?) ON CONFLICT DO NOTHING", id, tag);
@@ -1 +1 @@
1
- {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../src/server/repo.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"repo.d.ts","sourceRoot":"","sources":["../../src/server/repo.ts"],"names":[],"mappings":"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"}
package/dist/storage.js CHANGED
@@ -616,7 +616,7 @@ function setAgentFocus(idOrName, projectId, db) {
616
616
  }
617
617
 
618
618
  // src/version.ts
619
- var VERSION = "0.2.9";
619
+ var VERSION = "0.2.11";
620
620
 
621
621
  // src/db/feedback.ts
622
622
  function saveFeedback(input) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.2.9";
1
+ export declare const VERSION = "0.2.11";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,WAAW,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/recordings",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "type": "module",
5
5
  "description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
6
6
  "repository": {
@@ -52,6 +52,67 @@ warn_or_fail() {
52
52
  exit 1
53
53
  }
54
54
 
55
+ # Deterministic, source-freshness-based rebuild skip.
56
+ #
57
+ # We rebuild only when the app SOURCES or the package VERSION actually changed.
58
+ # The decision is a hash of the native app source tree plus the package version
59
+ # — NOT the app's signature. This is deliberate:
60
+ # * Identical reinstall (same source/version): keep the already-installed,
61
+ # already-granted app untouched, so macOS permission grants survive.
62
+ # * Genuine app update (source or version changed): rebuild and re-sign with
63
+ # the same stable per-machine certificate. Its certificate-based designated
64
+ # requirement is unchanged, so the grants survive the update too — no
65
+ # update-starvation and no force flag needed.
66
+ STAMP_FILE="${DATA_DIR}/.recordings-source-hash"
67
+
68
+ # Keep the ~/Applications launch point pointed at the current build and restart
69
+ # a running instance. A second, stale copy in ~/Applications splits TCC
70
+ # permissions and leaves users on an old build, so we refresh it from the
71
+ # canonical app on EVERY path — including the skip-rebuild path, where the
72
+ # canonical app is the already-installed APP_DEST.
73
+ refresh_alt_copy_and_restart() {
74
+ local source_app="$1"
75
+ [ -d "$source_app" ] || return 0
76
+
77
+ local alt_copy="${HOME}/Applications/Recordings.app"
78
+ if [ -d "$alt_copy" ]; then
79
+ rm -rf "$alt_copy"
80
+ cp -R "$source_app" "$alt_copy" || true
81
+ echo "Updated stale copy at ${alt_copy} to the current app."
82
+ fi
83
+
84
+ # If an old instance is running, restart it on the current build.
85
+ if pgrep -x Recordings >/dev/null 2>&1; then
86
+ pkill -x Recordings || true
87
+ sleep 1
88
+ open "$APP_DEST" || true
89
+ echo "Restarted Recordings.app on the current build."
90
+ fi
91
+ }
92
+
93
+ compute_source_hash() {
94
+ command -v shasum >/dev/null 2>&1 || return 1
95
+ [ -d "$NATIVE_DIR" ] || return 1
96
+ {
97
+ # Version-aware: the package version line participates in the hash.
98
+ grep '"version"' "${PACKAGE_ROOT}/package.json" 2>/dev/null
99
+ # Source-aware: hash every native source file (relative paths, so the hash
100
+ # is independent of the install location), excluding build artifacts.
101
+ ( cd "$NATIVE_DIR" && find . -type f ! -path './.build/*' -exec shasum {} + 2>/dev/null | sort )
102
+ } | shasum | awk '{print $1}'
103
+ }
104
+
105
+ SOURCE_HASH="$(compute_source_hash)"
106
+
107
+ if [ -d "$APP_DEST" ] && [ "${RECORDINGS_FORCE_APP_REINSTALL:-0}" != "1" ] && \
108
+ [ -n "$SOURCE_HASH" ] && [ -f "$STAMP_FILE" ] && \
109
+ [ "$(cat "$STAMP_FILE" 2>/dev/null)" = "$SOURCE_HASH" ]; then
110
+ echo "Recordings.app at ${APP_DEST} is already built from the current sources; skipping rebuild to preserve macOS permission grants."
111
+ echo "Set RECORDINGS_FORCE_APP_REINSTALL=1 to force a rebuild."
112
+ refresh_alt_copy_and_restart "$APP_DEST"
113
+ exit 0
114
+ fi
115
+
55
116
  if ! command -v swift >/dev/null 2>&1; then
56
117
  warn_or_fail "Swift toolchain not found"
57
118
  fi
@@ -73,53 +134,16 @@ rm -rf "$APP_DEST"
73
134
  mkdir -p "$DATA_DIR"
74
135
  cp -R "$APP_SOURCE" "$APP_DEST" || warn_or_fail "failed to copy app bundle"
75
136
 
76
- current_cdhash() {
77
- codesign -d --verbose=4 "$1" 2>&1 | awk -F= '/^CDHash=/ { print toupper($2); exit }'
78
- }
79
-
80
- tcc_csreq_hex() {
81
- local db_path="$1"
82
- local service="$2"
83
- if [ ! -r "$db_path" ] || ! command -v sqlite3 >/dev/null 2>&1; then
84
- return 0
85
- fi
86
- sqlite3 "$db_path" \
87
- "SELECT hex(csreq) FROM access WHERE service = '${service}' AND client = 'com.hasna.recordings' ORDER BY last_modified DESC LIMIT 1;" \
88
- 2>/dev/null || true
89
- }
90
-
91
- reset_stale_permission() {
92
- local service="$1"
93
- local tcc_service="$2"
94
- local db_path="$3"
95
- local cdhash="$4"
96
- local csreq_hex
97
- csreq_hex="$(tcc_csreq_hex "$db_path" "$tcc_service" | tr '[:lower:]' '[:upper:]')"
98
- if [ -n "$cdhash" ] && [ -n "$csreq_hex" ] && [[ "$csreq_hex" != *"$cdhash"* ]]; then
99
- tccutil reset "$service" com.hasna.recordings >/dev/null 2>&1 || true
100
- echo "Reset stale ${service} permission for the newly installed Recordings.app."
101
- fi
102
- }
103
-
104
- APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
105
- reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
106
- reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
107
-
108
- # A second copy in ~/Applications splits TCC permissions and leaves users running
109
- # stale builds. Keep that launch point but always point it at the fresh build.
110
- ALT_COPY="${HOME}/Applications/Recordings.app"
111
- if [ -d "$ALT_COPY" ]; then
112
- rm -rf "$ALT_COPY"
113
- cp -R "$APP_SOURCE" "$ALT_COPY" || true
114
- echo "Updated stale copy at ${ALT_COPY} to the freshly built app."
137
+ # Record the source/version hash this build was produced from so an identical
138
+ # future reinstall is skipped (see the rebuild-skip guard above).
139
+ if [ -n "$SOURCE_HASH" ]; then
140
+ printf '%s\n' "$SOURCE_HASH" > "$STAMP_FILE"
115
141
  fi
116
142
 
117
- # If an old instance is running, restart it on the new build.
118
- if pgrep -x Recordings >/dev/null 2>&1; then
119
- pkill -x Recordings || true
120
- sleep 1
121
- open "$APP_DEST" || true
122
- echo "Restarted Recordings.app on the new build."
123
- fi
143
+ # NOTE: this installer must never modify TCC permission state. Automatically
144
+ # resetting "stale" grants deleted the user's Microphone/Accessibility
145
+ # approvals on every update; users must always keep their existing decisions.
146
+
147
+ refresh_alt_copy_and_restart "$APP_DEST"
124
148
 
125
149
  echo "Installed Recordings.app from package: ${APP_DEST}"
@@ -8,6 +8,200 @@ MODE="${1:-release}"
8
8
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
9
  cd "$SCRIPT_DIR"
10
10
 
11
+ # A STABLE certificate identity keeps the app's TCC designated requirement
12
+ # constant across rebuilds so macOS permission grants (Microphone /
13
+ # Accessibility) persist. Ad-hoc ("-") pins the identity to this exact binary's
14
+ # CDHash and forces re-authorization after every build, so we NEVER default to
15
+ # it. When no explicit identity is provided we create (once) and reuse a
16
+ # per-machine self-signed code-signing certificate held in a DEDICATED signing
17
+ # keychain, so the default postinstall path is self-contained AND works over a
18
+ # headless SSH session — no out-of-band env and no unlocked login keychain
19
+ # required.
20
+ SIGNING_CN="Hasna Recordings Signing"
21
+ SIGNING_DIR="${HOME}/.hasna/recordings/signing"
22
+ # Dedicated code-signing keychain. We NEVER touch the login keychain: over SSH
23
+ # the login keychain is locked and codesign would block on an interactive
24
+ # unlock/allow prompt. A dedicated keychain with a KNOWN password (stored in the
25
+ # vault) can be created, unlocked, and key-partition-listed non-interactively.
26
+ SIGNING_KEYCHAIN="${SIGNING_DIR}/recordings-signing.keychain-db"
27
+
28
+ # Resolve the macOS keychain/codesign tools by ABSOLUTE path when present, so a
29
+ # same-named shim earlier on PATH (e.g. the Hasna `security` CLI installed in
30
+ # ~/.bun/bin) cannot shadow the real /usr/bin/security. On hosts without those
31
+ # absolute paths (e.g. the Linux unit-test sandbox) we fall back to PATH so the
32
+ # tests can stub them.
33
+ SECURITY="/usr/bin/security"; [ -x "$SECURITY" ] || SECURITY="security"
34
+ CODESIGN="/usr/bin/codesign"; [ -x "$CODESIGN" ] || CODESIGN="codesign"
35
+ # SHA-1 hash of the signing identity, resolved by ensure_local_signing_identity.
36
+ SIGNING_IDENTITY=""
37
+
38
+ # Vault keys are host-scoped so each machine owns its own signing material.
39
+ signing_host() {
40
+ hostname -s 2>/dev/null || hostname 2>/dev/null || echo unknown
41
+ }
42
+ vault_prefix() {
43
+ printf 'hasna/machine/%s/recordings/signing' "$(signing_host)"
44
+ }
45
+
46
+ # Append the dedicated keychain to the USER search list (never replacing it) so
47
+ # codesign / find-identity can see the identity while leaving login and other
48
+ # keychains intact.
49
+ add_signing_keychain_to_search_list() {
50
+ local target="$SIGNING_KEYCHAIN"
51
+ local -a current=()
52
+ local line trimmed
53
+ while IFS= read -r line; do
54
+ # `security list-keychains` prints each path indented and quoted.
55
+ trimmed="${line#"${line%%[![:space:]]*}"}" # strip leading whitespace
56
+ trimmed="${trimmed%\"}"; trimmed="${trimmed#\"}"
57
+ [ -n "$trimmed" ] || continue
58
+ [ "$trimmed" = "$target" ] && return 0 # already in the list
59
+ current+=("$trimmed")
60
+ done < <("$SECURITY" list-keychains -d user 2>/dev/null)
61
+ "$SECURITY" list-keychains -d user -s "$target" ${current[@]+"${current[@]}"} >/dev/null 2>&1 \
62
+ || echo "WARN: could not add $target to the keychain search list" >&2
63
+ }
64
+
65
+ # Create/reuse the "Hasna Recordings Signing" identity inside the DEDICATED
66
+ # signing keychain, unlocking it non-interactively with the known vault-stored
67
+ # password so codesign can sign over SSH without any prompt. Reusing the same
68
+ # certificate is what makes the designated requirement (certificate-based)
69
+ # stable across rebuilds so TCC grants survive. Returns 0 when usable.
70
+ ensure_local_signing_identity() {
71
+ command -v "$SECURITY" >/dev/null 2>&1 || { echo "ERROR: 'security' not found" >&2; return 1; }
72
+ command -v openssl >/dev/null 2>&1 || { echo "ERROR: 'openssl' not found" >&2; return 1; }
73
+ # The dedicated keychain's password lives in the vault; without the vault CLI
74
+ # we cannot manage it non-interactively, so fail closed rather than fall back
75
+ # to the (headless-hostile) login keychain.
76
+ command -v secrets >/dev/null 2>&1 || { echo "ERROR: 'secrets' vault CLI not found; cannot manage the dedicated signing keychain password" >&2; return 1; }
77
+
78
+ local prefix pw_key cert_key key_key
79
+ prefix="$(vault_prefix)"
80
+ pw_key="${prefix}/keychain_password"
81
+ cert_key="${prefix}/certificate_pem_b64"
82
+ key_key="${prefix}/private_key_pem_b64"
83
+
84
+ mkdir -p "$SIGNING_DIR" && chmod 700 "$SIGNING_DIR" 2>/dev/null || true
85
+
86
+ # 1. Known keychain password: generate once, persist in the vault, reuse.
87
+ local kc_pw
88
+ kc_pw="$(secrets get "$pw_key" 2>/dev/null || true)"
89
+ if [ -z "$kc_pw" ]; then
90
+ kc_pw="$(openssl rand -base64 24 | tr -d '\n')"
91
+ if ! secrets set "$pw_key" "$kc_pw" --type password \
92
+ --label "Recordings signing keychain password ($(signing_host))" >/dev/null 2>&1; then
93
+ echo "ERROR: failed to store signing keychain password in the vault ($pw_key)" >&2
94
+ return 1
95
+ fi
96
+ echo "Generated dedicated signing keychain password (stored in vault: $pw_key)."
97
+ fi
98
+
99
+ # 2. Create the dedicated keychain if missing; ALWAYS unlock it with the
100
+ # known password. Disable auto-lock so the key stays reachable headless.
101
+ if [ ! -f "$SIGNING_KEYCHAIN" ]; then
102
+ echo "Creating dedicated code-signing keychain: $SIGNING_KEYCHAIN"
103
+ if ! "$SECURITY" create-keychain -p "$kc_pw" "$SIGNING_KEYCHAIN" >/dev/null 2>&1; then
104
+ echo "ERROR: failed to create dedicated signing keychain" >&2; return 1
105
+ fi
106
+ fi
107
+ if ! "$SECURITY" unlock-keychain -p "$kc_pw" "$SIGNING_KEYCHAIN" >/dev/null 2>&1; then
108
+ echo "ERROR: failed to unlock dedicated signing keychain (stale vault password?)" >&2; return 1
109
+ fi
110
+ # No timeout / no lock-on-sleep: keep it usable across a headless build.
111
+ "$SECURITY" set-keychain-settings "$SIGNING_KEYCHAIN" >/dev/null 2>&1 || true
112
+
113
+ # 3. Make the dedicated keychain discoverable to codesign/find-identity.
114
+ add_signing_keychain_to_search_list
115
+
116
+ # 4. Reuse the identity if it is already present in the dedicated keychain.
117
+ # NOTE: no -v (valid-only) filter. A self-signed identity is untrusted
118
+ # (CSSMERR_TP_NOT_TRUSTED) — and marking it trusted needs GUI auth that is
119
+ # unavailable over SSH — so it is invisible to `find-identity -v`. codesign
120
+ # can nonetheless sign with it, so we locate it by its SHA-1 HASH (which is
121
+ # also what we pass to `codesign --sign`, decoupling signing from the
122
+ # certificate's common name).
123
+ SIGNING_IDENTITY="$("$SECURITY" find-identity -p codesigning "$SIGNING_KEYCHAIN" 2>/dev/null | grep -oE '[0-9A-F]{40}' | head -1)"
124
+ if [ -n "$SIGNING_IDENTITY" ]; then
125
+ # Re-assert non-interactive key access with the KNOWN password.
126
+ "$SECURITY" set-key-partition-list -S apple-tool:,apple: -k "$kc_pw" "$SIGNING_KEYCHAIN" >/dev/null 2>&1 || true
127
+ return 0
128
+ fi
129
+
130
+ # 5. Materialize cert+key: reuse the vault copy if present (keeps the DR — and
131
+ # the TCC grants bound to it — stable even if the keychain is recreated),
132
+ # otherwise mint a new self-signed identity and persist it to the vault.
133
+ local work key crt p12 vault_cert_b64 vault_key_b64
134
+ work="$(mktemp -d)" || return 1
135
+ key="$work/key.pem"; crt="$work/cert.pem"; p12="$work/identity.p12"
136
+
137
+ vault_cert_b64="$(secrets get "$cert_key" 2>/dev/null || true)"
138
+ vault_key_b64="$(secrets get "$key_key" 2>/dev/null || true)"
139
+ if [ -n "$vault_cert_b64" ] && [ -n "$vault_key_b64" ]; then
140
+ printf '%s' "$vault_cert_b64" | openssl base64 -d -A > "$crt" 2>/dev/null || true
141
+ printf '%s' "$vault_key_b64" | openssl base64 -d -A > "$key" 2>/dev/null || true
142
+ [ -s "$crt" ] && [ -s "$key" ] && echo "Reusing signing certificate from vault ($cert_key)."
143
+ fi
144
+ if [ ! -s "$crt" ] || [ ! -s "$key" ]; then
145
+ echo "Minting new self-signed code-signing certificate \"$SIGNING_CN\" (one-time)..."
146
+ cat > "$work/req.cnf" <<EOF
147
+ [ req ]
148
+ distinguished_name = dn
149
+ x509_extensions = ext
150
+ prompt = no
151
+ [ dn ]
152
+ CN = $SIGNING_CN
153
+ [ ext ]
154
+ basicConstraints = critical,CA:false
155
+ keyUsage = critical,digitalSignature
156
+ extendedKeyUsage = critical,codeSigning
157
+ EOF
158
+ if ! openssl req -x509 -newkey rsa:2048 -nodes -keyout "$key" -out "$crt" \
159
+ -days 3650 -config "$work/req.cnf" >/dev/null 2>&1; then
160
+ rm -rf "$work"; echo "ERROR: failed to generate self-signed signing certificate" >&2; return 1
161
+ fi
162
+ # Persist so future rebuilds — even after keychain re-creation or machine
163
+ # re-provisioning — reuse the SAME identity and keep the DR stable.
164
+ secrets set "$cert_key" "$(openssl base64 -A -in "$crt")" --type certificate \
165
+ --label "Recordings signing cert ($(signing_host))" >/dev/null 2>&1 \
166
+ || echo "WARN: could not persist signing certificate to vault ($cert_key)" >&2
167
+ secrets set "$key_key" "$(openssl base64 -A -in "$key")" --type private_key \
168
+ --label "Recordings signing key ($(signing_host))" >/dev/null 2>&1 \
169
+ || echo "WARN: could not persist signing key to vault ($key_key)" >&2
170
+ fi
171
+
172
+ # Package as PKCS#12 for `security import`. macOS's Security framework cannot
173
+ # read the PBKDF2/AES encoding OpenSSL 3 emits by default, so prefer -legacy
174
+ # (RC2/3DES + SHA1 MAC) and fall back for OpenSSL builds without it. Use a
175
+ # non-empty transport passphrase (the keychain password): an empty p12
176
+ # password trips "MAC verification failed" on macOS import.
177
+ if ! openssl pkcs12 -export -legacy -inkey "$key" -in "$crt" -name "$SIGNING_CN" \
178
+ -out "$p12" -passout pass:"$kc_pw" >/dev/null 2>&1; then
179
+ if ! openssl pkcs12 -export -inkey "$key" -in "$crt" -name "$SIGNING_CN" \
180
+ -out "$p12" -passout pass:"$kc_pw" >/dev/null 2>&1; then
181
+ rm -rf "$work"; echo "ERROR: failed to package signing identity as PKCS#12" >&2; return 1
182
+ fi
183
+ fi
184
+ # Import into the DEDICATED keychain (never the login keychain), allowlisting
185
+ # codesign so it may use the private key.
186
+ if ! "$SECURITY" import "$p12" -k "$SIGNING_KEYCHAIN" -P "$kc_pw" -T /usr/bin/codesign >/dev/null 2>&1; then
187
+ rm -rf "$work"; echo "ERROR: failed to import signing identity into $SIGNING_KEYCHAIN" >&2; return 1
188
+ fi
189
+ # Trust the self-signed root for code signing so `codesign --verify` passes.
190
+ "$SECURITY" add-trusted-cert -r trustRoot -p codeSign -k "$SIGNING_KEYCHAIN" "$crt" >/dev/null 2>&1 || true
191
+ # THE headless fix: grant codesign non-interactive access to the imported key
192
+ # using the KNOWN keychain password (never an empty password, never the login
193
+ # keychain), so signing works over SSH with no interactive allow prompt.
194
+ if ! "$SECURITY" set-key-partition-list -S apple-tool:,apple: -k "$kc_pw" "$SIGNING_KEYCHAIN" >/dev/null 2>&1; then
195
+ echo "WARN: set-key-partition-list did not complete; codesign may prompt for key access" >&2
196
+ fi
197
+ rm -rf "$work"
198
+
199
+ # Locate the freshly imported identity by hash (again, no -v: it is an
200
+ # untrusted self-signed cert). codesign signs by this hash.
201
+ SIGNING_IDENTITY="$("$SECURITY" find-identity -p codesigning "$SIGNING_KEYCHAIN" 2>/dev/null | grep -oE '[0-9A-F]{40}' | head -1)"
202
+ [ -n "$SIGNING_IDENTITY" ]
203
+ }
204
+
11
205
  echo "Building Recordings.app ($MODE)..."
12
206
  swift build -c "$MODE" --product App
13
207
 
@@ -34,9 +228,39 @@ for bundle in "$BUILD_DIR"/*.resources "$BUILD_DIR"/*.bundle .build/*/"$MODE"/*.
34
228
  ditto "$bundle" "$RESOURCES/$(basename "$bundle")"
35
229
  done
36
230
 
37
- # Copy entitlements (for codesigning)
38
- if [ -f RecordingsLib/Recordings.entitlements ]; then
39
- codesign --force --sign - --entitlements RecordingsLib/Recordings.entitlements "$APP_DIR" 2>/dev/null || true
231
+ # FAIL CLOSED on missing entitlements. Without the entitlements file the app
232
+ # would be signed with no Microphone/Accessibility entitlements (or skipped and
233
+ # left effectively unsigned) a silent, safety-critical regression. Refuse to
234
+ # build instead.
235
+ if [ ! -f RecordingsLib/Recordings.entitlements ]; then
236
+ echo "ERROR: RecordingsLib/Recordings.entitlements is missing." >&2
237
+ echo "Refusing to build: the app must be signed with its entitlements;" >&2
238
+ echo "skipping signing would ship an unsigned/unentitled Recordings.app." >&2
239
+ exit 1
240
+ fi
241
+
242
+ # Sign with entitlements using a STABLE identity. A signing failure must fail
243
+ # the build: silently falling back to ad-hoc would reintroduce identity churn
244
+ # and invalidate existing TCC grants.
245
+ #
246
+ # Precedence:
247
+ # 1. RECORDINGS_CODESIGN_IDENTITY — explicit identity (Developer ID, station
248
+ # signing cert, or "-" for an intentional ad-hoc build). An explicit
249
+ # identity that fails to sign fails the build.
250
+ # 2. The per-machine "Hasna Recordings Signing" self-signed certificate in the
251
+ # DEDICATED signing keychain, created once and reused, so the default build
252
+ # is stably signed non-interactively (works over SSH) with no out-of-band
253
+ # configuration. We never fall back to ad-hoc by default.
254
+ if [ -n "${RECORDINGS_CODESIGN_IDENTITY:-}" ]; then
255
+ "$CODESIGN" --force --sign "$RECORDINGS_CODESIGN_IDENTITY" --entitlements RecordingsLib/Recordings.entitlements "$APP_DIR"
256
+ elif ensure_local_signing_identity; then
257
+ "$CODESIGN" --force --keychain "$SIGNING_KEYCHAIN" --sign "$SIGNING_IDENTITY" --entitlements RecordingsLib/Recordings.entitlements "$APP_DIR"
258
+ else
259
+ echo "ERROR: no stable code-signing identity is available and refusing to" >&2
260
+ echo "ad-hoc sign: ad-hoc signatures change the app's designated requirement" >&2
261
+ echo "on every build and break macOS Microphone/Accessibility grants." >&2
262
+ echo "Set RECORDINGS_CODESIGN_IDENTITY to a valid signing identity and retry." >&2
263
+ exit 1
40
264
  fi
41
265
 
42
266
  echo "✓ Built $APP_DIR"