From a28168b920b911f9dc56db627cb02c7554809fce Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:15:35 +0200 Subject: [PATCH 01/18] [codex/vc-workflow] fix: terminate failed Agent fallbacks - Publish TextDone and Done after a persisted legacy fallback - Publish an actionable Error when both provider paths fail - Guard the terminal mapping against blank fallback output Authored-By: Codex --- app/controller/helpers.rs | 83 ++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 06c21b1b..588606f6 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -1161,6 +1161,36 @@ fn legacy_fallback_assistant_text( } } +const LEGACY_FALLBACK_UNAVAILABLE_MESSAGE: &str = + "Agent provider and fallback are unavailable. Check Settings → Providers."; + +/// Build the one terminal sequence the Swift Agent surface must receive after +/// the runtime has handed a voice turn to the legacy formatter. The fallback +/// used to persist its result without publishing anything, leaving the +/// assistant placeholder permanently in `Thinking` after a fast provider/key +/// failure. Keep this mapping pure so the terminal contract is testable without +/// a live provider or a Swift listener. +fn legacy_fallback_terminal_events(assistant_text: Option<&str>) -> Vec { + match assistant_text + .map(str::trim) + .filter(|text| !text.is_empty()) + { + Some(text) => vec![ + AgentDeliveryEvent::TextDone(text.to_string()), + AgentDeliveryEvent::Done, + ], + None => vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string(), + )], + } +} + +fn publish_legacy_fallback_terminal(assistant_text: Option<&str>) { + for event in legacy_fallback_terminal_events(assistant_text) { + crate::agent_delivery::publish_agent_delivery_event(event); + } +} + /// Answer one turn through the legacy formatter instead of the agent runtime. /// Returns the assistant text only when there is genuine output to persist; a /// formatter failure logs and yields `None` rather than writing a junk thread. @@ -1236,18 +1266,25 @@ async fn run_agent_send_with_fallback( ); debug!("Legacy fallback input length: {}", text.len()); let fallback_assistant_text = run_legacy_send_path(&text, whisper_language).await; - if let Some(assistant_text) = fallback_assistant_text { - match deliver_legacy_assistive_thread(&text, &assistant_text) { - Ok(Some(receipt)) => debug!( - backend_thread_id = %receipt.backend_id, - message_count = receipt.message_count, - "Legacy assistive fallback delivered" - ), - Ok(None) => {} - Err(error) => { - warn!("Failed to deliver legacy assistive fallback thread: {error}") + match fallback_assistant_text { + Some(assistant_text) => { + match deliver_legacy_assistive_thread(&text, &assistant_text) { + Ok(Some(receipt)) => { + debug!( + backend_thread_id = %receipt.backend_id, + message_count = receipt.message_count, + "Legacy assistive fallback delivered" + ); + publish_legacy_fallback_terminal(Some(&assistant_text)); + } + Ok(None) => publish_legacy_fallback_terminal(None), + Err(error) => { + warn!("Failed to deliver legacy assistive fallback thread: {error}"); + publish_legacy_fallback_terminal(None); + } } } + None => publish_legacy_fallback_terminal(None), } } } @@ -1756,6 +1793,32 @@ mod tests { ); } + /// A formatter fallback is still the terminal owner of the already-open + /// voice bubble. Success must fill and close it; failure must close it with + /// an actionable error. Neither branch may leave Swift in `Thinking`. + #[test] + fn legacy_fallback_always_publishes_a_terminal_ui_sequence() { + assert_eq!( + legacy_fallback_terminal_events(Some(" recovered reply ")), + vec![ + AgentDeliveryEvent::TextDone("recovered reply".to_string()), + AgentDeliveryEvent::Done, + ] + ); + assert_eq!( + legacy_fallback_terminal_events(None), + vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string() + )] + ); + assert_eq!( + legacy_fallback_terminal_events(Some(" ")), + vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string() + )] + ); + } + /// Provider that never emits anything: its event channel is closed /// immediately. Used where a session must exist but must not produce /// conversation history of its own. From daf22c0fb805a87abe0b71710de715c1d723cd85 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:16:36 +0200 Subject: [PATCH 02/18] [codex/vc-workflow] feat: add unattended corpus parity reports - Census private recordings without loading operator state or Keychain - Replay explicit production profiles in isolated worker processes - Emit redacted JSON/Markdown plus private keyboard-driven Qube HTML - Preserve host configuration hashes and fail closed on TCC readiness Authored-By: Codex --- Cargo.toml | 6 +- Makefile | 56 +- bin/codescribe-corpus.rs | 1969 +++++++++++++++++++++++++++++++++++ core/quality/qube_report.rs | 8 +- core/stt/apple_stt/mod.rs | 32 + 5 files changed, 2068 insertions(+), 3 deletions(-) create mode 100644 bin/codescribe-corpus.rs diff --git a/Cargo.toml b/Cargo.toml index 3749a2e0..83630eda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,10 @@ path = "bin/codescribe.rs" name = "codescribe-teacher" path = "bin/codescribe-teacher.rs" +[[bin]] +name = "codescribe-corpus" +path = "bin/codescribe-corpus.rs" + [lib] path = "app/lib.rs" @@ -107,6 +111,7 @@ chrono = "0.4" # UUID generation uuid = { version = "1", features = ["v4"] } +sha2 = "0.10" # Lazy static for global state lazy_static = "1.4" @@ -136,7 +141,6 @@ tempfile = "3" mockito = "1" serial_test = "3" hound = "3.5" -sha2 = "0.10" [lints.rust] # Allow unexpected_cfgs from objc crate's msg_send! macro (uses cargo-clippy cfg internally) diff --git a/Makefile b/Makefile index f9e1fb2a..4b594578 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ bump bump-patch bump-minor bump-major version \ lint format test test-quick test-e2e test-e2e-real test-sse test-sse-release test-responses-live test-sse-heavy test-formatting test-all \ test-engine test-engine-apple test-engine-candle test-teacher \ - demo demo-raw demo-assistive check verify semgrep fix clean help \ + demo demo-raw demo-assistive check verify semgrep fix clean help corpus-census test-corpus-parity \ dist-preflight dist-preflight-signed verify-canaries smoke-canaries \ dmg dmg-signed release-standard release-full release-dmgs notarize verify-dmg download-model download-e5 download-embedder ensure-models \ hooks @@ -77,6 +77,18 @@ LOCAL_LLM_ENDPOINT ?= http://localhost:11434/v1/responses LOCAL_LLM_MODEL ?= gpt-oss:120b-cloud LOCAL_LLM_API_KEY ?= local +# Content-private corpus inventory and production-session replay. The binary +# hard-disables Keychain and never loads operator settings/.env. Machine reports +# are content-redacted; private mode-0600 Qube HTML carries review transcripts. +CORPUS_ROOTS ?= $(HOME)/.codescribe/data_assets $(HOME)/.codescribe/transcriptions +CORPUS_REFERENCE_POLICY ?= human +CORPUS_PROFILES ?= apple-layer0,apple-layer1-inprocess +CORPUS_RUNS ?= 1 +CORPUS_MAX_RECORDINGS ?= 1 +CORPUS_APPLE_BRIDGE ?= /Applications/Codescribe.app/Contents/MacOS/codescribe-stt-bridge +CORPUS_RUN_ID ?= $(shell date +%Y%m%d-%H%M%S) +CORPUS_OUT ?= $(HOME)/.vibecrafted/artifacts/vetcoders/codescribe/$(shell date +%Y_%m%d)/reports/corpus-$(CORPUS_RUN_ID) + define APPLY_TEST_LLM if [[ "$(TEST_USE_LOCAL_LLM)" == "1" ]]; then \ export LLM_ENDPOINT="$(LOCAL_LLM_ENDPOINT)"; \ @@ -317,6 +329,7 @@ bump-major: # gate: test-engine-parity class=operator ci=no -- Layer 0 parity bar vs the Apple reference; private corpus, host-local bench # gate: test-engine-parity-layered class=operator ci=no -- Layer 1 parity arm judged on structure; private corpus, host-local bench # gate: test-engine-parity-both class=operator ci=no -- runs both parity arms and prints the delta +# gate: test-corpus-parity class=operator ci=no -- isolated production-session file replay; private corpus and local STT models # gate: test-teacher class=operator ci=no -- teacher CLI proof run, writes an HTML report # gate: test-swift class=operator ci=no -- SwiftUI suite + Apple phrase-restart Rust/Swift lockstep self-test; needs Xcode and built ffi/bridge binaries # gate: smoke-macos27 class=operator ci=no -- host smoke after an OS/Xcode bump; operator-only rows report SKIP @@ -763,6 +776,45 @@ test-engine-parity-both: fi; \ [ "$$off_rc" -eq 0 ] && [ "$$on_rc" -eq 0 ] +# Inventory every configured corpus root without loading operator settings, +# dotenv or Keychain. Census always discovers historical same-stem references; +# replay decides separately whether they are admissible as quality references. +.PHONY: corpus-census +corpus-census: + @set -euo pipefail; \ + root_args=(); \ + for root in $(CORPUS_ROOTS); do root_args+=(--root "$$root"); done; \ + CODESCRIBE_DISABLE_KEYCHAIN=1 cargo run --quiet --bin codescribe-corpus -- census \ + "$${root_args[@]}" \ + --include-historical \ + --out "$(CORPUS_OUT)/census.json" + +# Production PCM-session replay. One recording x the two core arms is the safe +# default; expand CORPUS_PROFILES / CORPUS_MAX_RECORDINGS deliberately for a +# retained matrix. Each profile runs in a fresh process and isolated data root. +.PHONY: test-corpus-parity +test-corpus-parity: + @set -euo pipefail; \ + root_args=(); \ + max_args=(); \ + if [ ! -x "$(CORPUS_APPLE_BRIDGE)" ]; then \ + printf 'corpus parity refused: signed Apple STT bridge is not executable: %s\n' "$(CORPUS_APPLE_BRIDGE)" >&2; \ + exit 2; \ + fi; \ + for root in $(CORPUS_ROOTS); do root_args+=(--root "$$root"); done; \ + if [ -n "$(strip $(CORPUS_MAX_RECORDINGS))" ]; then \ + max_args+=(--max-recordings "$(CORPUS_MAX_RECORDINGS)"); \ + fi; \ + CODESCRIBE_DISABLE_KEYCHAIN=1 cargo run --quiet --bin codescribe-corpus -- run \ + "$${root_args[@]}" \ + "$${max_args[@]}" \ + --out-dir "$(CORPUS_OUT)" \ + --profiles "$(CORPUS_PROFILES)" \ + --runs "$(CORPUS_RUNS)" \ + --references "$(CORPUS_REFERENCE_POLICY)" \ + --apple-bridge "$(CORPUS_APPLE_BRIDGE)" \ + --commit "$$(git rev-parse HEAD)" + # Host smoke for the macOS surfaces we own — run after every OS/Xcode bump. # Headless, raises no TCC dialog, posts no synthetic events; operator-only rows # report SKIP instead of passing quietly. SMOKE_ARGS='--with-inference' adds the @@ -1160,6 +1212,8 @@ help: @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-engine-apple' 'Apple live multi-utterance e2e (ENGINE_CLIP / ENGINE_ALL_CLIPS=1)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-engine-candle' 'Candle live multi-utterance e2e (same engine bar)' @printf '%s\n' ' make test-engine-parity-both Both parity arms + delta (needs the private corpus)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'corpus-census' 'Inventory both private corpus roots; hashes/counts only' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-corpus-parity' 'Isolated production replay (profiles/runs/recordings are explicit vars)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-teacher' 'Teacher CLI proof HTML (live×whisper×human)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-all' 'Run full test suite' diff --git a/bin/codescribe-corpus.rs b/bin/codescribe-corpus.rs new file mode 100644 index 00000000..dc231fc8 --- /dev/null +++ b/bin/codescribe-corpus.rs @@ -0,0 +1,1969 @@ +//! Unattended corpus census and production-overlay replay. +//! +//! This tool never loads the operator's `settings.json` or `.env`. Matrix +//! profiles run in fresh child processes with an isolated +//! `CODESCRIBE_DATA_DIR`; audio is the only substituted production boundary. +//! Machine reports contain hashes, counts and scores, never filenames or +//! transcript bodies. A separate private Qube HTML contains transcript bodies +//! and opaque audio links for local operator review. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt::Write as _; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, ExitCode, Stdio}; +use std::str::FromStr; +use std::time::Instant; + +#[cfg(unix)] +use std::os::unix::fs::{PermissionsExt, symlink}; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::Utc; +use clap::{Parser, Subcommand, ValueEnum}; +use codescribe::controller::production_replay::{ProductionReplayLane, replay_overlay_recording}; +use codescribe::qube_report::{ + LocalTranscriptionMode, MetricsReference, QualityReport, QualityReportConfig, ReportEntry, + ReportEnvironment, ReportMetrics, ReportSummary, ReportTranscriptSemantics, + ReportTranscriptState, ReportTranscripts, render_html as render_qube_html, +}; +use codescribe_core::asr_session::GatewaySessionAvailability; +use codescribe_core::config::UserSettings; +use codescribe_core::pipeline::contracts::{EngineEvent, LayerSource}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const REPORT_SCHEMA: &str = "codescribe-corpus-parity/v2"; +const AUDIO_EXTENSIONS: [&str; 3] = ["wav", "m4a", "mp3"]; +const CONTROLLED_ENV: [&str; 14] = [ + "CODESCRIBE_STT_ENGINE", + "CODESCRIBE_LAYERED_TRANSCRIPTION", + "STT_TAIL_PROVIDER", + "CODESCRIBE_SILERO_FUSION", + "CODESCRIBE_SILERO_FUSION_CONTEXT", + "CODESCRIBE_SPAN_IDEMPOTENCE", + "CODESCRIBE_INLINE_FORMAT", + "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", + "FINAL_PASS_MODE", + "CODESCRIBE_FINAL_PASS_MODE", + "CODESCRIBE_LOCAL_STT_FINAL_PASS", + "CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", + "CODESCRIBE_APPLE_STT_BRIDGE", + "CODESCRIBE_BRIDGE_DISCLAIM", +]; + +#[derive(Debug, Parser)] +#[command( + name = "codescribe-corpus", + about = "Private corpus census and production-overlay replay", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Inventory audio and reference classes without running STT. + Census { + /// Corpus root. Repeat to combine roots. + #[arg(long = "root", required = true)] + roots: Vec, + /// JSON report path. + #[arg(long)] + out: PathBuf, + /// Treat same-stem TXT files as historical references. + #[arg(long)] + include_historical: bool, + }, + /// Run one or more isolated replay profiles and write a retained report. + Run { + /// Corpus root. Repeat to combine roots. + #[arg(long = "root", required = true)] + roots: Vec, + /// Durable report directory. + #[arg(long)] + out_dir: PathBuf, + /// Comma-separated profile names. + #[arg( + long, + value_delimiter = ',', + default_value = "apple-layer0,apple-layer1-inprocess" + )] + profiles: Vec, + /// Independent executions per distinct recording and profile. + #[arg(long, default_value_t = 1)] + runs: usize, + /// Reference selection policy. + #[arg(long, value_enum, default_value_t = ReferencePolicy::Human)] + references: ReferencePolicy, + /// Bound the selected distinct recordings after stable hash sort. + #[arg(long)] + max_recordings: Option, + /// Recognition language pin. + #[arg(long, default_value = "pl")] + language: String, + /// Exact source commit claimed by this binary invocation. + #[arg(long)] + commit: String, + /// Exact signed Apple STT bridge artifact used by every worker. + #[arg(long)] + apple_bridge: PathBuf, + }, + /// Internal one-profile worker. Fresh process = fresh runtime globals. + #[command(hide = true)] + Worker { + #[arg(long = "root", required = true)] + roots: Vec, + #[arg(long)] + out: PathBuf, + #[arg(long)] + profile: ReplayProfile, + #[arg(long, default_value_t = 1)] + runs: usize, + #[arg(long, value_enum)] + references: ReferencePolicy, + #[arg(long)] + max_recordings: Option, + #[arg(long)] + language: String, + #[arg(long)] + commit: String, + #[arg(long)] + apple_bridge: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ReferencePolicy { + /// Only explicit `_human_transcription.txt` siblings are quality truth. + Human, + /// Prefer explicit human truth, then admit same-stem historical TXT. + HumanAndHistorical, +} + +impl ReferencePolicy { + const fn as_str(self) -> &'static str { + match self { + Self::Human => "human", + Self::HumanAndHistorical => "human_and_historical", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ReplayProfile { + /// Apple canvas + lexicon, Layer 1 disarmed. + AppleLayer0, + /// Apple canvas + in-process Whisper partials + lexicon. + AppleLayer1Inprocess, + /// Apple canvas + sidecar Whisper partials + lexicon. + AppleLayer1Sidecar, + /// Apple canvas + remote Whisper partials + lexicon. + AppleLayer1Remote, + /// Layer 1 plus Silero utterance identity, utterance-only context. + AppleLayer1FusionUtterance, + /// Layer 1 plus Silero identity and bounded left-audio context. + AppleLayer1FusionLeftPad, + /// Layer 1 plus Silero identity and stable-text prompt context. + AppleLayer1FusionStablePrompt, + /// Fusion plus sealed-span replay/idempotence fence. + AppleLayer1FusionIdempotent, + /// Layer 1 live transcript plus the opt-in local final-pass stop lane. + AppleLayer1LocalFinalPass, +} + +impl ReplayProfile { + const fn token(self) -> &'static str { + match self { + Self::AppleLayer0 => "apple-layer0", + Self::AppleLayer1Inprocess => "apple-layer1-inprocess", + Self::AppleLayer1Sidecar => "apple-layer1-sidecar", + Self::AppleLayer1Remote => "apple-layer1-remote", + Self::AppleLayer1FusionUtterance => "apple-layer1-fusion-utterance", + Self::AppleLayer1FusionLeftPad => "apple-layer1-fusion-left-pad", + Self::AppleLayer1FusionStablePrompt => "apple-layer1-fusion-stable-prompt", + Self::AppleLayer1FusionIdempotent => "apple-layer1-fusion-idempotent", + Self::AppleLayer1LocalFinalPass => "apple-layer1-local-final-pass", + } + } + + const fn layered(self) -> bool { + !matches!(self, Self::AppleLayer0) + } + + const fn tail_provider(self) -> &'static str { + match self { + Self::AppleLayer1Sidecar => "sidecar", + Self::AppleLayer1Remote => "remote", + _ => "inprocess", + } + } + + const fn fusion(self) -> bool { + matches!( + self, + Self::AppleLayer1FusionUtterance + | Self::AppleLayer1FusionLeftPad + | Self::AppleLayer1FusionStablePrompt + | Self::AppleLayer1FusionIdempotent + ) + } + + const fn fusion_context(self) -> &'static str { + match self { + Self::AppleLayer1FusionLeftPad => "left_pad", + Self::AppleLayer1FusionStablePrompt => "stable_prompt", + _ => "utterance_only", + } + } + + const fn idempotence(self) -> bool { + matches!(self, Self::AppleLayer1FusionIdempotent) + } + + const fn stop_lane(self) -> ProductionReplayLane { + match self { + Self::AppleLayer1LocalFinalPass => ProductionReplayLane::LocalFinalPass, + _ => ProductionReplayLane::AppleLexicon, + } + } +} + +impl std::fmt::Display for ReplayProfile { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.token()) + } +} + +impl FromStr for ReplayProfile { + type Err = String; + + fn from_str(raw: &str) -> std::result::Result { + let normalized = raw.trim().replace('_', "-"); + [ + Self::AppleLayer0, + Self::AppleLayer1Inprocess, + Self::AppleLayer1Sidecar, + Self::AppleLayer1Remote, + Self::AppleLayer1FusionUtterance, + Self::AppleLayer1FusionLeftPad, + Self::AppleLayer1FusionStablePrompt, + Self::AppleLayer1FusionIdempotent, + Self::AppleLayer1LocalFinalPass, + ] + .into_iter() + .find(|profile| profile.token() == normalized) + .ok_or_else(|| format!("unknown replay profile {raw:?}")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ReferenceKind { + Human, + HistoricalSameStem, +} + +impl ReferenceKind { + const fn rank(self) -> u8 { + match self { + Self::Human => 0, + Self::HistoricalSameStem => 1, + } + } +} + +#[derive(Debug, Clone)] +struct Reference { + path: PathBuf, + sha256: String, + kind: ReferenceKind, +} + +#[derive(Debug, Clone)] +struct Clip { + path: PathBuf, + sha256: String, + reference: Option, + has_apple_reference: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CorpusCensus { + schema: String, + generated_at: String, + root_count: usize, + discovered_audio_instances: usize, + distinct_audio: usize, + duplicate_instances: usize, + format_instances: BTreeMap, + human_reference_instances: usize, + historical_reference_instances: usize, + apple_reference_instances: usize, + distinct_human_paired: usize, + distinct_historical_paired: usize, + distinct_apple_referenced: usize, + distinct_unpaired: usize, + selected_distinct: usize, + reference_policy: String, + privacy: PrivacyContract, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PrivacyContract { + source_paths_emitted: bool, + source_filenames_emitted: bool, + transcript_bodies_emitted: bool, + opaque_ids_are_hash_prefixes: bool, +} + +impl Default for PrivacyContract { + fn default() -> Self { + Self { + source_paths_emitted: false, + source_filenames_emitted: false, + transcript_bodies_emitted: false, + opaque_ids_are_hash_prefixes: true, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileReport { + schema: String, + generated_at: String, + commit: String, + profile: ReplayProfile, + reference_policy: String, + corpus: CorpusCensus, + distinct_recordings: usize, + requested_runs_per_recording: usize, + requested_executions: usize, + successful_executions: usize, + failed_executions: usize, + total_audio_seconds_executed: f64, + total_tail_patches: usize, + requested_layered: bool, + observed_layered: bool, + profile_observation_matches: bool, + mean_wer: Option, + mean_cer: Option, + mean_character_parity: Option, + input_hashes_unchanged: bool, + settings_loaded: bool, + dotenv_loaded: bool, + keychain_disabled: bool, + apple_stt_bridge: FileFingerprint, + quality_html: String, + rows: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ExecutionRow { + opaque_id: String, + run: usize, + audio_sha256: String, + reference_sha256: String, + reference_kind: ReferenceKind, + duration_seconds: f64, + sample_rate_hz: u32, + status: String, + error_class: Option, + wall_seconds: f64, + events: usize, + previews: usize, + sealed_finals: usize, + final_count: usize, + unique_final_id_count: usize, + repeated_final_id_count: usize, + overlapping_final_window_count: usize, + tail_patches: usize, + layer1_provider_armed: bool, + live_chars: usize, + adjudicated_chars: usize, + delivered_chars: usize, + reference_tokens: usize, + delivered_tokens: usize, + token_ratio: f64, + head_present: bool, + tail_present: bool, + wer: f64, + cer: f64, + character_parity: f64, + teacher_similarity: f64, + final_pass_attempted: bool, + final_pass_skipped: bool, + lexicon_rewrites: u64, + gate_drops: u64, + audio_hash_unchanged: bool, + reference_hash_unchanged: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct FileFingerprint { + label: String, + exists: bool, + sha256: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileStatus { + profile: ReplayProfile, + worker_exit: Option, + report_present: bool, + successful_executions: usize, + failed_executions: usize, + observed_layered: Option, + mean_wer: Option, + mean_cer: Option, + mean_character_parity: Option, + quality_html: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct MatrixReport { + schema: String, + generated_at: String, + commit: String, + corpus: CorpusCensus, + requested_profiles: usize, + completed_profiles: usize, + distinct_recordings: usize, + requested_runs_per_recording: usize, + requested_executions: usize, + successful_executions: usize, + failed_executions: usize, + profile_status: Vec, + configuration_files_before: Vec, + configuration_files_after: Vec, + configuration_files_unchanged: bool, + operator_settings_loaded: bool, + operator_dotenv_loaded: bool, + keychain_disabled: bool, + apple_stt_bridge: FileFingerprint, + permission_request_apis_called_by_tool: bool, + tcc_database_inspected: bool, + permission_state_proven_unchanged: bool, + quality_gate: &'static str, + coverage: CoverageContract, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CoverageContract { + production_pcm_session_replay: &'static str, + production_stop_adjudication: &'static str, + production_lexicon_delivery: &'static str, + coreaudio_microphone_capture: &'static str, + blackhole_loopback_capture: &'static str, + hold_toggle_hotkey_modes: &'static str, + clipboard_paste_and_target_app: &'static str, + cloud_gateway: &'static str, + inline_llm_formatting: &'static str, + tcc_permissions: &'static str, +} + +impl Default for CoverageContract { + fn default() -> Self { + Self { + production_pcm_session_replay: "covered", + production_stop_adjudication: "covered", + production_lexicon_delivery: "covered", + coreaudio_microphone_capture: "not_covered_by_file_replay", + blackhole_loopback_capture: "not_covered_by_file_replay", + hold_toggle_hotkey_modes: "not_covered_by_file_replay", + clipboard_paste_and_target_app: "not_covered_by_file_replay", + cloud_gateway: "covered_only_when_apple_layer1_remote_is_requested_and_configured", + inline_llm_formatting: "not_covered_by_current_replay_seam", + tcc_permissions: "not_mutated_or_fully_verified", + } + } +} + +fn main() -> ExitCode { + // SAFETY: this is the first executable statement, before Clap parsing, + // runtime construction or thread creation. Corpus tooling must be unable + // to read, write or prompt for the operator's production Keychain even if + // a future replay dependency unexpectedly reaches Config/secret code. + unsafe { + std::env::set_var("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + } + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("codescribe-corpus: {error:#}"); + ExitCode::from(2) + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Census { + roots, + out, + include_historical, + } => { + let policy = if include_historical { + ReferencePolicy::HumanAndHistorical + } else { + ReferencePolicy::Human + }; + let discovery = discover_corpus(&roots, policy, None)?; + atomic_write_json(&out, &discovery.census)?; + atomic_write( + &markdown_sibling(&out), + census_markdown(&discovery.census).as_bytes(), + )?; + println!( + "corpus census: instances={} distinct={} selected={} report={}", + discovery.census.discovered_audio_instances, + discovery.census.distinct_audio, + discovery.census.selected_distinct, + out.display() + ); + Ok(()) + } + Command::Run { + roots, + out_dir, + profiles, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + } => run_matrix(MatrixArgs { + roots, + out_dir, + profiles, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + }), + Command::Worker { + roots, + out, + profile, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + } => { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("build replay runtime")?; + runtime.block_on(run_worker(WorkerArgs { + roots, + out, + profile, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + })) + } + } +} + +struct Discovery { + census: CorpusCensus, + selected: Vec, +} + +fn discover_corpus( + roots: &[PathBuf], + policy: ReferencePolicy, + max_recordings: Option, +) -> Result { + if roots.is_empty() { + bail!("at least one corpus root is required"); + } + let mut instances = Vec::new(); + for root in roots { + if !root.is_dir() { + bail!("corpus root is not a directory: {}", root.display()); + } + walk_audio(root, &mut instances)?; + } + instances.sort(); + + let mut format_instances = BTreeMap::new(); + let mut human_reference_instances = 0usize; + let mut historical_reference_instances = 0usize; + let mut apple_reference_instances = 0usize; + let mut distinct = BTreeMap::::new(); + + for path in &instances { + let extension = lower_extension(path).unwrap_or_else(|| "unknown".to_string()); + *format_instances.entry(extension.clone()).or_insert(0) += 1; + let audio_sha256 = sha256_file(path)?; + let human_path = reference_path(path, "_human_transcription.txt"); + let historical_path = path.with_extension("txt"); + let apple_path = reference_path(path, "_apple_live_reference.txt"); + let human = human_path.filter(|candidate| candidate.is_file()); + let historical = historical_path.is_file().then_some(historical_path); + let has_apple_reference = apple_path.is_some_and(|candidate| candidate.is_file()); + human_reference_instances += usize::from(human.is_some()); + historical_reference_instances += usize::from(historical.is_some()); + apple_reference_instances += usize::from(has_apple_reference); + + let reference = if let Some(reference_path) = human { + Some(Reference { + sha256: sha256_file(&reference_path)?, + path: reference_path, + kind: ReferenceKind::Human, + }) + } else if matches!(policy, ReferencePolicy::HumanAndHistorical) { + if let Some(reference_path) = historical { + Some(Reference { + sha256: sha256_file(&reference_path)?, + path: reference_path, + kind: ReferenceKind::HistoricalSameStem, + }) + } else { + None + } + } else { + None + }; + + let incoming = Clip { + path: path.clone(), + sha256: audio_sha256.clone(), + reference, + has_apple_reference, + }; + match distinct.get_mut(&audio_sha256) { + Some(existing) => merge_duplicate(existing, incoming)?, + None => { + distinct.insert(audio_sha256, incoming); + } + } + } + + let distinct_audio = distinct.len(); + let distinct_human_paired = distinct + .values() + .filter(|clip| { + matches!( + clip.reference.as_ref().map(|reference| reference.kind), + Some(ReferenceKind::Human) + ) + }) + .count(); + let distinct_historical_paired = distinct + .values() + .filter(|clip| { + matches!( + clip.reference.as_ref().map(|reference| reference.kind), + Some(ReferenceKind::HistoricalSameStem) + ) + }) + .count(); + let distinct_apple_referenced = distinct + .values() + .filter(|clip| clip.has_apple_reference) + .count(); + let distinct_unpaired = distinct + .values() + .filter(|clip| clip.reference.is_none()) + .count(); + let mut selected = distinct + .into_values() + .filter(|clip| clip.reference.is_some()) + .collect::>(); + selected.sort_by(|left, right| left.sha256.cmp(&right.sha256)); + if let Some(limit) = max_recordings { + selected.truncate(limit); + } + + let census = CorpusCensus { + schema: REPORT_SCHEMA.to_string(), + generated_at: Utc::now().to_rfc3339(), + root_count: roots.len(), + discovered_audio_instances: instances.len(), + distinct_audio, + duplicate_instances: instances.len().saturating_sub(distinct_audio), + format_instances, + human_reference_instances, + historical_reference_instances, + apple_reference_instances, + distinct_human_paired, + distinct_historical_paired, + distinct_apple_referenced, + distinct_unpaired, + selected_distinct: selected.len(), + reference_policy: policy.as_str().to_string(), + privacy: PrivacyContract::default(), + }; + Ok(Discovery { census, selected }) +} + +fn walk_audio(directory: &Path, output: &mut Vec) -> Result<()> { + let mut entries = fs::read_dir(directory) + .with_context(|| format!("read corpus directory {}", directory.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let file_type = entry.file_type()?; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + walk_audio(&path, output)?; + } else if file_type.is_file() && is_audio(&path) { + output.push(path); + } + } + Ok(()) +} + +fn is_audio(path: &Path) -> bool { + lower_extension(path).is_some_and(|extension| AUDIO_EXTENSIONS.contains(&extension.as_str())) +} + +fn lower_extension(path: &Path) -> Option { + path.extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) +} + +fn reference_path(audio: &Path, suffix: &str) -> Option { + let stem = audio.file_stem()?.to_str()?; + Some(audio.parent()?.join(format!("{stem}{suffix}"))) +} + +fn merge_duplicate(existing: &mut Clip, incoming: Clip) -> Result<()> { + existing.has_apple_reference |= incoming.has_apple_reference; + match (&existing.reference, &incoming.reference) { + (Some(left), Some(right)) if left.kind == right.kind && left.sha256 != right.sha256 => { + bail!( + "duplicate audio hash has conflicting {:?} reference hashes", + left.kind + ); + } + (Some(left), Some(right)) if right.kind.rank() < left.kind.rank() => { + existing.reference = Some(right.clone()); + } + (None, Some(reference)) => existing.reference = Some(reference.clone()), + _ => {} + } + Ok(()) +} + +struct MatrixArgs { + roots: Vec, + out_dir: PathBuf, + profiles: Vec, + runs: usize, + references: ReferencePolicy, + max_recordings: Option, + language: String, + commit: String, + apple_bridge: PathBuf, +} + +fn run_matrix(args: MatrixArgs) -> Result<()> { + if args.runs == 0 { + bail!("--runs must be greater than zero"); + } + if args.profiles.is_empty() { + bail!("at least one replay profile is required"); + } + let apple_stt_bridge = fingerprint_file("apple_stt_bridge", &args.apple_bridge)?; + if !apple_stt_bridge.exists { + bail!("--apple-bridge must name an existing file"); + } + fs::create_dir_all(&args.out_dir) + .with_context(|| format!("create report directory {}", args.out_dir.display()))?; + let discovery = discover_corpus(&args.roots, args.references, args.max_recordings)?; + if discovery.selected.is_empty() { + bail!( + "no recordings match reference policy {}", + args.references.as_str() + ); + } + + let config_before = operator_configuration_fingerprints()?; + let current_exe = std::env::current_exe().context("resolve corpus runner executable")?; + let mut profile_status = Vec::with_capacity(args.profiles.len()); + let mut completed_profiles = 0usize; + let mut successful_executions = 0usize; + let mut failed_executions = 0usize; + + for profile in &args.profiles { + let profile_out = args + .out_dir + .join(format!("profile-{}.json", profile.token())); + let runtime_dir = args.out_dir.join("runtime").join(profile.token()); + fs::create_dir_all(&runtime_dir)?; + let mut child = ProcessCommand::new(¤t_exe); + child + .arg("worker") + .arg("--out") + .arg(&profile_out) + .arg("--profile") + .arg(profile.token()) + .arg("--runs") + .arg(args.runs.to_string()) + .arg("--references") + .arg(args.references.as_str().replace('_', "-")) + .arg("--language") + .arg(&args.language) + .arg("--commit") + .arg(&args.commit) + .arg("--apple-bridge") + .arg(&args.apple_bridge) + .env("CODESCRIBE_DATA_DIR", &runtime_dir) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(limit) = args.max_recordings { + child.arg("--max-recordings").arg(limit.to_string()); + } + for root in &args.roots { + child.arg("--root").arg(root); + } + configure_profile_environment(&mut child, *profile, &args.apple_bridge); + let status = child + .status() + .with_context(|| format!("launch profile {}", profile.token()))?; + let report = fs::read(&profile_out) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if report.is_some() { + completed_profiles += 1; + } + let successful = report + .as_ref() + .map_or(0, |profile_report| profile_report.successful_executions); + let failed = report + .as_ref() + .map_or(discovery.selected.len() * args.runs, |profile_report| { + profile_report.failed_executions + }); + successful_executions += successful; + failed_executions += failed; + profile_status.push(ProfileStatus { + profile: *profile, + worker_exit: status.code(), + report_present: report.is_some(), + successful_executions: successful, + failed_executions: failed, + observed_layered: report + .as_ref() + .map(|profile_report| profile_report.observed_layered), + mean_wer: report + .as_ref() + .and_then(|profile_report| profile_report.mean_wer), + mean_cer: report + .as_ref() + .and_then(|profile_report| profile_report.mean_cer), + mean_character_parity: report + .as_ref() + .and_then(|profile_report| profile_report.mean_character_parity), + quality_html: report + .as_ref() + .map(|profile_report| profile_report.quality_html.clone()), + }); + } + + let config_after = operator_configuration_fingerprints()?; + let config_unchanged = config_before == config_after; + let requested_executions = discovery.selected.len() * args.runs * args.profiles.len(); + let matrix = MatrixReport { + schema: REPORT_SCHEMA.to_string(), + generated_at: Utc::now().to_rfc3339(), + commit: args.commit, + corpus: discovery.census, + requested_profiles: args.profiles.len(), + completed_profiles, + distinct_recordings: discovery.selected.len(), + requested_runs_per_recording: args.runs, + requested_executions, + successful_executions, + failed_executions, + profile_status, + configuration_files_before: config_before, + configuration_files_after: config_after, + configuration_files_unchanged: config_unchanged, + operator_settings_loaded: false, + operator_dotenv_loaded: false, + keychain_disabled: true, + apple_stt_bridge, + permission_request_apis_called_by_tool: false, + tcc_database_inspected: false, + permission_state_proven_unchanged: false, + quality_gate: "measurement_only_operator_decides", + coverage: CoverageContract::default(), + }; + let report_path = args.out_dir.join("report.json"); + atomic_write_json(&report_path, &matrix)?; + atomic_write( + &args.out_dir.join("report.md"), + matrix_markdown(&matrix).as_bytes(), + )?; + println!( + "corpus parity: distinct={} executions={}/{} config_unchanged={} report={}", + matrix.distinct_recordings, + matrix.successful_executions, + matrix.requested_executions, + matrix.configuration_files_unchanged, + report_path.display() + ); + if completed_profiles != args.profiles.len() { + bail!( + "{} of {} profile workers failed to publish a report", + args.profiles.len() - completed_profiles, + args.profiles.len() + ); + } + Ok(()) +} + +fn configure_profile_environment( + command: &mut ProcessCommand, + profile: ReplayProfile, + apple_bridge: &Path, +) { + for key in CONTROLLED_ENV { + command.env_remove(key); + } + if !matches!(profile, ReplayProfile::AppleLayer1Remote) { + command.env_remove("STT_API_KEY"); + command.env_remove("STT_ENDPOINT"); + command.env_remove("CODESCRIBE_STT_ENDPOINT"); + } + command + .env("CODESCRIBE_DISABLE_KEYCHAIN", "1") + .env("CODESCRIBE_STT_ENGINE", "apple") + .env("CODESCRIBE_APPLE_STT_BRIDGE", apple_bridge) + .env("CODESCRIBE_BRIDGE_DISCLAIM", "1") + .env( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + if profile.layered() { "phase1" } else { "off" }, + ) + .env("STT_TAIL_PROVIDER", profile.tail_provider()) + .env( + "CODESCRIBE_SILERO_FUSION", + if profile.fusion() { "on" } else { "off" }, + ) + .env("CODESCRIBE_SILERO_FUSION_CONTEXT", profile.fusion_context()) + .env( + "CODESCRIBE_SPAN_IDEMPOTENCE", + if profile.idempotence() { "on" } else { "off" }, + ) + .env("CODESCRIBE_INLINE_FORMAT", "off") + .env("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", "off") + .env("CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", "0") + .env( + "FINAL_PASS_MODE", + if matches!(profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "always" + } else { + "off" + }, + ) + .env( + "CODESCRIBE_LOCAL_STT_FINAL_PASS", + if matches!(profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "1" + } else { + "0" + }, + ); +} + +struct WorkerArgs { + roots: Vec, + out: PathBuf, + profile: ReplayProfile, + runs: usize, + references: ReferencePolicy, + max_recordings: Option, + language: String, + commit: String, + apple_bridge: PathBuf, +} + +async fn run_worker(args: WorkerArgs) -> Result<()> { + if args.runs == 0 { + bail!("--runs must be greater than zero"); + } + validate_worker_environment(args.profile, &args.apple_bridge)?; + codescribe_core::stt::apple_stt::ensure_noninteractive_ready(Some(&args.language)) + .context("noninteractive Apple STT preflight")?; + let discovery = discover_corpus(&args.roots, args.references, args.max_recordings)?; + if discovery.selected.is_empty() { + bail!("worker selected no recordings"); + } + + let mut settings = UserSettings { + stt_engine: Some("apple".to_string()), + layered_transcription: Some(if args.profile.layered() { + "phase1".to_string() + } else { + "off".to_string() + }), + final_pass_mode: Some( + if matches!(args.profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "always" + } else { + "off" + } + .to_string(), + ), + ..UserSettings::default() + }; + // The cloud product session is a separate gateway surface. Tail-patch + // provider profiles are controlled by the explicit process environment. + settings.asr_mode = Some("apple_only".to_string()); + + let output_root = args + .out + .parent() + .ok_or_else(|| anyhow!("profile report path has no parent"))?; + let quality_dir = output_root.join("quality"); + let quality_audio_dir = quality_dir.join("audio"); + fs::create_dir_all(&quality_audio_dir).context("create private quality report directory")?; + make_private_directory(&quality_dir)?; + make_private_directory(&quality_audio_dir)?; + + let mut rows = Vec::with_capacity(discovery.selected.len() * args.runs); + let mut quality_entries = Vec::with_capacity(discovery.selected.len() * args.runs); + let mut total_audio_seconds_executed = 0.0; + for clip in &discovery.selected { + let reference = clip + .reference + .as_ref() + .expect("selected clips have references"); + let truth = fs::read_to_string(&reference.path).context("read paired reference")?; + let (samples, sample_rate) = codescribe_core::audio::load_audio_file(&clip.path) + .map_err(|_| anyhow!("decode replay audio failed"))?; + if samples.is_empty() || sample_rate == 0 { + bail!("selected replay audio is empty or has zero sample rate"); + } + let quality_audio_rel = publish_quality_audio(&quality_audio_dir, clip)?; + let duration_seconds = samples.len() as f64 / f64::from(sample_rate); + eprintln!( + "corpus replay selected: profile={} recording={} duration_seconds={duration_seconds:.3} runs={}", + args.profile.token(), + opaque_id(&clip.sha256), + args.runs + ); + for run in 1..=args.runs { + let started = Instant::now(); + let replay = replay_overlay_recording( + &clip.path, + Some(args.language.clone()), + &settings, + GatewaySessionAvailability::Unavailable, + args.profile.stop_lane(), + ) + .await; + let wall_seconds = started.elapsed().as_secs_f64(); + total_audio_seconds_executed += duration_seconds; + match replay { + Ok(replay) => { + quality_entries.push(success_quality_entry( + clip, + reference, + &truth, + run, + args.profile, + duration_seconds, + &quality_audio_rel, + &replay, + )); + rows.push(success_row( + clip, + reference, + &truth, + run, + duration_seconds, + sample_rate, + wall_seconds, + replay, + )?); + } + Err(error) => { + quality_entries.push(failure_quality_entry( + clip, + reference, + &truth, + run, + args.profile, + duration_seconds, + &quality_audio_rel, + &format!("{error:#}"), + )); + rows.push(failure_row( + clip, + reference, + run, + duration_seconds, + sample_rate, + wall_seconds, + )?); + } + } + eprintln!( + "corpus replay: profile={} recording={} run={}/{} status={}", + args.profile.token(), + opaque_id(&clip.sha256), + run, + args.runs, + rows.last().map_or("missing", |row| row.status.as_str()) + ); + } + } + + let successful = rows.iter().filter(|row| row.status == "ok").count(); + let failed = rows.len() - successful; + let total_tail_patches = rows.iter().map(|row| row.tail_patches).sum(); + let observed_layered = total_tail_patches > 0; + let successful_rows = rows + .iter() + .filter(|row| row.status == "ok") + .collect::>(); + let mean_wer = mean(successful_rows.iter().map(|row| row.wer)); + let mean_cer = mean(successful_rows.iter().map(|row| row.cer)); + let mean_character_parity = mean(successful_rows.iter().map(|row| row.character_parity)); + let input_hashes_unchanged = rows + .iter() + .all(|row| row.audio_hash_unchanged && row.reference_hash_unchanged); + let quality_html = format!("quality/{}.html", args.profile.token()); + let quality_report = build_quality_report( + args.profile, + &args.language, + args.references, + quality_entries, + ); + let quality_config = QualityReportConfig { + input_dir: args.roots[0].clone(), + output_dir: quality_dir.clone(), + date_filter: None, + limit: 0, + language: Some(args.language.clone()), + skip_cloud: true, + cloud_concurrency: 0, + skip_formatting: true, + debug_mode: true, + copy_audio: false, + metrics_reference: MetricsReference::Corpus, + local_transcription: LocalTranscriptionMode::LocalWhisper, + }; + atomic_write_private( + &output_root.join(&quality_html), + render_qube_html(&quality_report, &quality_config).as_bytes(), + )?; + + let report = ProfileReport { + schema: REPORT_SCHEMA.to_string(), + generated_at: Utc::now().to_rfc3339(), + commit: args.commit, + profile: args.profile, + reference_policy: args.references.as_str().to_string(), + corpus: discovery.census, + distinct_recordings: discovery.selected.len(), + requested_runs_per_recording: args.runs, + requested_executions: discovery.selected.len() * args.runs, + successful_executions: successful, + failed_executions: failed, + total_audio_seconds_executed, + total_tail_patches, + requested_layered: args.profile.layered(), + observed_layered, + profile_observation_matches: observed_layered == args.profile.layered(), + mean_wer, + mean_cer, + mean_character_parity, + input_hashes_unchanged, + settings_loaded: false, + dotenv_loaded: false, + keychain_disabled: true, + apple_stt_bridge: fingerprint_file("apple_stt_bridge", &args.apple_bridge)?, + quality_html, + rows, + }; + atomic_write_json(&args.out, &report)?; + Ok(()) +} + +fn publish_quality_audio(quality_audio_dir: &Path, clip: &Clip) -> Result { + let extension = clip + .path + .extension() + .and_then(OsStr::to_str) + .unwrap_or("wav") + .to_ascii_lowercase(); + let file_name = format!("{}.{}", opaque_id(&clip.sha256), extension); + let published = quality_audio_dir.join(&file_name); + if fs::symlink_metadata(&published).is_ok() { + let existing = fs::canonicalize(&published) + .with_context(|| format!("resolve quality audio link {}", published.display()))?; + let expected = fs::canonicalize(&clip.path) + .with_context(|| format!("resolve corpus audio {}", clip.path.display()))?; + if existing != expected { + bail!( + "quality audio link collision for {}", + opaque_id(&clip.sha256) + ); + } + } else { + symlink(&clip.path, &published) + .with_context(|| format!("publish private quality audio {}", published.display()))?; + } + Ok(format!("audio/{file_name}")) +} + +fn success_quality_entry( + clip: &Clip, + _reference: &Reference, + truth: &str, + run: usize, + profile: ReplayProfile, + duration_seconds: f64, + audio_rel_path: &str, + replay: &codescribe::controller::production_replay::ProductionOverlayReplay, +) -> ReportEntry { + let raw_wer = word_error_rate(truth, &replay.live_text) as f32; + let raw_cer = character_error_rate(truth, &replay.live_text) as f32; + let post_wer = word_error_rate(truth, &replay.delivered_text) as f32; + let post_cer = character_error_rate(truth, &replay.delivered_text) as f32; + let raw_state = if replay.live_text.trim().is_empty() { + ReportTranscriptState::EmptyTranscript + } else { + ReportTranscriptState::TextCommitted + }; + ReportEntry { + id: format!("{}-run{run}-{}", opaque_id(&clip.sha256), profile.token()), + audio_path: opaque_id(&clip.sha256), + audio_rel_path: audio_rel_path.to_string(), + reference_path: None, + duration_secs: duration_seconds as f32, + transcripts: ReportTranscripts { + raw: Some(replay.live_text.clone()), + post: Some(replay.delivered_text.clone()), + ai_formatted: None, + cloud: None, + reference: Some(truth.to_string()), + }, + raw_semantics: Some(ReportTranscriptSemantics { + state: raw_state, + reason: None, + }), + metrics: ReportMetrics { + raw_wer: Some(raw_wer), + raw_cer: Some(raw_cer), + post_wer: Some(post_wer), + post_cer: Some(post_cer), + ..ReportMetrics::default() + }, + postprocess_stats: Some(replay.postprocess_stats.clone()), + errors: Vec::new(), + } +} + +fn failure_quality_entry( + clip: &Clip, + _reference: &Reference, + truth: &str, + run: usize, + profile: ReplayProfile, + duration_seconds: f64, + audio_rel_path: &str, + error: &str, +) -> ReportEntry { + ReportEntry { + id: format!("{}-run{run}-{}", opaque_id(&clip.sha256), profile.token()), + audio_path: opaque_id(&clip.sha256), + audio_rel_path: audio_rel_path.to_string(), + reference_path: None, + duration_secs: duration_seconds as f32, + transcripts: ReportTranscripts { + reference: Some(truth.to_string()), + ..ReportTranscripts::default() + }, + raw_semantics: None, + metrics: ReportMetrics::default(), + postprocess_stats: None, + errors: vec![error.to_string()], + } +} + +fn build_quality_report( + profile: ReplayProfile, + language: &str, + references: ReferencePolicy, + entries: Vec, +) -> QualityReport { + let raw_wer = entries + .iter() + .filter_map(|entry| entry.metrics.raw_wer) + .collect::>(); + let raw_cer = entries + .iter() + .filter_map(|entry| entry.metrics.raw_cer) + .collect::>(); + let post_wer = entries + .iter() + .filter_map(|entry| entry.metrics.post_wer) + .collect::>(); + let post_cer = entries + .iter() + .filter_map(|entry| entry.metrics.post_cer) + .collect::>(); + let raw_text_committed = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::TextCommitted) + ) + }) + .count(); + let raw_no_speech_detected = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::NoSpeechDetected) + ) + }) + .count(); + let raw_quality_gate_dropped = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::QualityGateDropped) + ) + }) + .count(); + QualityReport { + generated_at: Utc::now().to_rfc3339(), + environment: ReportEnvironment { + stt_endpoint: None, + stt_api_key_present: false, + llm_formatting_endpoint: None, + llm_formatting_model: None, + llm_formatting_key_present: false, + local_model: None, + whisper_language: Some(language.to_string()), + metrics_reference: references.as_str().to_string(), + local_transcription: format!("production_overlay:{}", profile.token()), + }, + summary: ReportSummary { + total_files: entries.len(), + processed_files: entries + .iter() + .filter(|entry| entry.errors.is_empty()) + .count(), + avg_raw_wer: mean_f32(&raw_wer), + avg_post_wer: mean_f32(&post_wer), + avg_raw_cer: mean_f32(&raw_cer), + avg_post_cer: mean_f32(&post_cer), + raw_no_speech_detected, + raw_quality_gate_dropped, + raw_text_committed, + ..ReportSummary::default() + }, + entries, + } +} + +fn mean_f32(values: &[f32]) -> Option { + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f32) +} + +fn make_private_directory(path: &Path) -> Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).with_context(|| { + format!( + "set private report directory permissions {}", + path.display() + ) + }) +} + +fn validate_worker_environment(profile: ReplayProfile, apple_bridge: &Path) -> Result<()> { + let expected = [ + ("CODESCRIBE_STT_ENGINE", "apple"), + ( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + if profile.layered() { "phase1" } else { "off" }, + ), + ("STT_TAIL_PROVIDER", profile.tail_provider()), + ( + "CODESCRIBE_SILERO_FUSION", + if profile.fusion() { "on" } else { "off" }, + ), + ( + "CODESCRIBE_SPAN_IDEMPOTENCE", + if profile.idempotence() { "on" } else { "off" }, + ), + ("CODESCRIBE_INLINE_FORMAT", "off"), + ("CODESCRIBE_DISABLE_KEYCHAIN", "1"), + ("CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", "0"), + ("CODESCRIBE_BRIDGE_DISCLAIM", "1"), + ]; + for (key, value) in expected { + if std::env::var(key).as_deref() != Ok(value) { + bail!("worker environment pin mismatch for {key}"); + } + } + if std::env::var_os("CODESCRIBE_APPLE_STT_BRIDGE").as_deref() != Some(apple_bridge.as_os_str()) + { + bail!("worker Apple STT bridge pin mismatch"); + } + let data_dir = std::env::var_os("CODESCRIBE_DATA_DIR") + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("worker requires isolated CODESCRIBE_DATA_DIR"))?; + fs::create_dir_all(PathBuf::from(data_dir)).context("create isolated data directory")?; + Ok(()) +} + +fn success_row( + clip: &Clip, + reference: &Reference, + truth: &str, + run: usize, + duration_seconds: f64, + sample_rate: u32, + wall_seconds: f64, + replay: codescribe::controller::production_replay::ProductionOverlayReplay, +) -> Result { + let reference_tokens = normalized_words(truth); + let delivered_tokens = normalized_words(&replay.delivered_text); + let head_present = reference_tokens + .iter() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let tail_present = reference_tokens + .iter() + .rev() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let token_ratio = delivered_tokens.len() as f64 / reference_tokens.len().max(1) as f64; + let previews = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::Preview { .. })) + .count(); + let sealed_finals = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::UtteranceFinal { .. })) + .count(); + let tail_patches = replay + .events + .iter() + .filter(|event| { + matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) + }) + .count(); + let audio_hash_unchanged = sha256_file(&clip.path)? == clip.sha256; + let reference_hash_unchanged = sha256_file(&reference.path)? == reference.sha256; + Ok(ExecutionRow { + opaque_id: opaque_id(&clip.sha256), + run, + audio_sha256: clip.sha256.clone(), + reference_sha256: reference.sha256.clone(), + reference_kind: reference.kind, + duration_seconds, + sample_rate_hz: sample_rate, + status: "ok".to_string(), + error_class: None, + wall_seconds, + events: replay.events.len(), + previews, + sealed_finals, + final_count: replay.boundary_evidence.final_count, + unique_final_id_count: replay.boundary_evidence.unique_final_id_count, + repeated_final_id_count: replay.boundary_evidence.repeated_final_id_count, + overlapping_final_window_count: replay.boundary_evidence.overlapping_final_window_count, + tail_patches, + layer1_provider_armed: replay.layer1_armed, + live_chars: replay.live_text.chars().count(), + adjudicated_chars: replay.adjudicated_text.chars().count(), + delivered_chars: replay.delivered_text.chars().count(), + reference_tokens: reference_tokens.len(), + delivered_tokens: delivered_tokens.len(), + token_ratio, + head_present, + tail_present, + wer: word_error_rate(truth, &replay.delivered_text), + cer: character_error_rate(truth, &replay.delivered_text), + character_parity: normalized_character_parity(truth, &replay.delivered_text), + teacher_similarity: teacher_similarity(truth, &replay.delivered_text), + final_pass_attempted: replay.final_pass_attempted, + final_pass_skipped: replay.final_pass_skipped, + lexicon_rewrites: replay.postprocess_stats.lexicon_rewrites, + gate_drops: replay.postprocess_stats.gate_drops, + audio_hash_unchanged, + reference_hash_unchanged, + }) +} + +fn failure_row( + clip: &Clip, + reference: &Reference, + run: usize, + duration_seconds: f64, + sample_rate: u32, + wall_seconds: f64, +) -> Result { + Ok(ExecutionRow { + opaque_id: opaque_id(&clip.sha256), + run, + audio_sha256: clip.sha256.clone(), + reference_sha256: reference.sha256.clone(), + reference_kind: reference.kind, + duration_seconds, + sample_rate_hz: sample_rate, + status: "error".to_string(), + error_class: Some("production_replay_failed".to_string()), + wall_seconds, + events: 0, + previews: 0, + sealed_finals: 0, + final_count: 0, + unique_final_id_count: 0, + repeated_final_id_count: 0, + overlapping_final_window_count: 0, + tail_patches: 0, + layer1_provider_armed: false, + live_chars: 0, + adjudicated_chars: 0, + delivered_chars: 0, + reference_tokens: 0, + delivered_tokens: 0, + token_ratio: 0.0, + head_present: false, + tail_present: false, + wer: 0.0, + cer: 0.0, + character_parity: 0.0, + teacher_similarity: 0.0, + final_pass_attempted: false, + final_pass_skipped: false, + lexicon_rewrites: 0, + gate_drops: 0, + audio_hash_unchanged: sha256_file(&clip.path)? == clip.sha256, + reference_hash_unchanged: sha256_file(&reference.path)? == reference.sha256, + }) +} + +fn edit_distance(reference: &[T], hypothesis: &[T]) -> usize { + let mut previous = (0..=hypothesis.len()).collect::>(); + let mut current = vec![0; hypothesis.len() + 1]; + for (row, expected) in reference.iter().enumerate() { + current[0] = row + 1; + for (column, actual) in hypothesis.iter().enumerate() { + current[column + 1] = if expected == actual { + previous[column] + } else { + 1 + previous[column] + .min(previous[column + 1]) + .min(current[column]) + }; + } + std::mem::swap(&mut previous, &mut current); + } + previous[hypothesis.len()] +} + +fn normalized_words(text: &str) -> Vec { + text.split(|character: char| !character.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_lowercase) + .collect() +} + +fn normalized_characters(text: &str) -> Vec { + text.chars() + .flat_map(char::to_lowercase) + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn word_error_rate(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_words(reference); + let hypothesis = normalized_words(hypothesis); + edit_distance(&reference, &hypothesis) as f64 / reference.len().max(1) as f64 +} + +fn character_error_rate(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_characters(reference); + let hypothesis = normalized_characters(hypothesis); + edit_distance(&reference, &hypothesis) as f64 / reference.len().max(1) as f64 +} + +fn normalized_character_parity(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_characters(reference); + let hypothesis = normalized_characters(hypothesis); + let denominator = reference.len().max(hypothesis.len()).max(1); + (1.0 - edit_distance(&reference, &hypothesis) as f64 / denominator as f64).clamp(0.0, 1.0) +} + +fn teacher_similarity(reference: &str, hypothesis: &str) -> f64 { + use codescribe_core::quality::teacher::{AlignOp, align_words, tokenize}; + + let normalize = |text: &str| text.split_whitespace().collect::>().join(" "); + let reference_tokens = tokenize(&normalize(reference)); + let hypothesis_tokens = tokenize(&normalize(hypothesis)); + let equal = align_words(&reference_tokens, &hypothesis_tokens) + .iter() + .filter(|operation| matches!(operation, AlignOp::Equal { .. })) + .count(); + equal as f64 / reference_tokens.len().max(hypothesis_tokens.len()).max(1) as f64 +} + +fn mean(values: impl Iterator) -> Option { + let values = values.collect::>(); + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f64) +} + +fn opaque_id(sha256: &str) -> String { + format!("audio-{}", &sha256[..sha256.len().min(16)]) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("open input {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn operator_configuration_fingerprints() -> Result> { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("HOME is unavailable for configuration fingerprinting"))?; + [ + ( + "settings_json", + home.join("Library/Application Support/Codescribe/settings.json"), + ), + ("dotenv", home.join(".codescribe/.env")), + ( + "preferences_plist", + home.join("Library/Preferences/com.vetcoders.codescribe.plist"), + ), + ] + .into_iter() + .map(|(label, path)| { + let exists = path.is_file(); + Ok(FileFingerprint { + label: label.to_string(), + exists, + sha256: exists.then(|| sha256_file(&path)).transpose()?, + }) + }) + .collect() +} + +fn fingerprint_file(label: &str, path: &Path) -> Result { + let exists = path.is_file(); + Ok(FileFingerprint { + label: label.to_string(), + exists, + sha256: exists.then(|| sha256_file(path)).transpose()?, + }) +} + +fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec_pretty(value)?; + atomic_write(path, &bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("report path has no UTF-8 filename"))?; + let temporary = parent.join(format!(".{file_name}.tmp-{}", std::process::id())); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .with_context(|| format!("create temporary report {}", temporary.display()))?; + io::Write::write_all(&mut file, bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<()> { + atomic_write(path, bytes)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("set private report permissions {}", path.display())) +} + +fn markdown_sibling(json: &Path) -> PathBuf { + json.with_extension("md") +} + +fn census_markdown(census: &CorpusCensus) -> String { + format!( + "# Codescribe corpus census\n\n- Audio instances: {}\n- Distinct audio: {}\n- Duplicate instances: {}\n- Formats: {}\n- Human-paired distinct: {}\n- Historical-paired distinct: {}\n- Apple-referenced distinct: {}\n- Unpaired distinct: {}\n- Selected distinct: {}\n- Reference policy: `{}`\n- Source paths or filenames emitted: no\n- Transcript bodies emitted: no\n", + census.discovered_audio_instances, + census.distinct_audio, + census.duplicate_instances, + census + .format_instances + .iter() + .map(|(format, count)| format!("{format}={count}")) + .collect::>() + .join(", "), + census.distinct_human_paired, + census.distinct_historical_paired, + census.distinct_apple_referenced, + census.distinct_unpaired, + census.selected_distinct, + census.reference_policy, + ) +} + +fn matrix_markdown(report: &MatrixReport) -> String { + let mut output = String::new(); + writeln!(output, "# Codescribe corpus parity report\n").unwrap(); + writeln!(output, "- Commit: `{}`", report.commit).unwrap(); + writeln!( + output, + "- Apple STT bridge SHA-256: `{}`", + report + .apple_stt_bridge + .sha256 + .as_deref() + .unwrap_or("missing") + ) + .unwrap(); + writeln!( + output, + "- Distinct recordings: {}", + report.distinct_recordings + ) + .unwrap(); + writeln!( + output, + "- Executions: {}/{} successful", + report.successful_executions, report.requested_executions + ) + .unwrap(); + writeln!( + output, + "- Operator config hashes unchanged: {}", + report.configuration_files_unchanged + ) + .unwrap(); + writeln!(output, "- Operator settings loaded: no").unwrap(); + writeln!(output, "- Operator dotenv loaded: no").unwrap(); + writeln!( + output, + "- Keychain access disabled: {}", + report.keychain_disabled + ) + .unwrap(); + writeln!( + output, + "- Permission request APIs called: {}", + report.permission_request_apis_called_by_tool + ) + .unwrap(); + writeln!( + output, + "- TCC state fully proven unchanged: {}", + report.permission_state_proven_unchanged + ) + .unwrap(); + writeln!(output, "- Quality gate: `{}`\n", report.quality_gate).unwrap(); + writeln!( + output, + "| Profile | OK | Failed | Observed L1 | Mean WER | Mean CER | Char parity | Qube quality |" + ) + .unwrap(); + writeln!(output, "|---|---:|---:|---|---:|---:|---:|---|").unwrap(); + for status in &report.profile_status { + writeln!( + output, + "| `{}` | {} | {} | {} | {} | {} | {} | {} |", + status.profile.token(), + status.successful_executions, + status.failed_executions, + optional_bool(status.observed_layered), + optional_score(status.mean_wer), + optional_score(status.mean_cer), + optional_score(status.mean_character_parity), + status + .quality_html + .as_deref() + .map_or_else(|| "n/a".to_string(), |path| format!("[open]({path})")), + ) + .unwrap(); + } + output.push_str( + "\n## Coverage boundary\n\nThis is production PCM-session replay through stop adjudication and lexicon delivery. It does not prove CoreAudio microphone capture, BlackHole loopback, hotkey modes, target-app paste, inline LLM formatting, or TCC continuity. Those surfaces remain explicit in `report.json`.\n", + ); + output +} + +fn optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "n/a", + } +} + +fn optional_score(value: Option) -> String { + value.map_or_else(|| "n/a".to_string(), |score| format!("{score:.4}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_tokens_round_trip_without_hidden_defaults() { + for profile in [ + ReplayProfile::AppleLayer0, + ReplayProfile::AppleLayer1Inprocess, + ReplayProfile::AppleLayer1Sidecar, + ReplayProfile::AppleLayer1Remote, + ReplayProfile::AppleLayer1FusionUtterance, + ReplayProfile::AppleLayer1FusionLeftPad, + ReplayProfile::AppleLayer1FusionStablePrompt, + ReplayProfile::AppleLayer1FusionIdempotent, + ReplayProfile::AppleLayer1LocalFinalPass, + ] { + assert_eq!(profile.token().parse::(), Ok(profile)); + } + } + + #[test] + fn privacy_contract_is_fail_closed() { + let contract = PrivacyContract::default(); + assert!(!contract.source_paths_emitted); + assert!(!contract.source_filenames_emitted); + assert!(!contract.transcript_bodies_emitted); + assert!(contract.opaque_ids_are_hash_prefixes); + } + + #[test] + fn edit_metrics_distinguish_loss_from_exact_text() { + assert_eq!(word_error_rate("alpha beta", "alpha beta"), 0.0); + assert!(word_error_rate("alpha beta", "alpha") > 0.0); + assert_eq!(normalized_character_parity("Alpha beta", "alpha beta"), 1.0); + } + + #[test] + fn coverage_never_claims_file_replay_is_live_capture() { + let coverage = CoverageContract::default(); + assert_eq!(coverage.production_pcm_session_replay, "covered"); + assert_eq!( + coverage.coreaudio_microphone_capture, + "not_covered_by_file_replay" + ); + assert_eq!(coverage.tcc_permissions, "not_mutated_or_fully_verified"); + } + + #[test] + fn isolated_data_dir_is_not_removed_with_profile_overrides() { + assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DATA_DIR")); + assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DISABLE_KEYCHAIN")); + } + + #[test] + fn production_quality_report_uses_qube_keyboard_surface() { + let entries = vec![ReportEntry { + id: "audio-deadbeef-run1-apple-layer0".to_string(), + audio_path: "audio-deadbeef".to_string(), + audio_rel_path: "audio/audio-deadbeef.wav".to_string(), + reference_path: None, + duration_secs: 1.0, + transcripts: ReportTranscripts { + raw: Some("surowy tekst".to_string()), + post: Some("dostarczony tekst".to_string()), + reference: Some("tekst człowieka".to_string()), + ..ReportTranscripts::default() + }, + raw_semantics: Some(ReportTranscriptSemantics { + state: ReportTranscriptState::TextCommitted, + reason: None, + }), + metrics: ReportMetrics { + raw_wer: Some(0.5), + post_wer: Some(0.25), + ..ReportMetrics::default() + }, + postprocess_stats: None, + errors: Vec::new(), + }]; + let report = build_quality_report( + ReplayProfile::AppleLayer0, + "pl", + ReferencePolicy::Human, + entries, + ); + let config = QualityReportConfig { + input_dir: PathBuf::from("."), + output_dir: PathBuf::from("."), + date_filter: None, + limit: 0, + language: Some("pl".to_string()), + skip_cloud: true, + cloud_concurrency: 0, + skip_formatting: true, + debug_mode: true, + copy_audio: false, + metrics_reference: MetricsReference::Corpus, + local_transcription: LocalTranscriptionMode::LocalWhisper, + }; + let html = render_qube_html(&report, &config); + assert!(html.contains("Ctrl+Cmd+Space")); + assert!(html.contains("event.code === 'ArrowLeft'")); + assert!(html.contains("surowy tekst")); + assert!(html.contains("dostarczony tekst")); + assert!(html.contains("tekst człowieka")); + } +} diff --git a/core/quality/qube_report.rs b/core/quality/qube_report.rs index f02d70b0..76f85d92 100644 --- a/core/quality/qube_report.rs +++ b/core/quality/qube_report.rs @@ -888,7 +888,13 @@ fn render_markdown(report: &QualityReport) -> String { /// stays portable. Reference transcripts are emitted but hidden unless /// `debug_mode` is set — the operator is meant to judge the audio first, not read /// the answer. Every interpolated value goes through [`html_escape`]. -fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { +/// Render the reusable, self-contained Qube review surface. +/// +/// Production replay tools call this renderer with an in-memory report so the +/// operator gets the same audio controls, keyboard navigation and annotation +/// workflow without routing the recording through Qube's legacy LocalWhisper +/// runner. +pub fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { let debug = config.debug_mode; let mut body = String::new(); diff --git a/core/stt/apple_stt/mod.rs b/core/stt/apple_stt/mod.rs index b1985dee..3fde529a 100644 --- a/core/stt/apple_stt/mod.rs +++ b/core/stt/apple_stt/mod.rs @@ -351,6 +351,38 @@ fn init_impl() -> Result<()> { Ok(()) } +/// Fail-closed readiness probe for unattended tools that must never raise a +/// Speech Recognition dialog or install Apple speech assets. +/// +/// This deliberately bypasses the cached [`init`] path: `probe` with +/// `allow_download=false` is observational, while `init` is allowed to request +/// SFSpeech authorization and download SpeechTranscriber assets. Callers may +/// proceed only when the selected backend is already installed and either does +/// not require Speech TCC or is already authorized. +pub fn ensure_noninteractive_ready(language: Option<&str>) -> Result<()> { + ensure_supported_platform()?; + let locale = resolved_locale(language); + let probe = probe_bridge(&locale, false)?; + if !probe.supported { + bail!("Apple on-device STT does not support locale '{locale}' without changing host state"); + } + if !probe.installed { + bail!( + "Apple on-device STT assets for locale '{locale}' are not installed; unattended replay refuses to download them" + ); + } + match speech_auth_init_decision(probe.backend, probe.speech_auth.as_deref()) { + SpeechAuthInitDecision::NotRequired | SpeechAuthInitDecision::Proceed => Ok(()), + SpeechAuthInitDecision::RequestAuthorization => bail!( + "Speech Recognition permission is not determined; unattended replay refuses to request it" + ), + SpeechAuthInitDecision::HardFail => { + let auth = probe.speech_auth.as_deref().unwrap_or("unknown"); + bail!("Speech Recognition permission is {auth}; unattended replay will not change it") + } + } +} + /// Runtime availability guard used by engine router. pub(crate) fn is_runtime_available() -> bool { if !cfg!(target_os = "macos") { From bd41eb5c31e274a048db0f7ad3c7974e56ad0a63 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:23:13 +0200 Subject: [PATCH 03/18] [codex/vc-workflow] refactor: load MiniLM as a runtime resource - Keep MiniLM bytes out of normal Cargo targets - Resolve signed app resources before falling back to the HF cache - Bundle and sign the model during app assembly - Verify the resource directly instead of inferring it from dylib size - Mark corpus source identity dirty when the Living Tree is not clean Authored-By: Codex --- Makefile | 29 +++++----- README.md | 6 +-- core/build.rs | 32 ++++++----- core/embedder/engine.rs | 49 +++++++++++++++-- core/embedder/mod.rs | 2 +- docs/ARCHITECTURE.md | 13 +++-- docs/ENV_REGISTRY.toml | 13 +++-- docs/INSTALLATION.md | 2 +- docs/TEAM_SETUP.md | 7 +-- scripts/build-app.sh | 50 +++++++++++++++++ scripts/build-dmg.sh | 19 +++---- scripts/entitlements.appstore-basic.plist | 2 +- scripts/entitlements.plist | 8 +-- scripts/verify-dmg-payload.sh | 66 ++++++++++++++--------- 14 files changed, 215 insertions(+), 83 deletions(-) diff --git a/Makefile b/Makefile index 4b594578..2b337579 100644 --- a/Makefile +++ b/Makefile @@ -114,22 +114,23 @@ build: @echo "Building (debug)..." @cargo build -# Slim public default: Silero VAD + MiniLM. Whisper is runtime/cache/Settings download. -# Do NOT set CODESCRIBE_EMBED_WHISPER here — that is the fat experimental SKU only. +# Slim public default: Silero in the dylib; MiniLM is a signed app resource; +# Whisper is runtime/cache/Settings download. Large model bytes never flow +# through normal Cargo targets. release-codescribe: dist-preflight - @echo "Building codescribe-ffi (release dylib, embedded: Silero + MiniLM; Whisper runtime)..." + @echo "Building codescribe-ffi (release dylib: Silero embedded; MiniLM/Whisper runtime)..." @echo " The app front-end is no longer a Rust bin; this builds the UniFFI bridge dylib." @echo " Produce the runnable SwiftUI app with: make app PROFILE=release" @echo " Fat Whisper embed: make release-codescribe-embedded" @CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ - env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_NO_EMBED cargo build --release -p codescribe-ffi + env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_EMBED_EMBEDDER -u CODESCRIBE_NO_EMBED cargo build --release -p codescribe-ffi # Optional fat SKU / offline curiosity: bake Whisper into the dylib (~1GB+). # Not the daily release path. Pair with `make release-full` for a _full DMG. release-codescribe-embedded: dist-preflight ensure-models - @echo "Building codescribe-ffi (FAT: Silero + MiniLM + Whisper embedded)..." + @echo "Building codescribe-ffi (FAT Whisper: Silero + Whisper embedded; MiniLM runtime resource)..." @CODESCRIBE_EMBED_WHISPER=1 CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ - cargo build --release -p codescribe-ffi + env -u CODESCRIBE_EMBED_EMBEDDER cargo build --release -p codescribe-ffi # ── SwiftUI app (macos/) via the codescribe-ffi UniFFI bridge ──────────────── # Full verified pipeline: cargo (ffi dylib) → uniffi-bindgen → xcodegen → xcodebuild. @@ -150,10 +151,10 @@ release-qube: dist-preflight release: release-codescribe release-qube install: - @echo "Installing qube tools + codescribe CLI (slim: Silero + MiniLM; Whisper from cache / Settings)..." + @echo "Installing qube tools + codescribe CLI (Silero embedded; MiniLM/Whisper from cache)..." @echo "Local install uses the development license verifier — same contract as install-app." @./scripts/download-embedder.sh || true - @env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_NO_EMBED -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ + @env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_EMBED_EMBEDDER -u CODESCRIBE_NO_EMBED -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ CODESCRIBE_LOCAL_INSTALL=1 cargo install --path . --force @mkdir -p ~/.codescribe @$(MAKE) hooks @@ -797,6 +798,8 @@ test-corpus-parity: @set -euo pipefail; \ root_args=(); \ max_args=(); \ + source_identity="$$(git rev-parse HEAD)"; \ + if [ -n "$$(git status --porcelain --untracked-files=all)" ]; then source_identity="$$source_identity-dirty"; fi; \ if [ ! -x "$(CORPUS_APPLE_BRIDGE)" ]; then \ printf 'corpus parity refused: signed Apple STT bridge is not executable: %s\n' "$(CORPUS_APPLE_BRIDGE)" >&2; \ exit 2; \ @@ -813,7 +816,7 @@ test-corpus-parity: --runs "$(CORPUS_RUNS)" \ --references "$(CORPUS_REFERENCE_POLICY)" \ --apple-bridge "$(CORPUS_APPLE_BRIDGE)" \ - --commit "$$(git rev-parse HEAD)" + --commit "$$source_identity" # Host smoke for the macOS surfaces we own — run after every OS/Xcode bump. # Headless, raises no TCC dialog, posts no synthetic events; operator-only rows @@ -1155,7 +1158,7 @@ help: @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'BUILD & INSTALL' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'build' 'Build debug binary' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release dylib slim (Silero + MiniLM; Whisper runtime)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release dylib slim (Silero embedded; MiniLM/Whisper runtime)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install' 'Install CLI slim (Whisper via cache/Settings, not embedded)' @printf '%s\n' ' make install-no-embed DEV/RECOVERY: no optional embeds (runtime paths only)' @printf '%s\n' ' make release-codescribe-embedded Fat dylib with Whisper baked in (not daily)' @@ -1260,7 +1263,8 @@ dist-preflight-signed: dist-preflight fi @echo "dist preflight: Sparkle public key OK (32-byte Ed25519 from $(if $(SPARKLE_ED_PUBLIC_KEY),environment,$(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE)))" -# Daily slim DMG (public default): Silero + MiniLM, Whisper NOT embedded. +# Daily slim DMG (public default): Silero embedded, MiniLM runtime resource, +# Whisper NOT embedded. dmg: dist-preflight @CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" ./scripts/build-dmg.sh @@ -1289,7 +1293,8 @@ release-standard: dist-preflight-signed ./scripts/verify-dmg-payload.sh "$$DMG" --variant slim --version "$$VERSION" # Optional fat SKU: bake Whisper (~1GB+) into the app. Not the daily path. -# Ends with the fail-closed payload gate (full = Silero + MiniLM + Whisper). +# Ends with the fail-closed payload gate (full = Silero + Whisper embedded, +# MiniLM runtime resource). release-full: dist-preflight-signed ensure-models @CODESCRIBE_CODESIGN_IDENTITY="$(CODESCRIBE_DIST_CODESIGN_IDENTITY)" \ CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ diff --git a/README.md b/README.md index 8c07383a..5d54053c 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Codescribe can load custom MCP servers from `~/.codescribe/mcp.json`. That keeps ## Features - **Rust core + SwiftUI app** — Native macOS SwiftUI shell over the Rust engine through UniFFI, with candle-core + Metal GPU -- **Two DMG variants** — Standard (daily) embeds Silero VAD + MiniLM; Whisper is downloaded from Settings → Dictation or HF cache. Optional `_full` DMG also embeds Whisper for offline/curiosity installs. +- **Two DMG variants** — Standard (daily) embeds Silero VAD and signs MiniLM as a runtime app resource; Whisper is downloaded from Settings → Dictation or HF cache. Optional `_full` DMG also embeds Whisper for offline/curiosity installs. - **Whisper Live** — Streaming transcription happens _during recording_ (chunks + overlap), so `stop()` is near-instant - **Stream postprocess** — semantic gating + cleanup of live chunks before final output @@ -334,7 +334,7 @@ Codescribe uses **whisper-large-v3-turbo** (mlx-community, fp16): ### Runtime Whisper (Current) -**Daily public builds are slim.** `make release`, `make dmg` / `dmg-signed`, and `make release-standard` embed **Silero VAD** (required) and **MiniLM** when available. **Whisper is not baked in** (~900 MB–1.5 GB saved). Install local Candle Whisper from **Settings → Dictation → Download Whisper**, or run `make download-model`. +**Daily public builds keep large weights out of Cargo artifacts.** `make release`, `make dmg` / `dmg-signed`, and `make release-standard` embed only **Silero VAD** in the Rust engine. **MiniLM** is copied into the signed app as a runtime resource, while **Whisper is not baked in** (~900 MB–1.5 GB saved). Install local Candle Whisper from **Settings → Dictation → Download Whisper**, or run `make download-model`. Optional fat SKU (offline / curiosity): `make release-full` or `CODESCRIBE_EMBED_WHISPER=1` / `make release-codescribe-embedded`. @@ -350,7 +350,7 @@ The mlx-community repo ships only `config.json` + `weights.safetensors`; the download paths compose `tokenizer.json` + `mel_filters.npz` from the legacy repo (both files are quantization-independent). -`CODESCRIBE_NO_EMBED=1` is a development/recovery path that also skips MiniLM embed; it is not the public slim product path. +`CODESCRIBE_EMBED_EMBEDDER=1` is an explicit fat/debug path that compiles MiniLM into Rust artifacts. Normal builds resolve MiniLM from the signed app resource or HF cache. `CODESCRIBE_NO_EMBED=1` disables every optional binary embed; Silero remains embedded. Model files required: diff --git a/core/build.rs b/core/build.rs index e597181f..ec2efba9 100644 --- a/core/build.rs +++ b/core/build.rs @@ -8,7 +8,10 @@ //! (`resolve_runtime_whisper_model_path`) — the model is held in memory for the //! session anyway, so baking ~1GB into every artifact only multiplied target/ //! into tens of GB for zero runtime win (2026-06-10 policy, operator-decided). -//! Release builds still embed Silero VAD + MiniLM embedder by default. +//! MiniLM follows the same runtime-load policy as Whisper: normal builds load +//! it from the app resource bundle or HF cache; only +//! `CODESCRIBE_EMBED_EMBEDDER=1` bakes it into the Rust artifact. Silero VAD +//! remains embedded because it is small and part of capture identity. //! Opt-out of all optional embedding with CODESCRIBE_NO_EMBED=1 (except Silero). //! TTS requires opt-in via CODESCRIBE_EMBED_TTS. //! @@ -45,10 +48,10 @@ const DEFAULT_TTS_REPO: &str = "sesame/csm-1b"; /// Hugging Face repo id for Mimi codec weights used with TTS embedding. const DEFAULT_MIMI_REPO: &str = "kyutai/mimi"; -/// Default embedder model — MiniLM multilingual (~224MB fp16, always embedded like Silero) +/// Default embedder model — MiniLM multilingual (~471MB fp32 weights on disk). /// Override with CODESCRIBE_EMBEDDER_REPO for alternative models const DEFAULT_EMBEDDER_MODEL_NAME: &str = "minilm-l12-v2"; -/// Default sentence-transformers MiniLM repo embedded unless `CODESCRIBE_NO_EMBED`. +/// Default sentence-transformers MiniLM repo resolved from bundle/cache at runtime. const DEFAULT_EMBEDDER_REPO: &str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"; /// Env flag: local install path skips release license-key hardening in `main`. const LOCAL_INSTALL_ENV: &str = "CODESCRIBE_LOCAL_INSTALL"; @@ -57,8 +60,8 @@ const LOCAL_INSTALL_ENV: &str = "CODESCRIBE_LOCAL_INSTALL"; /// compiles against. /// /// Each asset has its own policy, and they are not symmetrical: Silero VAD is -/// non-negotiable and its absence panics; MiniLM embeds by default; Whisper and -/// TTS are opt-in. A requested-but-missing model degrades to a `cargo:warning` +/// non-negotiable and its absence panics; MiniLM, Whisper and TTS are opt-in. +/// A requested-but-missing model degrades to a `cargo:warning` /// and a runtime lookup rather than failing the build — only the committed /// Silero file is treated as a repo invariant. fn main() { @@ -67,6 +70,7 @@ fn main() { println!("cargo:rerun-if-env-changed=CODESCRIBE_MODEL_PATH"); println!("cargo:rerun-if-env-changed=CODESCRIBE_NO_EMBED"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_WHISPER"); + println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_EMBEDDER"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_TTS"); println!("cargo:rerun-if-env-changed=CODESCRIBE_TTS_PATH"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBEDDER_REPO"); @@ -212,8 +216,9 @@ fn main() { ); } - // MiniLM embedder — always embedded (like Silero), ~224MB fp16 - // Skip only with CODESCRIBE_NO_EMBED=1 + // MiniLM embedder — runtime bundle/cache by default, matching Whisper. + // Binary embedding is an explicit fat-SKU/debug request only. + let embed_embedder_requested = env_flag("CODESCRIBE_EMBED_EMBEDDER", false); let embedder_repo = env::var("CODESCRIBE_EMBEDDER_REPO") .ok() .map(|v| v.trim().to_string()) @@ -226,7 +231,8 @@ fn main() { && embedder_model_path.join("tokenizer.json").exists() && embedder_model_path.join("model.safetensors").exists(); - if !no_embed && embedder_model_exists { + let embedder_embedded = embed_embedder_requested && !no_embed && embedder_model_exists; + if embedder_embedded { println!( "cargo:warning=Embedding MiniLM model from: {}", embedder_model_path.display() @@ -247,7 +253,7 @@ fn main() { fs::write(&embedder_dest_path, embedder_content) .expect("Failed to write embedded_embedder_data.rs"); println!("cargo:rustc-cfg=embed_embedder"); - } else if !no_embed && !embedder_model_exists { + } else if embed_embedder_requested && !no_embed && !embedder_model_exists { println!( "cargo:warning=Embedder model not found at: {}", embedder_model_path.display() @@ -323,12 +329,12 @@ fn main() { }; let embedder_summary = if qube_context { "not_used" - } else if !no_embed && embedder_model_exists { + } else if embedder_embedded { "embedded" - } else if no_embed { - "runtime_load_from_cache" - } else { + } else if embed_embedder_requested && !no_embed { "missing_at_build_time" + } else { + "runtime_load_from_bundle_or_cache" }; let tts_summary = if qube_context { "not_used" diff --git a/core/embedder/engine.rs b/core/embedder/engine.rs index f230314b..b58e4853 100644 --- a/core/embedder/engine.rs +++ b/core/embedder/engine.rs @@ -1,7 +1,7 @@ //! Embedder Engine - offline MiniLM embeddings via Candle BERT. //! -//! Provides text embeddings using a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model (fp16). -//! No runtime downloads; model must be embedded or present on disk. +//! Provides text embeddings using a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model. +//! No runtime downloads; model must be embedded, bundled in the app, or present in the HF cache. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -366,7 +366,8 @@ impl EmbedderEngine { } /// Locate a model directory: explicit path, then `CODESCRIBE_EMBEDDER_PATH`, -/// then an HF cache snapshot for the configured or default repo. +/// then the signed app's runtime resource, then an HF cache snapshot for the +/// configured or default repo. /// /// Never downloads. Failure returns an error naming the exact commands and env /// vars that would fix it. @@ -382,6 +383,15 @@ fn resolve_model_path(explicit: Option<&PathBuf>, repo_override: Option<&str>) - } } + // A public app carries MiniLM as a normal signed resource rather than + // compiling 471 MB through every Cargo target. An explicit repo override + // still wins by bypassing this default-model resource. + if repo_override.is_none() + && let Some(bundled) = bundled_app_model_path() + { + return Ok(bundled); + } + if let Some(repo) = repo_override { if let Some(snapshot) = hf_cache::find_snapshot_with_any( repo, @@ -405,6 +415,20 @@ fn resolve_model_path(explicit: Option<&PathBuf>, repo_override: Option<&str>) - )) } +/// Resolve `Codescribe.app/Contents/Resources/models/embedder` without assuming +/// a fixed install location. CLI/test binaries naturally return `None` and fall +/// through to the HF cache. +fn bundled_app_model_path() -> Option { + let executable = std::env::current_exe().ok()?; + bundled_model_path_for_executable(&executable) +} + +fn bundled_model_path_for_executable(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + let candidate = macos_dir.join("../Resources/models/embedder"); + model_files_present(&candidate).then(|| candidate.canonicalize().unwrap_or(candidate)) +} + /// Whether `path` holds a usable model: both config files plus safetensors or /// ONNX weights. /// @@ -593,4 +617,23 @@ mod tests { let sim = EmbedderEngine::similarity(&a, &b); assert!(sim.abs() < 0.001); } + + #[test] + fn resolves_signed_app_embedder_resource_from_executable_geometry() { + let temp = tempfile::tempdir().unwrap(); + let contents = temp.path().join("Codescribe.app/Contents"); + let executable = contents.join("MacOS/Codescribe"); + let model = contents.join("Resources/models/embedder"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&model).unwrap(); + std::fs::write(&executable, b"").unwrap(); + for name in ["config.json", "tokenizer.json", "model.safetensors"] { + std::fs::write(model.join(name), b"fixture").unwrap(); + } + + assert_eq!( + bundled_model_path_for_executable(&executable), + Some(model.canonicalize().unwrap()) + ); + } } diff --git a/core/embedder/mod.rs b/core/embedder/mod.rs index fc0106e7..8e908185 100644 --- a/core/embedder/mod.rs +++ b/core/embedder/mod.rs @@ -1,7 +1,7 @@ //! Text Embedder module - semantic embeddings using MiniLM (offline). //! //! Provides semantic text embeddings for RAG, similarity search, and context matching. -//! Uses a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model (no runtime downloads by default). +//! Uses a bundled, cached, or explicitly embedded paraphrase-multilingual-MiniLM-L12-v2 model (no runtime downloads). //! Override with `CODESCRIBE_EMBEDDER_REPO=sentence-transformers/...` (HF cache). //! //! ## Quick Start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a52c16b4..73024f6d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -306,10 +306,10 @@ The Rust AppKit `ui/voice_chat/` module (`mod.rs` / `api.rs` / `handlers.rs` / ` ## Model Location -**Current runtime truth**: Whisper is embedded by default when the model snapshot is -available at build time. Runtime lookup remains available as a fallback when -embedding is disabled with `CODESCRIBE_NO_EMBED=1` or the build cannot embed the -model: +**Current runtime truth**: daily builds keep Whisper and MiniLM weights out of +Cargo artifacts. MiniLM loads from the signed app resource (or HF cache for CLI +and development); Whisper resolves from the paths below. Explicit fat builds +may still opt into binary embedding: 1. `CODESCRIBE_MODEL_PATH` environment variable 2. `~/.codescribe/models/whisper-large-v3-turbo/` (fp16 default) @@ -317,6 +317,11 @@ model: 4. Legacy fallback: `whisper-large-v3-turbo-mlx-q8` dir or `LibraxisAI/whisper-large-v3-turbo-mlx-q8` snapshots +MiniLM resolution: `CODESCRIBE_EMBEDDER_PATH`, then +`Codescribe.app/Contents/Resources/models/embedder`, then the configured/default +Hugging Face cache snapshot. `CODESCRIBE_EMBED_EMBEDDER=1` is the explicit +binary-embed escape hatch. + ## Related Documentation - [`guide/README.md`](guide/README.md) — User documentation diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index 0a9d416b..dd2fe952 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -912,11 +912,11 @@ category = "embedder" description = "Override path to E5 embedder model directory" [vars.CODESCRIBE_EMBEDDER_REPO] -default = "intfloat/multilingual-e5-large" +default = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" type = "string" reload = "rebuild" category = "embedder" -description = "HF repo for E5 embedder (used for embedding at build/runtime)" +description = "HF repo for the MiniLM semantic-gate model (runtime cache resolution and optional build-time embedding)" [vars.CODESCRIBE_EMBEDDER_DEVICE] default = "" @@ -1450,7 +1450,7 @@ default = "0" type = "bool" reload = "rebuild" category = "build" -description = "Skip embedding Whisper model in binary" +description = "Skip every optional model embed in Rust artifacts; Silero remains embedded" [vars.CODESCRIBE_EMBED_WHISPER] default = "0" @@ -1459,6 +1459,13 @@ reload = "rebuild" category = "build" description = "Opt-in: embed Whisper model in binary (distribution builds); default loads from HF cache at runtime" +[vars.CODESCRIBE_EMBED_EMBEDDER] +default = "0" +type = "bool" +reload = "rebuild" +category = "build" +description = "Opt-in fat/debug path: compile MiniLM into Rust artifacts; normal app builds package it as a signed runtime resource" + [vars.CODESCRIBE_EMBED_TTS] default = "0" type = "bool" diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 6ebeb89a..67669774 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -52,7 +52,7 @@ make notarize # Notarize with Apple (requires Developer ID) # make release-dmgs # Build + sign + notarize standard and full DMGs ``` -**Result**: standard `Codescribe_X.Y.Z-….dmg` (slim: Silero + MiniLM; Whisper via Settings download / cache) and optional `…_full.dmg` (embeds Whisper too). Daily operator flow is slim: `make release && make dmg-signed && make notarize`. +**Result**: standard `Codescribe_X.Y.Z-….dmg` (slim: Silero embedded, MiniLM signed as a runtime resource, Whisper via Settings download / cache) and optional `…_full.dmg` (embeds Whisper too). Daily operator flow is slim: `make release && make dmg-signed && make notarize`. ### About panel commit stamp diff --git a/docs/TEAM_SETUP.md b/docs/TEAM_SETUP.md index 5ea43f0e..d624958a 100644 --- a/docs/TEAM_SETUP.md +++ b/docs/TEAM_SETUP.md @@ -59,10 +59,11 @@ Grant in: System Settings > Privacy & Security ## Model -**Embedded-first Whisper policy**: `whisper-large-v3-turbo` (mlx-community fp16; legacy q8 fallback) -**Embedded Embedder**: `paraphrase-multilingual-MiniLM-L12-v2` (for semantic gating) +**Runtime Whisper policy**: `whisper-large-v3-turbo` (mlx-community fp16; legacy q8 fallback) +**Runtime Embedder**: `paraphrase-multilingual-MiniLM-L12-v2` (signed app resource or HF cache, for semantic gating) -- `core/build.rs` embeds Whisper by default when a complete model is available at build time. +- `core/build.rs` keeps Whisper and MiniLM out of normal Cargo artifacts; explicit + `CODESCRIBE_EMBED_WHISPER=1` / `CODESCRIBE_EMBED_EMBEDDER=1` builds are fat debug/offline paths. - Runtime fallback resolves Whisper from exactly one shared contract in `core/config/models.rs`: `CODESCRIBE_MODEL_PATH` → configured local model path/alias → configured HF repo snapshot → default local turbo model → default HF cache snapshot. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index e49c6596..d4445cbc 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -22,6 +22,7 @@ # Env toggles: # SKIP_XCODEBUILD=1 stop after xcodegen (verifies stages 1-4 without Xcode) # CODE_SIGNING_ALLOWED=YES|NO passed through to xcodebuild (default NO) +# CODESCRIBE_EMBEDDER_BUNDLE_SOURCE=/path/to/model explicit MiniLM resource source set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -59,6 +60,44 @@ if [ "${SKIP_XCODEBUILD:-0}" != "1" ]; then require swiftc "install Xcode command line tools: xcode-select --install" fi +resolve_embedder_source() { + local explicit="${CODESCRIBE_EMBEDDER_BUNDLE_SOURCE:-${CODESCRIBE_EMBEDDER_PATH:-}}" + if [[ -n "$explicit" && -f "$explicit/config.json" && -f "$explicit/tokenizer.json" && -f "$explicit/model.safetensors" ]]; then + printf '%s\n' "$explicit" + return 0 + fi + + local repo="${CODESCRIBE_EMBEDDER_REPO:-sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2}" + local repo_dir="models--${repo//\//--}" + local cache_roots=() + [[ -n "${CODESCRIBE_HF_CACHE:-}" ]] && cache_roots+=("$CODESCRIBE_HF_CACHE") + [[ -n "${HUGGINGFACE_HUB_CACHE:-}" ]] && cache_roots+=("$HUGGINGFACE_HUB_CACHE") + [[ -n "${HF_HUB_CACHE:-}" ]] && cache_roots+=("$HF_HUB_CACHE") + [[ -n "${HF_HOME:-}" ]] && cache_roots+=("$HF_HOME/hub") + cache_roots+=("$HOME/.cache/huggingface/hub") + cache_roots+=("$HOME/.codescribe/embeddings" "$HOME/.codescribe/embeddings/hub") + + local cache snapshot + for cache in "${cache_roots[@]}"; do + [[ -d "$cache/$repo_dir/snapshots" ]] || continue + for snapshot in "$cache/$repo_dir/snapshots"/*; do + if [[ -f "$snapshot/config.json" && -f "$snapshot/tokenizer.json" && -f "$snapshot/model.safetensors" ]]; then + printf '%s\n' "$snapshot" + return 0 + fi + done + done + return 1 +} + +EMBEDDER_RUNTIME_SOURCE="" +if [[ "${SKIP_XCODEBUILD:-0}" != "1" && "${CODESCRIBE_EMBED_EMBEDDER:-0}" != "1" ]]; then + if ! EMBEDDER_RUNTIME_SOURCE="$(resolve_embedder_source)"; then + echo "error: MiniLM runtime resource not found; run 'make download-embedder' or set CODESCRIBE_EMBEDDER_BUNDLE_SOURCE" >&2 + exit 1 + fi +fi + SCHEME="Codescribe" BRIDGE_DIR="macos/Codescribe/Bridge" DYLIB="$TARGET_DIR/libcodescribe_ffi.dylib" @@ -167,6 +206,17 @@ mkdir -p "$FRAMEWORKS" "$MACOS_DIR" cp "$DYLIB" "$FRAMEWORKS/" cp "$STT_SIDECAR_BIN" "$MACOS_DIR/codescribe-stt-sidecar" chmod 755 "$MACOS_DIR/codescribe-stt-sidecar" +if [[ -n "$EMBEDDER_RUNTIME_SOURCE" ]]; then + EMBEDDER_BUNDLE_DIR="$APP/Contents/Resources/models/embedder" + mkdir -p "$EMBEDDER_BUNDLE_DIR" + cp -L "$EMBEDDER_RUNTIME_SOURCE/config.json" "$EMBEDDER_BUNDLE_DIR/config.json" + cp -L "$EMBEDDER_RUNTIME_SOURCE/tokenizer.json" "$EMBEDDER_BUNDLE_DIR/tokenizer.json" + cp -L "$EMBEDDER_RUNTIME_SOURCE/model.safetensors" "$EMBEDDER_BUNDLE_DIR/model.safetensors" + chmod 644 "$EMBEDDER_BUNDLE_DIR/config.json" "$EMBEDDER_BUNDLE_DIR/tokenizer.json" "$EMBEDDER_BUNDLE_DIR/model.safetensors" + echo " MiniLM runtime resource bundled from HF/local model directory." +else + echo " MiniLM is compiled into the binary by explicit CODESCRIBE_EMBED_EMBEDDER=1." +fi STT_BRIDGE_BUNDLED=0 # Same host-triple pin as Makefile ENGINE_BRIDGE_TARGET (W0-B / S-1): avoid # inheriting the builder's macosxN.0 so bundled bridges match CI/dev hosts. diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index 5942563a..742411e1 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -21,9 +21,9 @@ APP_PATH="$ROOT_DIR/macos/build/Build/Products/Release/${APP_NAME}.app" SIGN=0 NOTARIZE=0 NO_EMBED=0 -# Public daily release is slim: Silero VAD + MiniLM only. Whisper resolves at -# runtime (HF cache / ~/.codescribe/models / Settings download). Embed Whisper -# only via --embed-whisper (curiosity / offline / experimental fat SKU). +# Public daily release keeps Silero in the dylib and packages MiniLM as a signed +# runtime resource. Whisper resolves from cache / Settings download. Embed +# Whisper only via --embed-whisper (curiosity / offline / experimental fat SKU). EMBED_WHISPER=0 EMBED_WHISPER_EXPLICIT=0 DMG_SUFFIX="" @@ -32,7 +32,8 @@ usage() { cat < - - + + @@ -20,12 +20,12 @@ com.apple.security.device.audio-input - + com.apple.security.cs.allow-unsigned-executable-memory - + com.apple.security.cs.allow-dyld-environment-variables diff --git a/scripts/verify-dmg-payload.sh b/scripts/verify-dmg-payload.sh index 0a647ad6..02ff241a 100755 --- a/scripts/verify-dmg-payload.sh +++ b/scripts/verify-dmg-payload.sh @@ -1,24 +1,23 @@ #!/bin/bash # verify-dmg-payload.sh — fail-closed DMG payload gate # -# Signed ≠ complete. Regression 0.13.2 (≈89 MB DMG, dylib ≈30 MB, no MiniLM) +# Signed ≠ complete. Regression 0.13.2 (≈89 MB DMG, no MiniLM) # passed codesign + notarize + staple. This gate is the defense class that # refuses an incomplete public artifact. # # Payload contract (core/build.rs + Makefile release-codescribe): -# slim: Silero VAD + MiniLM embedded; Whisper runtime/download -# full: Silero + MiniLM + Whisper embedded -# CODESCRIBE_NO_EMBED=1 skips MiniLM — a known regression vector; size floors -# catch it regardless of cause. +# slim: Silero VAD embedded + MiniLM signed runtime resource; Whisper runtime +# full: Silero + Whisper embedded + MiniLM signed runtime resource +# MiniLM is checked directly under Contents/Resources/models/embedder; Cargo +# target size is no longer used as a proxy for product completeness. # # Probe mechanism (calibrated 2026-08-04 on fixtures): -# Models land in libcodescribe_ffi.dylib via include_bytes! (__DATA). -# String markers alone are NOT reliable (runtime download paths keep the -# MiniLM/Whisper repo names even when weights are not embedded). -# Hard size floors on DMG + dylib are the primary fail-closed signal: +# Silero/optional Whisper land in libcodescribe_ffi.dylib. MiniLM is a normal +# signed app resource loaded at runtime. Hard file-presence/size checks plus +# DMG/dylib floors are the fail-closed signal: # BAD 0.13.2: dmg≈85 MB, dylib≈29 MB -# GOOD slim 0.13.3: dmg≈501 MB, dylib≈488 MB -# GOOD full 0.13.3: dmg≈1.3 GB, dylib≈1.3 GB +# expected slim: dmg≈501 MB, dylib≈30 MB, MiniLM≈471 MB resource +# expected full: dmg≈1.3 GB, dylib≈800+ MB, MiniLM≈471 MB resource # # Usage: # ./scripts/verify-dmg-payload.sh --variant slim|full --version X.Y.Z @@ -32,14 +31,15 @@ set -euo pipefail # ── Calibrated thresholds (bytes) ──────────────────────────────────────────── -# MiniLM fp16 alone is ~224 MB; slim dylib with Silero+MiniLM+code ≈ 488 MB. -# Floor at 400 MB leaves headroom for model-format changes without accepting -# the no-embed regression (≈30 MB dylib). +# The slim dylib contains code + Silero only; MiniLM has its own direct resource +# gate below. Keep a low engine-presence floor instead of forcing model bytes +# through Cargo artifacts. readonly SLIM_DMG_MIN=$((400 * 1024 * 1024)) -readonly SLIM_DYLIB_MIN=$((400 * 1024 * 1024)) -# Whisper large-v3-turbo-mlx-q8 + MiniLM + Silero ≈ 1.3 GB dylib. +readonly SLIM_DYLIB_MIN=$((20 * 1024 * 1024)) +# Whisper large-v3-turbo + code/Silero remains in the full dylib. readonly FULL_DMG_MIN=$((1000 * 1024 * 1024)) -readonly FULL_DYLIB_MIN=$((1000 * 1024 * 1024)) +readonly FULL_DYLIB_MIN=$((700 * 1024 * 1024)) +readonly EMBEDDER_WEIGHTS_MIN=$((400 * 1024 * 1024)) # Soft markers (advisory + secondary). Size is primary. readonly MARKER_MINILM='paraphrase-multilingual-MiniLM' @@ -60,7 +60,7 @@ Usage: $0 --variant slim|full --version X.Y.Z [--skip-notary] Fail-closed payload gate for Codescribe release DMGs. Path to Codescribe_*.dmg - --variant slim|full slim = Silero+MiniLM; full = +Whisper embedded + --variant slim|full slim = Silero + MiniLM resource; full = +Whisper embedded --version X.Y.Z Expected CFBundleShortVersionString --skip-notary Skip stapler + spctl (pre-notarization smoke) @@ -185,7 +185,7 @@ case "$VARIANT" in ;; esac if (( DMG_BYTES < MIN_DMG )); then - fail "DMG too small for $VARIANT: $(human_mb "$DMG_BYTES") MB < $(human_mb "$MIN_DMG") MB floor — missing embedded payload (likely MiniLM/Whisper absent; CODESCRIBE_NO_EMBED or embed skip)" + fail "DMG too small for $VARIANT: $(human_mb "$DMG_BYTES") MB < $(human_mb "$MIN_DMG") MB floor — missing MiniLM resource and/or Whisper payload" else ok "DMG size $(human_mb "$DMG_BYTES") MB ≥ $(human_mb "$MIN_DMG") MB" fi @@ -311,6 +311,22 @@ if [[ -n "${APP_PATH:-}" ]]; then fail "codesign --verify --deep --strict failed on $(basename "$APP_PATH")" fi + # dylib payload + echo "" + echo "▶ MiniLM runtime resource proof" + EMBEDDER_DIR="$APP_PATH/Contents/Resources/models/embedder" + EMBEDDER_WEIGHTS="$EMBEDDER_DIR/model.safetensors" + if [[ ! -f "$EMBEDDER_DIR/config.json" || ! -f "$EMBEDDER_DIR/tokenizer.json" || ! -f "$EMBEDDER_WEIGHTS" ]]; then + fail "MiniLM runtime resource incomplete at Contents/Resources/models/embedder" + else + EMBEDDER_BYTES=$(stat -f%z "$EMBEDDER_WEIGHTS") + if (( EMBEDDER_BYTES < EMBEDDER_WEIGHTS_MIN )); then + fail "MiniLM weights too small: $(human_mb "$EMBEDDER_BYTES") MB < $(human_mb "$EMBEDDER_WEIGHTS_MIN") MB" + else + ok "MiniLM runtime resource complete ($(human_mb "$EMBEDDER_BYTES") MB weights)" + fi + fi + # dylib payload echo "" echo "▶ payload proof (libcodescribe_ffi.dylib, variant=$VARIANT)" @@ -322,17 +338,16 @@ if [[ -n "${APP_PATH:-}" ]]; then case "$VARIANT" in slim) MIN_DYLIB=$SLIM_DYLIB_MIN - EXPECT_LABEL="Silero + MiniLM (~224 MB MiniLM + Silero + code → dylib ≥ 400 MB)" + EXPECT_LABEL="engine code + embedded Silero (MiniLM is checked as a resource)" ;; full) MIN_DYLIB=$FULL_DYLIB_MIN - EXPECT_LABEL="Silero + MiniLM + Whisper (dylib ≥ 1000 MB)" + EXPECT_LABEL="engine code + Silero + embedded Whisper" ;; esac if (( DYLIB_BYTES < MIN_DYLIB )); then - # Primary regression signal for missing MiniLM (0.13.2 class). - fail "missing MiniLM (and/or Whisper) embed: dylib=$(human_mb "$DYLIB_BYTES") MB < $(human_mb "$MIN_DYLIB") MB floor for $VARIANT — expected $EXPECT_LABEL. Regression class: signed incomplete payload (0.13.2 had ≈29 MB dylib)." + fail "dylib=$(human_mb "$DYLIB_BYTES") MB < $(human_mb "$MIN_DYLIB") MB floor for $VARIANT — expected $EXPECT_LABEL" else ok "dylib size $(human_mb "$DYLIB_BYTES") MB ≥ $(human_mb "$MIN_DYLIB") MB ($VARIANT)" fi @@ -348,10 +363,9 @@ if [[ -n "${APP_PATH:-}" ]]; then fi if grep -a -q -F "$MARKER_MINILM" "$DYLIB" 2>/dev/null; then - ok "minilm repo marker present in dylib (secondary; size is authority)" + ok "minilm runtime resolver marker present in dylib (secondary)" else - # Soft: repo string may relocate; size floor already enforced. - echo " · note: MiniLM repo string not found via strings — size floor already applied" + echo " · note: MiniLM repo marker absent from dylib — direct resource gate already applied" fi if [[ "$VARIANT" == "full" ]]; then From 1e8b50c0e05160ab96078e69cd1b1ea70714b53b Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:27:24 +0200 Subject: [PATCH 04/18] [codex/vc-implement] Add scoped Agent reset - move only Agent conversations and MCP/tool files to Trash\n- clear Agent provider state and credentials without touching dictation data\n- add separate Settings confirmation, previews, bindings, and preservation tests\n\nAuthored-By: Codex --- bridge/src/config.rs | 296 +++++++++++++++++- core/config/loader.rs | 33 ++ macos/Codescribe/Bridge/codescribe_ffi.swift | 108 +++++++ macos/Codescribe/Bridge/codescribe_ffiFFI.h | 22 ++ .../Screens/AgentChat/AgentChatStore.swift | 4 +- .../Screens/Settings/SettingsEngine.swift | 17 + .../Screens/Settings/SettingsViewModel.swift | 69 +++- .../Screens/Settings/UserPanel.swift | 71 +++++ .../CodescribeTests/SettingsTruthTests.swift | 55 ++++ 9 files changed, 668 insertions(+), 7 deletions(-) diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 1f844327..e24f125e 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -39,6 +39,7 @@ use crate::{CsError, CsLanguage}; /// error, because at least one app-data root has already moved and the Rust /// process fence is permanently latched. const RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_RESET_RELAUNCH_REQUIRED"; +const AGENT_RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED"; /// Preserve the ordinary error contract before the destructive boundary, but /// mark every post-boundary error so the host cannot leave a half-reset, @@ -53,6 +54,16 @@ fn reset_error(reset: &AppDataResetGuard, message: impl Into) -> CsError CsError::Config { msg } } +fn agent_reset_error(mutation_started: bool, message: impl Into) -> CsError { + let message = message.into(); + let msg = if mutation_started { + format!("{AGENT_RESET_RELAUNCH_REQUIRED_MARKER}: {message}") + } else { + message + }; + CsError::Config { msg } +} + /// Full settings snapshot pushed to the Swift Settings UI. Combines real /// `Config` struct fields (settings.json / .env / defaults already merged by /// `Config::load()`) with env-only knobs read from persisted settings / .env @@ -166,6 +177,17 @@ pub struct CsResetPreview { pub total_bytes: u64, } +/// Non-secret impact summary for the narrowly-scoped Agent reset. Unlike the +/// app-data reset, this never counts or touches recordings, transcripts, +/// prompts, lexicon data, license state, or dictation preferences. +#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)] +pub struct CsAgentResetPreview { + pub threads: u64, + pub files: u64, + pub total_bytes: u64, + pub secrets_present: bool, +} + /// UI-safe view of one base prompt. Content is included because this surface is /// the prompt editor itself; audit records never include it. #[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)] @@ -1226,6 +1248,63 @@ impl CodescribeConfig { reset_preview_for_dirs(&app_data_dirs()) } + /// Preview the Agent-only reset without changing disk or Keychain state. + pub fn reset_agent_preview(&self) -> CsAgentResetPreview { + agent_reset_preview_for_paths(&agent_reset_paths()) + } + + /// Reset only durable Agent state. Conversations and tool/MCP files are + /// moved to Trash; provider credentials are deleted from Keychain and are + /// intentionally not recoverable. This deliberately does not use the + /// full-app reset fence or its broad root move. + pub fn reset_agent_data(&self) -> Result<(), CsError> { + let trash = codescribe_trash_dir()?; + let destination = create_agent_reset_destination(&trash)?; + let paths = agent_reset_paths(); + let mut mutation_started = false; + + for source in &paths { + if source.exists() { + let name = source.file_name().ok_or_else(|| { + agent_reset_error(mutation_started, "Agent reset path has no filename") + })?; + let target = unique_destination(&destination, &name.to_string_lossy(), None); + move_path_recoverably(source, &target).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to move Agent data to Trash: {error}"), + ) + })?; + mutation_started = true; + } + } + + clear_agent_settings().map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to clear Agent settings: {error}"), + ) + })?; + mutation_started = true; + Config::remove_env_keys(agent_env_keys()).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to clear Agent .env settings: {error}"), + ) + })?; + + for account in agent_secret_accounts() { + mutation_started = true; + delete_key(account).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to remove Agent secret {account}: {error}"), + ) + })?; + } + Ok(()) + } + /// Move only `mcp.json` to Trash. This intentionally does not touch any /// other config, transcripts, threads, logs, preferences, or Keychain keys. pub fn clear_mcp_configuration(&self) -> Result<(), CsError> { @@ -1476,6 +1555,123 @@ fn app_data_dirs() -> Vec { selected.into_iter().map(|(path, _)| path).collect() } +/// The complete durable Agent-owned file surface. Keep this explicit: a reset +/// is allowed to move only these paths, never a parent data root. +fn agent_reset_paths() -> Vec { + let data = UserSettings::settings_dir(); + let config = Config::config_dir(); + vec![ + // `ThreadStore` keeps attachments at `threads/blobs/`; moving the + // parent once avoids a second overlapping move and preserves recovery. + data.join("threads"), + config.join("mcp.json"), + config.join("tool_grants.json"), + ] +} + +fn agent_secret_accounts() -> &'static [&'static str] { + &[ + "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_XAI_API_KEY", + account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT, + account_auth::ANTHROPIC_ACCOUNT_TOKENS_ACCOUNT, + account_auth::XAI_ACCOUNT_TOKENS_ACCOUNT, + ] +} + +/// Legacy/power-user Agent rows that can otherwise outlive settings.json and +/// become the effective provider again after relaunch. Keep this list narrow: +/// dictation, formatting, audio and hotkey rows are intentionally absent. +fn agent_env_keys() -> &'static [&'static str] { + &[ + "LLM_ASSISTIVE_ENDPOINT", + "LLM_ASSISTIVE_MODEL", + "LLM_ASSISTIVE_PROVIDER", + "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_XAI_API_KEY", + "LLM_OPENAI_ACCOUNT_TOKENS", + "LLM_ANTHROPIC_ACCOUNT_TOKENS", + "LLM_XAI_ACCOUNT_TOKENS", + "LLM_OPENAI_OAUTH_CLIENT_ID", + "LLM_ANTHROPIC_OAUTH_CLIENT_ID", + "LLM_XAI_OAUTH_CLIENT_ID", + "AGENT_WORKSPACE_ROOTS", + "AGENT_ENTER_SENDS", + ] +} + +fn agent_reset_preview_for_paths(paths: &[PathBuf]) -> CsAgentResetPreview { + let mut preview = CsAgentResetPreview { + secrets_present: agent_secret_accounts().iter().any(|account| { + codescribe_core::config::keychain::load_key(account) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + }), + ..CsAgentResetPreview::default() + }; + + for path in paths { + if path.ends_with("threads") { + preview.threads = preview + .threads + .saturating_add(thread_index_count(&path.join("index.json"))); + } + preview.files = preview.files.saturating_add(path_file_count(path)); + preview.total_bytes = preview.total_bytes.saturating_add(path_size(path)); + } + preview +} + +fn path_file_count(path: &Path) -> u64 { + let Ok(metadata) = fs::symlink_metadata(path) else { + return 0; + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + return 1; + } + if !metadata.is_dir() { + return 0; + } + fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| path_file_count(&entry.path())) + .sum() +} + +/// Remove only settings that define an Agent identity, its provider, its +/// workspace and its tool policy. All transcription, audio, hotkey, lexicon, +/// license and other ordinary app settings remain in the same JSON document. +fn clear_agent_settings() -> anyhow::Result<()> { + let mut settings = UserSettings::load(); + settings.llm_assistive_endpoint = None; + settings.llm_assistive_model = None; + settings.llm_assistive_provider = None; + settings.openai_oauth_client_id = None; + settings.anthropic_oauth_client_id = None; + settings.xai_oauth_client_id = None; + settings.agent_workspace_roots = None; + settings.agent_permissions = None; + settings.agent_capabilities = None; + settings.agent_enter_sends = None; + settings.save() +} + +fn create_agent_reset_destination(trash: &Path) -> anyhow::Result { + fs::create_dir_all(trash)?; + let destination = unique_destination( + trash, + &format!("codescribe-agent-reset-{}", timestamp_slug(&Utc::now())), + None, + ); + fs::create_dir_all(&destination)?; + Ok(destination) +} + /// The user's `~/.Trash`, which is where a reset parks data so it stays /// recoverable. Fails loudly when the home directory cannot be resolved rather /// than inventing a fallback destination. @@ -2208,13 +2404,14 @@ fn ensure_known_account(account: &str) -> Result<(), CsError> { #[cfg(test)] mod reset_tests { use super::{ - CsResetPreview, ResetAuditEvent, app_data_dirs, append_reset_audit, capture_base_prompts, - clear_mcp_configuration_to, create_reset_destination, move_path_recoverably_with, - move_reset_dirs_to_destination, remove_path_without_following_symlinks, - reset_preview_for_dirs, restore_base_prompts, + CsResetPreview, ResetAuditEvent, agent_env_keys, agent_reset_error, agent_reset_paths, + agent_reset_preview_for_paths, agent_secret_accounts, app_data_dirs, append_reset_audit, + capture_base_prompts, clear_mcp_configuration_to, create_reset_destination, + move_path_recoverably, move_path_recoverably_with, move_reset_dirs_to_destination, + remove_path_without_following_symlinks, reset_preview_for_dirs, restore_base_prompts, }; use chrono::{DateTime, Utc}; - use codescribe_core::config::begin_app_data_reset; + use codescribe_core::config::{Config, begin_app_data_reset}; use serial_test::serial; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; @@ -2277,6 +2474,95 @@ mod reset_tests { .expect("valid fixed reset timestamp") } + #[test] + #[serial] + fn agent_reset_scope_moves_only_agent_files_and_keeps_dictation_roots() { + let sandbox = scratch("agent_scope"); + let root = sandbox.join("data"); + let trash = sandbox.join("trash"); + write( + &root.join("threads/index.json"), + br#"{"threads":[{"id":"one"}]}"#, + ); + write(&root.join("threads/one.json"), b"agent conversation"); + write(&root.join("threads/blobs/image.png"), b"agent attachment"); + write(&root.join("mcp.json"), b"{\"mcpServers\":{}}"); + write(&root.join("tool_grants.json"), b"{\"always_allow\":{}}"); + write( + &root.join("transcriptions/2026-08-15/voice.txt"), + b"must survive", + ); + write(&root.join("prompts/assistive.txt"), b"must survive"); + write(&root.join("dictionary/custom.json"), b"must survive"); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + + let paths = agent_reset_paths(); + let preview = agent_reset_preview_for_paths(&paths); + assert_eq!(preview.threads, 1); + assert_eq!(preview.files, 5); + + let destination = trash.join("codescribe-agent-reset-test"); + std::fs::create_dir_all(&destination).expect("create agent Trash destination"); + for source in &paths { + if source.exists() { + let name = source.file_name().expect("Agent fixture path name"); + move_path_recoverably(source, &destination.join(name)) + .expect("move only Agent-owned path"); + } + } + + assert!(destination.join("threads/one.json").is_file()); + assert!(destination.join("mcp.json").is_file()); + assert!(root.join("transcriptions/2026-08-15/voice.txt").is_file()); + assert!(root.join("prompts/assistive.txt").is_file()); + assert!(root.join("dictionary/custom.json").is_file()); + let _ = std::fs::remove_dir_all(&sandbox); + } + + #[test] + fn agent_reset_secret_scope_excludes_stt_and_non_agent_keys() { + let accounts = agent_secret_accounts(); + assert!(accounts.contains(&"LLM_ASSISTIVE_API_KEY")); + assert!(accounts.contains(&"LLM_OPENAI_ACCOUNT_TOKENS")); + assert!(!accounts.contains(&"STT_API_KEY")); + assert!(!accounts.contains(&"LLM_API_KEY")); + assert!(!accounts.contains(&"GITHUB_TOKEN")); + } + + #[test] + fn agent_reset_marks_only_post_mutation_failures_for_relaunch() { + assert!( + !format!("{:?}", agent_reset_error(false, "prepare failed")) + .contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") + ); + assert!( + format!("{:?}", agent_reset_error(true, "Keychain failed")) + .contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") + ); + } + + #[test] + #[serial] + fn agent_reset_env_scope_removes_only_agent_rows() { + let sandbox = scratch("agent_env"); + let env_path = sandbox.join(".env"); + write( + &env_path, + b"# preserve this comment\nLLM_ASSISTIVE_MODEL=agent-model\nAGENT_WORKSPACE_ROOTS=~/work\nSTT_ENDPOINT=https://stt.example\nWHISPER_LANGUAGE=pl\nLLM_FORMATTING_MODEL=formatter\n", + ); + let _env_path = EnvGuard::set("CODESCRIBE_ENV_PATH", &env_path); + + Config::remove_env_keys(agent_env_keys()).expect("remove only Agent env rows"); + let rewritten = std::fs::read_to_string(&env_path).expect("read rewritten env"); + assert!(!rewritten.contains("LLM_ASSISTIVE_MODEL=")); + assert!(!rewritten.contains("AGENT_WORKSPACE_ROOTS=")); + assert!(rewritten.contains("# preserve this comment")); + assert!(rewritten.contains("STT_ENDPOINT=https://stt.example")); + assert!(rewritten.contains("WHISPER_LANGUAGE=pl")); + assert!(rewritten.contains("LLM_FORMATTING_MODEL=formatter")); + let _ = std::fs::remove_dir_all(&sandbox); + } + /// The reset scope follows `CODESCRIBE_DATA_DIR`, previews live counts, and /// moves the complete source into a recoverable Trash destination. #[test] diff --git a/core/config/loader.rs b/core/config/loader.rs index dcdcd222..01990243 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -1452,6 +1452,39 @@ impl Config { Ok(()) } + /// Remove a narrow set of persisted `.env` rows while preserving every + /// unrelated user-written row, comment and ordering. Used by scoped reset + /// flows; callers must name their owned keys explicitly. + pub fn remove_env_keys(keys: &[&str]) -> anyhow::Result<()> { + use crate::safe_path::{safe_read_to_string_bounded, safe_write_bounded}; + + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); + let path = Self::env_path(); + if !path.exists() { + return Ok(()); + } + let path = path.canonicalize()?; + let root = path + .parent() + .map(|parent| parent.to_path_buf()) + .unwrap_or_else(Self::config_dir); + let contents = safe_read_to_string_bounded(&path, &root)?; + let owned: HashSet<&str> = keys.iter().copied().collect(); + let output = contents + .lines() + .filter(|line| { + let key = line.trim().split_once('=').map(|(key, _)| key.trim()); + !key.is_some_and(|key| owned.contains(key)) + }) + .collect::>() + .join("\n"); + let output = (!output.is_empty()) + .then(|| format!("{output}\n")) + .unwrap_or_default(); + safe_write_bounded(&path, &root, &output) + } + /// Migrate legacy keys inside .env to the current contract. fn migrate_env_legacy_keys() { let env_path = Self::env_path(); diff --git a/macos/Codescribe/Bridge/codescribe_ffi.swift b/macos/Codescribe/Bridge/codescribe_ffi.swift index 9e3ab590..e02095f9 100644 --- a/macos/Codescribe/Bridge/codescribe_ffi.swift +++ b/macos/Codescribe/Bridge/codescribe_ffi.swift @@ -1305,6 +1305,19 @@ public protocol CodescribeConfigProtocol: AnyObject, Sendable { */ func onboardingProgress() -> UInt32 + /** + * Reset only durable Agent state. Conversations and tool/MCP files are + * moved to Trash; provider credentials are deleted from Keychain and are + * intentionally not recoverable. This deliberately does not use the + * full-app reset fence or its broad root move. + */ + func resetAgentData() throws + + /** + * Preview the Agent-only reset without changing disk or Keychain state. + */ + func resetAgentPreview() -> CsAgentResetPreview + /** * Move all local codescribe data to a recoverable folder in the user's * Trash, append an external audit entry, and optionally remove Keychain @@ -1793,6 +1806,30 @@ open func onboardingProgress() -> UInt32 { self.uniffiCloneHandle(),$0 ) }) +} + + /** + * Reset only durable Agent state. Conversations and tool/MCP files are + * moved to Trash; provider credentials are deleted from Keychain and are + * intentionally not recoverable. This deliberately does not use the + * full-app reset fence or its broad root move. + */ +open func resetAgentData()throws {try rustCallWithError(FfiConverterTypeCsError_lift) { + uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_data( + self.uniffiCloneHandle(),$0 + ) +} +} + + /** + * Preview the Agent-only reset without changing disk or Keychain state. + */ +open func resetAgentPreview() -> CsAgentResetPreview { + return try! FfiConverterTypeCsAgentResetPreview_lift(try! rustCall() { + uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_preview( + self.uniffiCloneHandle(),$0 + ) +}) } /** @@ -7140,6 +7177,71 @@ public func FfiConverterTypeCsAgentAvailability_lower(_ value: CsAgentAvailabili } +/** + * Non-secret impact summary for the narrowly-scoped Agent reset. Unlike the + * app-data reset, this never counts or touches recordings, transcripts, + * prompts, lexicon data, license state, or dictation preferences. + */ +public struct CsAgentResetPreview: Equatable, Hashable { + public var threads: UInt64 + public var files: UInt64 + public var totalBytes: UInt64 + public var secretsPresent: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(threads: UInt64, files: UInt64, totalBytes: UInt64, secretsPresent: Bool) { + self.threads = threads + self.files = files + self.totalBytes = totalBytes + self.secretsPresent = secretsPresent + } + + +} + +#if compiler(>=6) +extension CsAgentResetPreview: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCsAgentResetPreview: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CsAgentResetPreview { + return + try CsAgentResetPreview( + threads: FfiConverterUInt64.read(from: &buf), + files: FfiConverterUInt64.read(from: &buf), + totalBytes: FfiConverterUInt64.read(from: &buf), + secretsPresent: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: CsAgentResetPreview, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.threads, into: &buf) + FfiConverterUInt64.write(value.files, into: &buf) + FfiConverterUInt64.write(value.totalBytes, into: &buf) + FfiConverterBool.write(value.secretsPresent, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsAgentResetPreview_lift(_ buf: RustBuffer) throws -> CsAgentResetPreview { + return try FfiConverterTypeCsAgentResetPreview.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsAgentResetPreview_lower(_ value: CsAgentResetPreview) -> RustBuffer { + return FfiConverterTypeCsAgentResetPreview.lower(value) +} + + /** * Agentic-lane readiness verdict + rows. `ready` reflects the CORE capability * gate only (assistive provider configured + its API key set + native tools @@ -13832,6 +13934,12 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_onboarding_progress() != 28580) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_data() != 41646) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_preview() != 8135) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_app_data() != 24728) { return InitializationResult.apiChecksumMismatch } diff --git a/macos/Codescribe/Bridge/codescribe_ffiFFI.h b/macos/Codescribe/Bridge/codescribe_ffiFFI.h index bcc6d2c9..c90a36da 100644 --- a/macos/Codescribe/Bridge/codescribe_ffiFFI.h +++ b/macos/Codescribe/Bridge/codescribe_ffiFFI.h @@ -798,6 +798,16 @@ RustBuffer uniffi_codescribe_ffi_fn_method_codescribeconfig_onboarding_mode(uint uint32_t uniffi_codescribe_ffi_fn_method_codescribeconfig_onboarding_progress(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +void uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_data(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +RustBuffer uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_agent_preview(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_APP_DATA #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CODESCRIBECONFIG_RESET_APP_DATA void uniffi_codescribe_ffi_fn_method_codescribeconfig_reset_app_data(uint64_t ptr, int8_t include_keys, int8_t include_prompts, RustCallStatus *_Nonnull out_status @@ -2257,6 +2267,18 @@ uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_onboarding_mode( #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_ONBOARDING_PROGRESS uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_onboarding_progress(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_DATA +uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_data(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_AGENT_PREVIEW +uint16_t uniffi_codescribe_ffi_checksum_method_codescribeconfig_reset_agent_preview(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_METHOD_CODESCRIBECONFIG_RESET_APP_DATA diff --git a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift index 7060f504..20c79502 100644 --- a/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift +++ b/macos/Codescribe/Screens/AgentChat/AgentChatStore.swift @@ -2080,7 +2080,9 @@ final class AgentChatStore: ObservableObject { let attachments: [PersistedAttachmentMetadata] } - private static let attachmentMetadataDefaultsKey = "AgentChatStore.attachmentMetadata.v1" + /// Agent-owned attachment sidecar. Reset Agent clears this alongside queued + /// accepted turns; UI layout preferences intentionally remain untouched. + static let attachmentMetadataDefaultsKey = "AgentChatStore.attachmentMetadata.v1" // MARK: Durable accepted-turn sidecar (queue persistence) diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index 36735aec..714d677a 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -80,6 +80,8 @@ protocol SettingsEngine { // optionally remove Keychain keys. MCP-only clear is a separate concern. func resetPreview() -> CsResetPreview func resetAppData(includeKeys: Bool, includePrompts: Bool) throws + func resetAgentPreview() -> CsAgentResetPreview + func resetAgentData() throws func clearMcpConfiguration() throws } @@ -184,6 +186,8 @@ final class RealSettingsEngine: SettingsEngine { func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { try config.resetAppData(includeKeys: includeKeys, includePrompts: includePrompts) } + func resetAgentPreview() -> CsAgentResetPreview { config.resetAgentPreview() } + func resetAgentData() throws { try config.resetAgentData() } func clearMcpConfiguration() throws { try config.clearMcpConfiguration() } } @@ -204,11 +208,13 @@ struct MockSettingsEngine: SettingsEngine { var lexiconEntriesLoader: (() throws -> [CsLexiconEntry])? var audioSnapshot: CsAudioInputSnapshot = .sample var resetPreviewValue: CsResetPreview = .sample + var agentResetPreviewValue: CsAgentResetPreview = .sample var formattingSnapshot: CsPromptSnapshot = .sampleFormatting var assistiveSnapshot: CsPromptSnapshot = .sampleAssistive var promptSaveObserver: ((String, String) throws -> Void)? var promptRestoreObserver: ((String) throws -> Void)? var resetAppDataObserver: ((Bool, Bool) throws -> Void)? + var resetAgentDataObserver: (() throws -> Void)? var clearMcpConfigurationObserver: (() throws -> Void)? var settingsLoader: (() -> CsSettings)? var updateConfigManyObserver: (([CsConfigEntry]) throws -> Void)? @@ -367,6 +373,8 @@ struct MockSettingsEngine: SettingsEngine { func resetAppData(includeKeys: Bool, includePrompts: Bool) throws { try resetAppDataObserver?(includeKeys, includePrompts) } + func resetAgentPreview() -> CsAgentResetPreview { agentResetPreviewValue } + func resetAgentData() throws { try resetAgentDataObserver?() } func clearMcpConfiguration() throws { try clearMcpConfigurationObserver?() } @@ -394,6 +402,15 @@ extension CsResetPreview { ) } +extension CsAgentResetPreview { + static let sample = CsAgentResetPreview( + threads: 3, + files: 8, + totalBytes: 12_288, + secretsPresent: true + ) +} + extension CsPromptSnapshot { static let sampleFormatting = CsPromptSnapshot( content: CsSettings.samplePrompt, diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 2b2471cd..adb534b4 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -545,6 +545,10 @@ func resetConfirmationMatches(_ text: String) -> Bool { text == "RESET" } +func resetAgentConfirmationMatches(_ text: String) -> Bool { + text == "RESET AGENT" +} + /// Rust marks failures that occurred after the first irreversible data move. /// Those are errors for audit/recovery, but staying in the current process is /// no longer safe because its app-data plane is deliberately latched. @@ -552,6 +556,10 @@ func resetFailureRequiresRelaunch(_ description: String) -> Bool { description.contains("CODESCRIBE_RESET_RELAUNCH_REQUIRED") } +func agentResetFailureRequiresRelaunch(_ description: String) -> Bool { + description.contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") +} + func resetImpactSummary(_ preview: CsResetPreview) -> String { let recordings = preview.audioFiles == 1 ? "recording" : "recordings" let days = preview.transcriptDays == 1 ? "day" : "days" @@ -788,11 +796,29 @@ extension UInt64 { /// guard would terminate the freshly-launched copy. @MainActor enum AppRelaunch { + /// Erases only durable Agent queue/attachment state. Agent window layout and + /// all non-Agent preferences (including hotkeys and dictation) deliberately + /// survive this narrow reset. + static func clearAgentDefaults(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: AgentChatStore.acceptedTurnsDefaultsKey) + defaults.removeObject(forKey: AgentChatStore.attachmentMetadataDefaultsKey) + defaults.synchronize() + } + + static func clearAgentDefaultsAndRelaunch() { + clearAgentDefaults() + relaunch() + } + static func clearDefaultsAndRelaunch() { if let bundleId = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: bundleId) UserDefaults.standard.synchronize() } + relaunch() + } + + private static func relaunch() { let bundlePath = Bundle.main.bundlePath let task = Process() task.launchPath = "/bin/sh" @@ -925,6 +951,7 @@ final class SettingsViewModel: ObservableObject { @Published private(set) var audioInput: CsAudioInputSnapshot @Published private(set) var audioInputReadError: String? @Published private(set) var resetPreview: CsResetPreview + @Published private(set) var agentResetPreview: CsAgentResetPreview @Published private(set) var licenseStatus: CsLicenseStatus /// Provider ids with a "Sign in with ChatGPT" flow in flight (browser open, /// local callback server listening). Guards double-clicks. @@ -1031,6 +1058,7 @@ final class SettingsViewModel: ObservableObject { self.audioInput = .sample self.audioInputReadError = nil self.resetPreview = .sample + self.agentResetPreview = .sample self.licenseStatus = self.licenseService.status // K4: tray cycles arrive on the bus; reload Settings badge display. // Register after every stored property is initialized (Swift init order). @@ -1219,7 +1247,11 @@ final class SettingsViewModel: ObservableObject { try hotkeys.resetToDefaults() loadHotkeys() } catch { - lastError = String(describing: error) + let description = String(describing: error) + lastError = description + if agentResetFailureRequiresRelaunch(description) { + AppRelaunch.clearAgentDefaultsAndRelaunch() + } } } @@ -1441,6 +1473,41 @@ final class SettingsViewModel: ObservableObject { AppRelaunch.clearDefaultsAndRelaunch() } + // MARK: - Reset Agent (narrow destructive action) + + func refreshAgentResetPreview() { + guard let engine else { return } + agentResetPreview = engine.resetAgentPreview() + } + + func resetAgentImpactDescription() -> String { + let preview = agentResetPreview + let threadWord = preview.threads == 1 ? "thread" : "threads" + let fileWord = preview.files == 1 ? "file" : "files" + let secretState = preview.secretsPresent ? "Provider API/OAuth secrets are present and will be deleted permanently." : "No provider API/OAuth secrets are currently stored." + return "Moves \(preview.threads) Agent \(threadWord) and \(preview.files) Agent \(fileWord) to Trash. " + + secretState + + " Recordings, transcriptions, dictionary and lexicon data, quality corpus and reports, prompts, audio, hotkeys, dictation settings, license, and macOS permissions stay unchanged." + } + + func resetAgentData() { + guard let engine else { return } + do { + try engine.resetAgentData() + mcpTestResults = [:] + reloadMcpServers() + refreshAgentStatus() + refreshAgentResetPreview() + AppRelaunch.clearAgentDefaultsAndRelaunch() + } catch { + let description = String(describing: error) + lastError = description + if agentResetFailureRequiresRelaunch(description) { + AppRelaunch.clearAgentDefaultsAndRelaunch() + } + } + } + func clearMcpConfiguration() { guard let engine else { return } do { diff --git a/macos/Codescribe/Screens/Settings/UserPanel.swift b/macos/Codescribe/Screens/Settings/UserPanel.swift index 022b8d2f..f6226e7a 100644 --- a/macos/Codescribe/Screens/Settings/UserPanel.swift +++ b/macos/Codescribe/Screens/Settings/UserPanel.swift @@ -188,6 +188,9 @@ struct UserPanel: View { } .padding(.top, 11) + ResetAgentSection(model: model) + .padding(.top, 24) + ResetAppDataSection(model: model) .padding(.top, 30) } @@ -263,6 +266,74 @@ struct UserPanel: View { // MARK: - Danger zone +/// A deliberately narrow reset for Agent state. It is separate from the full +/// app-data reset so it cannot clear dictation, recordings, prompts or license. +private struct ResetAgentSection: View { + @ObservedObject var model: SettingsViewModel + @State private var confirming = false + @State private var confirmationText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Reset Agent") + .foregroundStyle(CSColor.dangerLight) + + Text( + "Moves only Agent conversations, runtime identity, MCP and tool state to Trash. " + + "Provider API and OAuth secrets are deleted permanently. " + + "Recordings, transcriptions, dictionary, lexicon, quality reports, prompts, audio, hotkeys, dictation, license, and macOS permissions are preserved." + ) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 6) + + Button(role: .destructive) { + model.refreshAgentResetPreview() + confirmationText = "" + confirming = true + } label: { + Text("Reset Agent…") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.dangerLight) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.danger.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.danger.opacity(0.42), lineWidth: 1) + ) + } + .csFocusRing(cornerRadius: 8) + .padding(.top, 13) + .accessibilityLabel("Reset Agent. Destructive action.") + .accessibilityHint("Shows Agent-only impact and requires typing RESET AGENT before continuing.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .fill(CSColor.danger.opacity(0.055)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.card, style: .continuous) + .strokeBorder(CSColor.danger.opacity(0.55), lineWidth: 1) + ) + .alert("Reset Agent?", isPresented: $confirming) { + TextField("Type RESET AGENT to continue", text: $confirmationText) + Button("Cancel", role: .cancel) { confirmationText = "" } + Button("Reset Agent", role: .destructive) { model.resetAgentData() } + .disabled(!resetAgentConfirmationMatches(confirmationText)) + } message: { + Text(model.resetAgentImpactDescription()) + } + } +} + /// The full-data reset lives only at the foot of User settings, away from MCP /// editing. Data is recoverable from Trash; Keychain deletion remains opt-in. private struct ResetAppDataSection: View { diff --git a/macos/CodescribeTests/SettingsTruthTests.swift b/macos/CodescribeTests/SettingsTruthTests.swift index 67f5dfa8..bf5b1093 100644 --- a/macos/CodescribeTests/SettingsTruthTests.swift +++ b/macos/CodescribeTests/SettingsTruthTests.swift @@ -924,6 +924,61 @@ final class SettingsTruthTests: XCTestCase { ) } + func testAgentResetIsSeparatelyConfirmedAndNamesPreservedSurfaces() throws { + var calls = 0 + let preview = CsAgentResetPreview( + threads: 2, + files: 5, + totalBytes: 2_048, + secretsPresent: true + ) + let engine = MockSettingsEngine( + agentResetPreviewValue: preview, + resetAgentDataObserver: { calls += 1 } + ) + let model = SettingsViewModel(engine: engine) + + model.refreshAgentResetPreview() + XCTAssertEqual(model.agentResetPreview.threads, 2) + XCTAssertTrue(resetAgentConfirmationMatches("RESET AGENT")) + XCTAssertFalse(resetAgentConfirmationMatches("RESET")) + XCTAssertFalse(resetAgentConfirmationMatches("reset agent")) + XCTAssertTrue(model.resetAgentImpactDescription().contains("Recordings, transcriptions")) + XCTAssertTrue(model.resetAgentImpactDescription().contains("license")) + + try engine.resetAgentData() + XCTAssertEqual(calls, 1) + } + + func testAgentDefaultsResetLeavesHotkeysAndOtherPreferencesUntouched() { + let suite = "SettingsTruthTests.agent-reset-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + XCTFail("create isolated defaults suite") + return + } + defaults.set("queued Agent turn", forKey: AgentChatStore.acceptedTurnsDefaultsKey) + defaults.set("attachment metadata", forKey: AgentChatStore.attachmentMetadataDefaultsKey) + defaults.set("ctrl-space", forKey: "codescribe.hotkey") + defaults.set("dictation preference", forKey: "codescribe.dictation") + + AppRelaunch.clearAgentDefaults(defaults: defaults) + + XCTAssertNil(defaults.object(forKey: AgentChatStore.acceptedTurnsDefaultsKey)) + XCTAssertNil(defaults.object(forKey: AgentChatStore.attachmentMetadataDefaultsKey)) + XCTAssertEqual(defaults.string(forKey: "codescribe.hotkey"), "ctrl-space") + XCTAssertEqual(defaults.string(forKey: "codescribe.dictation"), "dictation preference") + defaults.removePersistentDomain(forName: suite) + } + + func testOnlyPostMutationAgentResetErrorsRequireRelaunch() { + XCTAssertFalse(agentResetFailureRequiresRelaunch("failed to prepare Agent Trash destination")) + XCTAssertTrue( + agentResetFailureRequiresRelaunch( + "CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED: failed to remove Agent secret" + ) + ) + } + func testOnlyPostDestructiveResetErrorsRequireRelaunch() { XCTAssertFalse(resetFailureRequiresRelaunch("failed to prepare Trash destination")) XCTAssertTrue( From e02879c849137e9db54df4d7fd0ce851af89314e Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:34:50 +0200 Subject: [PATCH 05/18] [codex/vc-workflow] fix: contain FINAL transcript rendering - stop animating the AppKit-backed TextEditor across overlay modes - clip the transcript body at its sibling boundary - render the observed minimum-height geometry and assert the action row stays clean Authored-By: Codex --- .../Overlay/DictationOverlayView.swift | 9 ++- macos/CodescribeTests/OverlayStateTests.swift | 77 +++++++++++++++++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index a94c2bca..1f0feee3 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -268,8 +268,12 @@ struct DictationOverlayView: View { listeningBody .transition(.opacity.combined(with: .offset(y: 8))) case .formatted: + // TextEditor is an AppKit-backed platform view. Moving it with a SwiftUI + // transition can leave its native text layer painting at the old frame + // while the surrounding stack has already settled, which lets transcript + // glyphs bleed through the action row during finalization. The FINAL body + // swaps in place; the containing clip below is the hard sibling boundary. formattedBody - .transition(.opacity.combined(with: .offset(y: 8))) case .noSpeech: noSpeechBody .transition(.opacity.combined(with: .offset(y: 8))) @@ -284,6 +288,9 @@ struct DictationOverlayView: View { .padding(.horizontal, 20) .padding(.top, 4) .padding(.bottom, 10) + // Platform-backed TextEditor content must never paint into the action/footer + // siblings, including the mode-transition and live-resize frames. + .clipped() .animation(CSMotion.floatIn, value: state.mode) } diff --git a/macos/CodescribeTests/OverlayStateTests.swift b/macos/CodescribeTests/OverlayStateTests.swift index e2f3db76..32ecd7cd 100644 --- a/macos/CodescribeTests/OverlayStateTests.swift +++ b/macos/CodescribeTests/OverlayStateTests.swift @@ -889,7 +889,8 @@ final class OverlayStateTests: XCTestCase { state.finishControllerRecording() XCTAssertEqual(state.formattedText, "Surowe słowa operatora.") - XCTAssertTrue(state.canRevert, "auto-formatted FINAL must offer Revert to the raw first version") + XCTAssertTrue( + state.canRevert, "auto-formatted FINAL must offer Revert to the raw first version") state.revertFormat() XCTAssertEqual(state.formattedText, "surowe słowa operatora") XCTAssertFalse(state.canRevert) @@ -1523,12 +1524,14 @@ final class OverlayStateTests: XCTestCase { ], preview: "dalej" ) - XCTAssertEqual(runs, [ - .highlight( - OverlayCanvas.lexiconHighlight( - utteranceId: 1, start: 0, replacement: "Reports", before: "RIPOS")!), - .text(" i Edyta dalej"), - ]) + XCTAssertEqual( + runs, + [ + .highlight( + OverlayCanvas.lexiconHighlight( + utteranceId: 1, start: 0, replacement: "Reports", before: "RIPOS")!), + .text(" i Edyta dalej"), + ]) } func testHighlightScreenshotRendersLexiconAndGap() throws { @@ -1571,4 +1574,64 @@ final class OverlayStateTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: dest.path)) } + @MainActor + func testFormattedOverlayMinimumHeightSnapshotRenders() throws { + let state = OverlayState.previewListening() + state.formattedText = Array( + repeating: + "Choose Insert to paste the text where you want it and press Return. The clipboard is untouched.", + count: 20 + ).joined(separator: "\n") + let size = CGSize( + width: 617, + height: DictationOverlayWindow.minSize.height + ) + let hostingView = NSHostingView( + rootView: DictationOverlayView(state: state) + .environment(\.csTextScale, 0.8) + .frame(width: size.width, height: size.height) + .preferredColorScheme(.dark) + ) + hostingView.frame = CGRect(origin: .zero, size: size) + hostingView.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + state.mode = .formatted + hostingView.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + guard let bitmap = hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds) else { + return XCTFail("could not allocate the formatted overlay bitmap") + } + hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap) + guard let png = bitmap.representation(using: .png, properties: [:]) else { + XCTFail("could not render the formatted overlay") + return + } + let dest = FileManager.default.temporaryDirectory + .appendingPathComponent("codescribe-formatted-overlay-min-height.png") + try png.write(to: dest) + XCTAssertGreaterThan(png.count, 800) + + // The middle of the action row is deliberately empty between To Agent and + // Close. Bright pixels here mean the native TextEditor escaped its body and + // painted transcript glyphs underneath the action controls (the observed + // live FINAL regression). Hairlines and glass stay below this threshold. + var leakedBrightPixels = 0 + for x in 330..<520 { + for y in 35..<65 { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { + continue + } + if color.redComponent > 0.7 && color.greenComponent > 0.7 + && color.blueComponent > 0.7 && color.alphaComponent > 0.5 + { + leakedBrightPixels += 1 + } + } + } + XCTAssertLessThan( + leakedBrightPixels, 5, + "formatted transcript painted into the action-row spacer" + ) + } + } From be6a02ca460f263c9ae7410868da4fb5a5b0c349 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:42:09 +0200 Subject: [PATCH 06/18] [codex/vc-workflow] fix: make Agent reset surgical - preserve every non-Agent settings value and fail closed on malformed JSON - delete only MCP connector tokens referenced by the active Agent config - keep Agent relaunch handling out of the hotkey reset path Authored-By: Codex --- bridge/src/config.rs | 107 +++++++-- core/config/settings.rs | 210 ++++++++++++++++++ core/mcp/config_store.rs | 2 +- core/mcp/mod.rs | 2 +- .../Screens/Settings/SettingsViewModel.swift | 14 +- .../Screens/Settings/UserPanel.swift | 5 +- 6 files changed, 306 insertions(+), 34 deletions(-) diff --git a/bridge/src/config.rs b/bridge/src/config.rs index e24f125e..eba43bbb 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -1258,6 +1258,17 @@ impl CodescribeConfig { /// intentionally not recoverable. This deliberately does not use the /// full-app reset fence or its broad root move. pub fn reset_agent_data(&self) -> Result<(), CsError> { + // Resolve connector-owned Keychain accounts before `mcp.json` is moved. + // A malformed config fails closed while no destructive mutation has + // started; otherwise its bearer tokens would become invisible orphans. + let mut secret_accounts: Vec = agent_secret_accounts() + .iter() + .map(|account| (*account).to_string()) + .collect(); + secret_accounts.extend( + agent_connector_secret_accounts() + .map_err(|error| agent_reset_error(false, error.to_string()))?, + ); let trash = codescribe_trash_dir()?; let destination = create_agent_reset_destination(&trash)?; let paths = agent_reset_paths(); @@ -1293,7 +1304,7 @@ impl CodescribeConfig { ) })?; - for account in agent_secret_accounts() { + for account in &secret_accounts { mutation_started = true; delete_key(account).map_err(|error| { agent_reset_error( @@ -1580,6 +1591,22 @@ fn agent_secret_accounts() -> &'static [&'static str] { ] } +/// Connector tokens created by the MCP Settings UI use a private account +/// namespace and are referenced from `mcp.json`. Delete only those app-owned +/// accounts: a hand-written auth_ref may deliberately point at a shared secret +/// such as GITHUB_TOKEN and Reset Agent must not infer ownership of it. +fn agent_connector_secret_accounts() -> anyhow::Result> { + let mut accounts: Vec = + codescribe_core::mcp::list_servers_at(&Config::config_dir().join("mcp.json"))? + .into_iter() + .filter_map(|server| server.auth_ref) + .filter(|account| account.starts_with("MCP_CONNECTOR_") && account.ends_with("_TOKEN")) + .collect(); + accounts.sort(); + accounts.dedup(); + Ok(accounts) +} + /// Legacy/power-user Agent rows that can otherwise outlive settings.json and /// become the effective provider again after relaunch. Keep this list narrow: /// dictation, formatting, audio and hotkey rows are intentionally absent. @@ -1603,12 +1630,21 @@ fn agent_env_keys() -> &'static [&'static str] { } fn agent_reset_preview_for_paths(paths: &[PathBuf]) -> CsAgentResetPreview { + let (connector_accounts, connector_scan_failed) = match agent_connector_secret_accounts() { + Ok(accounts) => (accounts, false), + Err(_) => (Vec::new(), true), + }; let mut preview = CsAgentResetPreview { - secrets_present: agent_secret_accounts().iter().any(|account| { - codescribe_core::config::keychain::load_key(account) - .map(|value| !value.trim().is_empty()) - .unwrap_or(false) - }), + secrets_present: connector_scan_failed + || agent_secret_accounts() + .iter() + .map(|account| (*account).to_string()) + .chain(connector_accounts) + .any(|account| { + codescribe_core::config::keychain::load_key(&account) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + }), ..CsAgentResetPreview::default() }; @@ -1647,18 +1683,7 @@ fn path_file_count(path: &Path) -> u64 { /// workspace and its tool policy. All transcription, audio, hotkey, lexicon, /// license and other ordinary app settings remain in the same JSON document. fn clear_agent_settings() -> anyhow::Result<()> { - let mut settings = UserSettings::load(); - settings.llm_assistive_endpoint = None; - settings.llm_assistive_model = None; - settings.llm_assistive_provider = None; - settings.openai_oauth_client_id = None; - settings.anthropic_oauth_client_id = None; - settings.xai_oauth_client_id = None; - settings.agent_workspace_roots = None; - settings.agent_permissions = None; - settings.agent_capabilities = None; - settings.agent_enter_sends = None; - settings.save() + UserSettings::remove_agent_owned_state() } fn create_agent_reset_destination(trash: &Path) -> anyhow::Result { @@ -2404,11 +2429,12 @@ fn ensure_known_account(account: &str) -> Result<(), CsError> { #[cfg(test)] mod reset_tests { use super::{ - CsResetPreview, ResetAuditEvent, agent_env_keys, agent_reset_error, agent_reset_paths, - agent_reset_preview_for_paths, agent_secret_accounts, app_data_dirs, append_reset_audit, - capture_base_prompts, clear_mcp_configuration_to, create_reset_destination, - move_path_recoverably, move_path_recoverably_with, move_reset_dirs_to_destination, - remove_path_without_following_symlinks, reset_preview_for_dirs, restore_base_prompts, + CsResetPreview, ResetAuditEvent, agent_connector_secret_accounts, agent_env_keys, + agent_reset_error, agent_reset_paths, agent_reset_preview_for_paths, agent_secret_accounts, + app_data_dirs, append_reset_audit, capture_base_prompts, clear_mcp_configuration_to, + create_reset_destination, move_path_recoverably, move_path_recoverably_with, + move_reset_dirs_to_destination, remove_path_without_following_symlinks, + reset_preview_for_dirs, restore_base_prompts, }; use chrono::{DateTime, Utc}; use codescribe_core::config::{Config, begin_app_data_reset}; @@ -2529,6 +2555,41 @@ mod reset_tests { assert!(!accounts.contains(&"GITHUB_TOKEN")); } + #[test] + #[serial] + fn agent_reset_connector_secret_scope_uses_only_managed_auth_refs() { + let sandbox = scratch("agent_connector_secret_scope"); + write( + &sandbox.join("mcp.json"), + br#"{ + "mcpServers": { + "managed": { + "command": "managed", + "args": [], + "auth_ref": "MCP_CONNECTOR_MANAGED_TOKEN" + }, + "shared": { + "command": "shared", + "args": [], + "auth_ref": "GITHUB_TOKEN" + }, + "handwritten": { + "command": "handwritten", + "args": [], + "auth_ref": "my_custom_secret" + } + } + }"#, + ); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &sandbox); + + assert_eq!( + agent_connector_secret_accounts().expect("read connector accounts"), + vec!["MCP_CONNECTOR_MANAGED_TOKEN"] + ); + let _ = std::fs::remove_dir_all(&sandbox); + } + #[test] fn agent_reset_marks_only_post_mutation_failures_for_relaunch() { assert!( diff --git a/core/config/settings.rs b/core/config/settings.rs index fde3b770..8e14f1f0 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -1235,6 +1235,83 @@ impl UserSettings { self.save_unlocked() } + /// Remove only Agent-owned fields from the persisted JSON document. + /// + /// This intentionally edits the raw JSON value instead of doing a + /// `load()` -> `save()` round-trip. `load()` is fail-soft and returns + /// defaults for malformed input; using it in a destructive reset could + /// therefore replace an unreadable settings file with defaults and erase + /// unrelated user choices. Unknown fields and every non-Agent subtree are + /// preserved value-for-value. A malformed document is left untouched. + pub fn remove_agent_owned_state() -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _settings_io = settings_io_lock(); + let path = Self::settings_path(); + if !path.exists() { + return Ok(()); + } + + let contents = fs::read_to_string(&path)?; + let mut value: serde_json::Value = serde_json::from_str(&contents)?; + let is_v2 = value.get("schema_version").is_some(); + + if is_v2 { + let before: SettingsV2 = serde_json::from_value(value.clone())?; + Self::validate_v2(&before)?; + } else { + let _: Self = serde_json::from_value(value.clone())?; + } + + let mut changed = false; + if is_v2 { + changed |= remove_json_keys_at( + &mut value, + &["speech", "assistive"], + &["llm_endpoint", "llm_model", "provider"], + )?; + changed |= remove_json_keys_at( + &mut value, + &["system"], + &[ + "agent_workspace_roots", + "openai_oauth_client_id", + "anthropic_oauth_client_id", + "xai_oauth_client_id", + ], + )?; + changed |= + remove_json_keys_at(&mut value, &["agent"], &["permissions", "capabilities"])?; + changed |= remove_json_keys_at(&mut value, &["interaction"], &["agent_enter_sends"])?; + + let after: SettingsV2 = serde_json::from_value(value.clone())?; + Self::validate_v2(&after)?; + } else { + changed |= remove_json_keys_at( + &mut value, + &[], + &[ + "llm_assistive_endpoint", + "llm_assistive_model", + "llm_assistive_provider", + "openai_oauth_client_id", + "anthropic_oauth_client_id", + "xai_oauth_client_id", + "agent_workspace_roots", + "agent_permissions", + "agent_capabilities", + "agent_enter_sends", + ], + )?; + let _: Self = serde_json::from_value(value.clone())?; + } + + if !changed { + return Ok(()); + } + let json = serde_json::to_string_pretty(&value)?; + Self::write_json_atomic(&path, &json) + } + /// Persist while the settings transaction lock and app-data admission are held. fn save_unlocked(&self) -> anyhow::Result<()> { let dir = Self::settings_dir(); @@ -1583,6 +1660,38 @@ impl UserSettings { } } +/// Remove named keys from an existing JSON object reached by `path`. Missing +/// sections are a no-op; a present non-object is an error so reset never +/// normalizes malformed state by destroying sibling settings. +fn remove_json_keys_at( + value: &mut serde_json::Value, + path: &[&str], + keys: &[&str], +) -> anyhow::Result { + let mut current = value; + for component in path { + let Some(next) = current.get_mut(*component) else { + return Ok(false); + }; + current = next; + } + let object = current.as_object_mut().ok_or_else(|| { + anyhow::anyhow!( + "settings path {} must be an object", + if path.is_empty() { + "".to_string() + } else { + path.join(".") + } + ) + })?; + let mut changed = false; + for key in keys { + changed |= object.remove(*key).is_some(); + } + Ok(changed) +} + /// Persistence is exercised against real files in a temp data dir, not against /// in-memory conversions — the failures these guard against (ghosted fields, /// migration loss, write amplification) only appear on the round-trip through @@ -1609,6 +1718,107 @@ mod tests { tmp } + #[test] + #[serial] + fn agent_reset_removes_only_owned_json_fields_and_preserves_unknowns() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let seeded = serde_json::json!({ + "schema_version": 3, + "interaction": { + "agent_enter_sends": true, + "auto_paste_enabled": false, + "future_interaction": "keep" + }, + "speech": { + "language": "pl", + "assistive": { + "llm_endpoint": "https://agent.example", + "llm_model": "agent-model", + "provider": "openai-responses" + }, + "formatting": { "level": "smart" }, + "future_speech": { "keep": true } + }, + "system": { + "agent_workspace_roots": ["/tmp/project"], + "openai_oauth_client_id": "openai-client", + "anthropic_oauth_client_id": "anthropic-client", + "xai_oauth_client_id": "xai-client", + "onboarding_mode": "basic", + "future_system": 42 + }, + "agent": { + "permissions": null, + "capabilities": null, + "future_agent": "keep" + }, + "future_top_level": { "keep": "exactly" } + }); + fs::write( + &path, + serde_json::to_string_pretty(&seeded).expect("serialize fixture"), + ) + .expect("seed settings"); + + UserSettings::remove_agent_owned_state().expect("surgical Agent settings reset"); + + let after: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read reset settings")) + .expect("parse reset settings"); + for pointer in [ + "/interaction/agent_enter_sends", + "/speech/assistive/llm_endpoint", + "/speech/assistive/llm_model", + "/speech/assistive/provider", + "/system/agent_workspace_roots", + "/system/openai_oauth_client_id", + "/system/anthropic_oauth_client_id", + "/system/xai_oauth_client_id", + "/agent/permissions", + "/agent/capabilities", + ] { + assert!( + after.pointer(pointer).is_none(), + "Agent field survived: {pointer}" + ); + } + for pointer in [ + "/interaction/auto_paste_enabled", + "/interaction/future_interaction", + "/speech/language", + "/speech/formatting", + "/speech/future_speech", + "/system/onboarding_mode", + "/system/future_system", + "/agent/future_agent", + "/future_top_level", + ] { + assert_eq!( + after.pointer(pointer), + seeded.pointer(pointer), + "non-Agent field changed: {pointer}" + ); + } + } + + #[test] + #[serial] + fn agent_reset_refuses_malformed_settings_without_rewriting_bytes() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let malformed = b"{ this is not valid settings JSON"; + fs::write(&path, malformed).expect("seed malformed settings"); + + UserSettings::remove_agent_owned_state().expect_err("malformed settings must fail closed"); + + assert_eq!( + fs::read(&path).expect("read malformed settings after refusal"), + malformed, + "Agent reset rewrote malformed settings" + ); + } + /// Zoom normalization: clamped to the supported range, rounded to two /// decimals, and the effective default encoded as `None` so it is omitted /// from the file entirely. diff --git a/core/mcp/config_store.rs b/core/mcp/config_store.rs index 4a538950..34c3faae 100644 --- a/core/mcp/config_store.rs +++ b/core/mcp/config_store.rs @@ -64,7 +64,7 @@ pub fn list_servers() -> Result> { /// Path-explicit twin of [`list_servers`]. Every public entry point in this /// module delegates to an `_at` variant so the tests can drive the real logic /// against a temp dir instead of the operator's live `~/.codescribe/mcp.json`. -fn list_servers_at(path: &Path) -> Result> { +pub fn list_servers_at(path: &Path) -> Result> { let Some(config) = McpConfigFile::load_optional(path)? else { return Ok(Vec::new()); }; diff --git a/core/mcp/mod.rs b/core/mcp/mod.rs index 3e4c0643..9853d23a 100644 --- a/core/mcp/mod.rs +++ b/core/mcp/mod.rs @@ -23,7 +23,7 @@ pub use client::{ default_mcp_config_path, }; pub use config_store::{ - McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, + McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, list_servers_at, probe_server_blocking, remove_server, test_server_blocking, update_server, }; pub use secret_migration::{ diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index adb534b4..549e18f4 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -1247,11 +1247,7 @@ final class SettingsViewModel: ObservableObject { try hotkeys.resetToDefaults() loadHotkeys() } catch { - let description = String(describing: error) - lastError = description - if agentResetFailureRequiresRelaunch(description) { - AppRelaunch.clearAgentDefaultsAndRelaunch() - } + lastError = String(describing: error) } } @@ -1484,8 +1480,12 @@ final class SettingsViewModel: ObservableObject { let preview = agentResetPreview let threadWord = preview.threads == 1 ? "thread" : "threads" let fileWord = preview.files == 1 ? "file" : "files" - let secretState = preview.secretsPresent ? "Provider API/OAuth secrets are present and will be deleted permanently." : "No provider API/OAuth secrets are currently stored." - return "Moves \(preview.threads) Agent \(threadWord) and \(preview.files) Agent \(fileWord) to Trash. " + let secretState = + preview.secretsPresent + ? "Agent provider and MCP connector secrets are present and will be deleted permanently." + : "No Agent provider or MCP connector secrets are currently stored." + return + "Moves \(preview.threads) Agent \(threadWord) and \(preview.files) Agent \(fileWord) to Trash. " + secretState + " Recordings, transcriptions, dictionary and lexicon data, quality corpus and reports, prompts, audio, hotkeys, dictation settings, license, and macOS permissions stay unchanged." } diff --git a/macos/Codescribe/Screens/Settings/UserPanel.swift b/macos/Codescribe/Screens/Settings/UserPanel.swift index f6226e7a..bce2f114 100644 --- a/macos/Codescribe/Screens/Settings/UserPanel.swift +++ b/macos/Codescribe/Screens/Settings/UserPanel.swift @@ -280,7 +280,7 @@ private struct ResetAgentSection: View { Text( "Moves only Agent conversations, runtime identity, MCP and tool state to Trash. " - + "Provider API and OAuth secrets are deleted permanently. " + + "Agent provider and MCP connector secrets are deleted permanently. " + "Recordings, transcriptions, dictionary, lexicon, quality reports, prompts, audio, hotkeys, dictation, license, and macOS permissions are preserved." ) .font(CSFont.mono(11, .medium)) @@ -310,7 +310,8 @@ private struct ResetAgentSection: View { .csFocusRing(cornerRadius: 8) .padding(.top, 13) .accessibilityLabel("Reset Agent. Destructive action.") - .accessibilityHint("Shows Agent-only impact and requires typing RESET AGENT before continuing.") + .accessibilityHint( + "Shows Agent-only impact and requires typing RESET AGENT before continuing.") } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) From 9d8d49ae278fffc8be35efe2ca62857922b01eba Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 02:46:42 +0200 Subject: [PATCH 07/18] [codex/vc-workflow] fix: compare preferences semantically - Normalize the operator preferences plist through plutil before hashing. - Keep settings.json and dotenv byte-exact while avoiding plist encoding false positives. - Cover equivalent XML and binary plist storage with a regression test. Authored-By: Codex --- bin/codescribe-corpus.rs | 81 +++++++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/bin/codescribe-corpus.rs b/bin/codescribe-corpus.rs index dc231fc8..ae5c03e0 100644 --- a/bin/codescribe-corpus.rs +++ b/bin/codescribe-corpus.rs @@ -1673,31 +1673,47 @@ fn sha256_file(path: &Path) -> Result { Ok(format!("{:x}", hasher.finalize())) } +fn sha256_plist_semantics(path: &Path) -> Result { + let output = ProcessCommand::new("/usr/bin/plutil") + .args(["-convert", "xml1", "-o", "-", "--"]) + .arg(path) + .output() + .with_context(|| format!("canonicalize preferences plist {}", path.display()))?; + if !output.status.success() { + bail!( + "plutil could not canonicalize preferences plist {} (status={})", + path.display(), + output.status + ); + } + Ok(format!("{:x}", Sha256::digest(&output.stdout))) +} + fn operator_configuration_fingerprints() -> Result> { let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| anyhow!("HOME is unavailable for configuration fingerprinting"))?; - [ + let mut fingerprints = [ ( "settings_json", home.join("Library/Application Support/Codescribe/settings.json"), ), ("dotenv", home.join(".codescribe/.env")), - ( - "preferences_plist", - home.join("Library/Preferences/com.vetcoders.codescribe.plist"), - ), ] .into_iter() - .map(|(label, path)| { - let exists = path.is_file(); - Ok(FileFingerprint { - label: label.to_string(), - exists, - sha256: exists.then(|| sha256_file(&path)).transpose()?, - }) - }) - .collect() + .map(|(label, path)| fingerprint_file(label, &path)) + .collect::>>()?; + + let preferences = home.join("Library/Preferences/com.vetcoders.codescribe.plist"); + let exists = preferences.is_file(); + fingerprints.push(FileFingerprint { + label: "preferences_plist_semantic".to_string(), + exists, + sha256: exists + .then(|| sha256_plist_semantics(&preferences)) + .transpose()?, + }); + Ok(fingerprints) } fn fingerprint_file(label: &str, path: &Path) -> Result { @@ -1913,6 +1929,43 @@ mod tests { assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DISABLE_KEYCHAIN")); } + #[test] + fn plist_fingerprint_ignores_storage_encoding() { + let temp = tempfile::tempdir().unwrap(); + let xml_path = temp.path().join("preferences.xml.plist"); + let binary_path = temp.path().join("preferences.binary.plist"); + fs::write( + &xml_path, + br#" + + + + agentEnabled + + launchCount + 7 + + +"#, + ) + .unwrap(); + fs::copy(&xml_path, &binary_path).unwrap(); + let conversion = ProcessCommand::new("/usr/bin/plutil") + .args(["-convert", "binary1", "--"]) + .arg(&binary_path) + .status() + .unwrap(); + assert!(conversion.success()); + assert_ne!( + sha256_file(&xml_path).unwrap(), + sha256_file(&binary_path).unwrap() + ); + assert_eq!( + sha256_plist_semantics(&xml_path).unwrap(), + sha256_plist_semantics(&binary_path).unwrap() + ); + } + #[test] fn production_quality_report_uses_qube_keyboard_surface() { let entries = vec![ReportEntry { From dff74f554753d75256c60815b18891682e39604a Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 03:18:05 +0200 Subject: [PATCH 08/18] [codex/vc-workflow] harden corpus report gate - Replace reset helper lint debt with explicit control flow. - Bundle replay execution inputs so report rows cannot receive mismatched arguments. - Bound corpus reads and private audio publication through capability-safe helpers. - Pass the full static gate with zero Semgrep findings. Authored-By: Codex --- bin/codescribe-corpus.rs | 200 +++++++++++++++++---------------------- core/config/loader.rs | 8 +- 2 files changed, 94 insertions(+), 114 deletions(-) diff --git a/bin/codescribe-corpus.rs b/bin/codescribe-corpus.rs index ae5c03e0..420f356d 100644 --- a/bin/codescribe-corpus.rs +++ b/bin/codescribe-corpus.rs @@ -10,7 +10,7 @@ use std::collections::BTreeMap; use std::ffi::OsStr; use std::fmt::Write as _; -use std::fs::{self, File, OpenOptions}; +use std::fs::{self, OpenOptions}; use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::process::{Command as ProcessCommand, ExitCode, Stdio}; @@ -18,7 +18,7 @@ use std::str::FromStr; use std::time::Instant; #[cfg(unix)] -use std::os::unix::fs::{PermissionsExt, symlink}; +use std::os::unix::fs::PermissionsExt; use anyhow::{Context, Result, anyhow, bail}; use chrono::Utc; @@ -32,6 +32,7 @@ use codescribe::qube_report::{ use codescribe_core::asr_session::GatewaySessionAvailability; use codescribe_core::config::UserSettings; use codescribe_core::pipeline::contracts::{EngineEvent, LayerSource}; +use codescribe_core::util::safe_path::{safe_open, safe_symlink_or_copy_bounded}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -1079,48 +1080,25 @@ async fn run_worker(args: WorkerArgs) -> Result<()> { .await; let wall_seconds = started.elapsed().as_secs_f64(); total_audio_seconds_executed += duration_seconds; + let execution = ReplayExecutionContext { + clip, + reference, + truth: &truth, + run, + profile: args.profile, + duration_seconds, + sample_rate, + wall_seconds, + audio_rel_path: &quality_audio_rel, + }; match replay { Ok(replay) => { - quality_entries.push(success_quality_entry( - clip, - reference, - &truth, - run, - args.profile, - duration_seconds, - &quality_audio_rel, - &replay, - )); - rows.push(success_row( - clip, - reference, - &truth, - run, - duration_seconds, - sample_rate, - wall_seconds, - replay, - )?); + quality_entries.push(success_quality_entry(&execution, &replay)); + rows.push(success_row(&execution, replay)?); } Err(error) => { - quality_entries.push(failure_quality_entry( - clip, - reference, - &truth, - run, - args.profile, - duration_seconds, - &quality_audio_rel, - &format!("{error:#}"), - )); - rows.push(failure_row( - clip, - reference, - run, - duration_seconds, - sample_rate, - wall_seconds, - )?); + quality_entries.push(failure_quality_entry(&execution, &format!("{error:#}"))); + rows.push(failure_row(&execution)?); } } eprintln!( @@ -1227,43 +1205,58 @@ fn publish_quality_audio(quality_audio_dir: &Path, clip: &Clip) -> Result { + clip: &'a Clip, + reference: &'a Reference, + truth: &'a str, run: usize, profile: ReplayProfile, duration_seconds: f64, - audio_rel_path: &str, + sample_rate: u32, + wall_seconds: f64, + audio_rel_path: &'a str, +} + +fn success_quality_entry( + execution: &ReplayExecutionContext<'_>, replay: &codescribe::controller::production_replay::ProductionOverlayReplay, ) -> ReportEntry { - let raw_wer = word_error_rate(truth, &replay.live_text) as f32; - let raw_cer = character_error_rate(truth, &replay.live_text) as f32; - let post_wer = word_error_rate(truth, &replay.delivered_text) as f32; - let post_cer = character_error_rate(truth, &replay.delivered_text) as f32; + let raw_wer = word_error_rate(execution.truth, &replay.live_text) as f32; + let raw_cer = character_error_rate(execution.truth, &replay.live_text) as f32; + let post_wer = word_error_rate(execution.truth, &replay.delivered_text) as f32; + let post_cer = character_error_rate(execution.truth, &replay.delivered_text) as f32; let raw_state = if replay.live_text.trim().is_empty() { ReportTranscriptState::EmptyTranscript } else { ReportTranscriptState::TextCommitted }; ReportEntry { - id: format!("{}-run{run}-{}", opaque_id(&clip.sha256), profile.token()), - audio_path: opaque_id(&clip.sha256), - audio_rel_path: audio_rel_path.to_string(), + id: format!( + "{}-run{}-{}", + opaque_id(&execution.clip.sha256), + execution.run, + execution.profile.token() + ), + audio_path: opaque_id(&execution.clip.sha256), + audio_rel_path: execution.audio_rel_path.to_string(), reference_path: None, - duration_secs: duration_seconds as f32, + duration_secs: execution.duration_seconds as f32, transcripts: ReportTranscripts { raw: Some(replay.live_text.clone()), post: Some(replay.delivered_text.clone()), ai_formatted: None, cloud: None, - reference: Some(truth.to_string()), + reference: Some(execution.truth.to_string()), }, raw_semantics: Some(ReportTranscriptSemantics { state: raw_state, @@ -1281,24 +1274,20 @@ fn success_quality_entry( } } -fn failure_quality_entry( - clip: &Clip, - _reference: &Reference, - truth: &str, - run: usize, - profile: ReplayProfile, - duration_seconds: f64, - audio_rel_path: &str, - error: &str, -) -> ReportEntry { +fn failure_quality_entry(execution: &ReplayExecutionContext<'_>, error: &str) -> ReportEntry { ReportEntry { - id: format!("{}-run{run}-{}", opaque_id(&clip.sha256), profile.token()), - audio_path: opaque_id(&clip.sha256), - audio_rel_path: audio_rel_path.to_string(), + id: format!( + "{}-run{}-{}", + opaque_id(&execution.clip.sha256), + execution.run, + execution.profile.token() + ), + audio_path: opaque_id(&execution.clip.sha256), + audio_rel_path: execution.audio_rel_path.to_string(), reference_path: None, - duration_secs: duration_seconds as f32, + duration_secs: execution.duration_seconds as f32, transcripts: ReportTranscripts { - reference: Some(truth.to_string()), + reference: Some(execution.truth.to_string()), ..ReportTranscripts::default() }, raw_semantics: None, @@ -1449,16 +1438,10 @@ fn validate_worker_environment(profile: ReplayProfile, apple_bridge: &Path) -> R } fn success_row( - clip: &Clip, - reference: &Reference, - truth: &str, - run: usize, - duration_seconds: f64, - sample_rate: u32, - wall_seconds: f64, + execution: &ReplayExecutionContext<'_>, replay: codescribe::controller::production_replay::ProductionOverlayReplay, ) -> Result { - let reference_tokens = normalized_words(truth); + let reference_tokens = normalized_words(execution.truth); let delivered_tokens = normalized_words(&replay.delivered_text); let head_present = reference_tokens .iter() @@ -1493,19 +1476,20 @@ fn success_row( ) }) .count(); - let audio_hash_unchanged = sha256_file(&clip.path)? == clip.sha256; - let reference_hash_unchanged = sha256_file(&reference.path)? == reference.sha256; + let audio_hash_unchanged = sha256_file(&execution.clip.path)? == execution.clip.sha256; + let reference_hash_unchanged = + sha256_file(&execution.reference.path)? == execution.reference.sha256; Ok(ExecutionRow { - opaque_id: opaque_id(&clip.sha256), - run, - audio_sha256: clip.sha256.clone(), - reference_sha256: reference.sha256.clone(), - reference_kind: reference.kind, - duration_seconds, - sample_rate_hz: sample_rate, + opaque_id: opaque_id(&execution.clip.sha256), + run: execution.run, + audio_sha256: execution.clip.sha256.clone(), + reference_sha256: execution.reference.sha256.clone(), + reference_kind: execution.reference.kind, + duration_seconds: execution.duration_seconds, + sample_rate_hz: execution.sample_rate, status: "ok".to_string(), error_class: None, - wall_seconds, + wall_seconds: execution.wall_seconds, events: replay.events.len(), previews, sealed_finals, @@ -1523,10 +1507,10 @@ fn success_row( token_ratio, head_present, tail_present, - wer: word_error_rate(truth, &replay.delivered_text), - cer: character_error_rate(truth, &replay.delivered_text), - character_parity: normalized_character_parity(truth, &replay.delivered_text), - teacher_similarity: teacher_similarity(truth, &replay.delivered_text), + wer: word_error_rate(execution.truth, &replay.delivered_text), + cer: character_error_rate(execution.truth, &replay.delivered_text), + character_parity: normalized_character_parity(execution.truth, &replay.delivered_text), + teacher_similarity: teacher_similarity(execution.truth, &replay.delivered_text), final_pass_attempted: replay.final_pass_attempted, final_pass_skipped: replay.final_pass_skipped, lexicon_rewrites: replay.postprocess_stats.lexicon_rewrites, @@ -1536,25 +1520,18 @@ fn success_row( }) } -fn failure_row( - clip: &Clip, - reference: &Reference, - run: usize, - duration_seconds: f64, - sample_rate: u32, - wall_seconds: f64, -) -> Result { +fn failure_row(execution: &ReplayExecutionContext<'_>) -> Result { Ok(ExecutionRow { - opaque_id: opaque_id(&clip.sha256), - run, - audio_sha256: clip.sha256.clone(), - reference_sha256: reference.sha256.clone(), - reference_kind: reference.kind, - duration_seconds, - sample_rate_hz: sample_rate, + opaque_id: opaque_id(&execution.clip.sha256), + run: execution.run, + audio_sha256: execution.clip.sha256.clone(), + reference_sha256: execution.reference.sha256.clone(), + reference_kind: execution.reference.kind, + duration_seconds: execution.duration_seconds, + sample_rate_hz: execution.sample_rate, status: "error".to_string(), error_class: Some("production_replay_failed".to_string()), - wall_seconds, + wall_seconds: execution.wall_seconds, events: 0, previews: 0, sealed_finals: 0, @@ -1580,8 +1557,9 @@ fn failure_row( final_pass_skipped: false, lexicon_rewrites: 0, gate_drops: 0, - audio_hash_unchanged: sha256_file(&clip.path)? == clip.sha256, - reference_hash_unchanged: sha256_file(&reference.path)? == reference.sha256, + audio_hash_unchanged: sha256_file(&execution.clip.path)? == execution.clip.sha256, + reference_hash_unchanged: sha256_file(&execution.reference.path)? + == execution.reference.sha256, }) } @@ -1660,7 +1638,7 @@ fn opaque_id(sha256: &str) -> String { } fn sha256_file(path: &Path) -> Result { - let mut file = File::open(path).with_context(|| format!("open input {}", path.display()))?; + let mut file = safe_open(path).with_context(|| format!("open input {}", path.display()))?; let mut hasher = Sha256::new(); let mut buffer = [0_u8; 64 * 1024]; loop { diff --git a/core/config/loader.rs b/core/config/loader.rs index 01990243..e7bfea76 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -1479,9 +1479,11 @@ impl Config { }) .collect::>() .join("\n"); - let output = (!output.is_empty()) - .then(|| format!("{output}\n")) - .unwrap_or_default(); + let output = if output.is_empty() { + String::new() + } else { + format!("{output}\n") + }; safe_write_bounded(&path, &root, &output) } From 15da9ed62732a9810757d0dcc46b56c72dbb53b8 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 07:54:28 +0200 Subject: [PATCH 09/18] [codex/vc-implement] fix: keep live transcript selectable --- .../Overlay/DictationOverlayView.swift | 90 +++------ .../Overlay/LiveTranscriptTextView.swift | 176 ++++++++++++++++++ .../LiveTranscriptTextViewTests.swift | 73 ++++++++ 3 files changed, 271 insertions(+), 68 deletions(-) create mode 100644 macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift create mode 100644 macos/CodescribeTests/LiveTranscriptTextViewTests.swift diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift index 1f0feee3..c47f8ea2 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayView.swift @@ -44,10 +44,6 @@ struct DictationOverlayView: View { private let transcriptMinHeight: CGFloat = 84 private let buttonRadius: CGFloat = 10 - /// Anchor id for the live transcript's tail. `scrollTo` pins it to the bottom on - /// every append so the newest text stays visible without any user interaction. - private let transcriptBottomAnchor = "overlayTranscriptBottom" - var body: some View { GlassPanel(cornerRadius: CSRadius.window) { VStack(alignment: .leading, spacing: 0) { @@ -308,74 +304,32 @@ struct DictationOverlayView: View { } } - /// Scrollable live transcript that ALWAYS follows the tail: every append pins - /// the view to the newest text with no user interaction required. The follow is - /// unconditional and intentional here because this scroll only exists while - /// `.listening` — during a hold-to-talk session the modifier key is held, so the - /// user physically cannot scroll, and pinning to the bottom is the only way the - /// growing transcript stays legible (an earlier "pause on manual scroll up" - /// heuristic mis-read normal content overflow as a scroll gesture and killed the - /// follow exactly when it was needed, hiding the newest chunk). Manual scroll is - /// owned by the terminal `.formatted` TextEditor, which is never driven by this. - /// A `minHeight` reserves ~2–3 lines so the tail is visible even at the min - /// window size instead of collapsing behind the waveform. + /// Native live transcript: follows the newest words until the user clicks or + /// selects an older phrase. The `NSTextView` keeps that selection stable across + /// ongoing stream updates, so drag selection, Cmd-C and context-menu Copy work + /// during recording without stopping capture. A `minHeight` reserves ~2–3 lines + /// at the window floor. private var transcriptScroll: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .bottom, spacing: 2) { - if state.highlightsEnabled, !state.highlights.isEmpty { - OverlayHighlightCanvas( - runs: state.highlightCanvasRuns, - selectedId: state.selectedHighlightId, - onSelect: { state.selectHighlight($0) } - ) - } else { - Text(state.listeningDisplay) - .csFont(15, .medium) - .lineSpacing(5) - .foregroundStyle(CSColor.textBody) - .fixedSize(horizontal: false, vertical: true) - .accessibilityIdentifier("overlay-transcript-live") - } - BlinkingCaret() - } - if state.highlightsEnabled { - OverlayHighlightTeachBar( - highlights: state.highlights, - selectedId: state.selectedHighlightId, - onSelect: { state.selectHighlight($0) }, - onTeach: { state.sendHighlightToTeach($0) } - ) - .padding(.top, 8) - } - Color.clear - .frame(height: 1) - .id(transcriptBottomAnchor) + VStack(alignment: .leading, spacing: 0) { + LiveTranscriptTextView(runs: state.highlightCanvasRuns) + .overlay(alignment: .bottomTrailing) { + BlinkingCaret() + .padding(.trailing, 3) + .allowsHitTesting(false) } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(minHeight: transcriptMinHeight) - .accessibilityIdentifier("overlay-transcript-area") - .onChange(of: state.listeningDisplay) { _, _ in - scrollToTail(proxy) - } - .onAppear { scrollToTail(proxy) } - } - } - - /// Pin the live transcript to its bottom anchor. A short ease keeps rapid - /// word-by-word appends from snapping harshly while still tracking the tail. - private func scrollToTail(_ proxy: ScrollViewProxy) { - // Defer one runloop tick: `onChange` fires before SwiftUI lays out the - // freshly appended text, so scrolling synchronously targets the previous - // content height and clips the newest word. By the next tick the bottom - // anchor sits below the new tail. - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.14)) { - proxy.scrollTo(transcriptBottomAnchor, anchor: .bottom) + .frame(minHeight: transcriptMinHeight) + .accessibilityIdentifier("overlay-transcript-area") + if state.highlightsEnabled { + OverlayHighlightTeachBar( + highlights: state.highlights, + selectedId: state.selectedHighlightId, + onSelect: { state.selectHighlight($0) }, + onTeach: { state.sendHighlightToTeach($0) } + ) + .padding(.top, 8) } } + .frame(maxWidth: .infinity, alignment: .leading) } private var formattedBody: some View { diff --git a/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift b/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift new file mode 100644 index 00000000..89464867 --- /dev/null +++ b/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift @@ -0,0 +1,176 @@ +import AppKit +import SwiftUI + +/// Selection rules for the live transcript's native text view. +/// +/// The stream may append or replace its open tail while the user has an older +/// phrase selected. AppKit resets selection when its storage is replaced, so the +/// representable snapshots and restores a clamped UTF-16 range on every update. +/// Keeping this policy pure makes the P0 behavior testable without a recording. +enum LiveTranscriptSelectionPolicy { + static func preservedRange(_ selection: NSRange, updatedLength: Int) -> NSRange { + let safeLength = max(0, updatedLength) + let location = min(max(0, selection.location), safeLength) + let length = min(max(0, selection.length), safeLength - location) + return NSRange(location: location, length: length) + } + + static func followsTail(selection: NSRange, textLength: Int) -> Bool { + selection.length == 0 && selection.location >= max(0, textLength) + } +} + +/// Read-only AppKit transcript surface used while recording. +/// +/// `Text` plus SwiftUI's selection overlay loses its selection whenever the +/// rapidly-changing value is rebuilt. A real `NSTextView` owns the responder +/// chain instead: drag selection, Cmd-C, Select All and the standard context +/// menu keep working while the recording and transcript updates continue. +struct LiveTranscriptTextView: NSViewRepresentable { + let runs: [OverlayCanvasRun] + @Environment(\.csTextScale) private var textScale + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> NSScrollView { + let textView = Self.makeTextView() + textView.delegate = context.coordinator + + let scrollView = NSScrollView() + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.horizontalScrollElasticity = .none + scrollView.documentView = textView + + update(textView, coordinator: context.coordinator) + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? LiveTranscriptNativeTextView else { return } + update(textView, coordinator: context.coordinator) + } + + static func makeTextView() -> LiveTranscriptNativeTextView { + let textView = LiveTranscriptNativeTextView(usingTextLayoutManager: true) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = true + textView.importsGraphics = false + textView.allowsUndo = false + textView.drawsBackground = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainerInset = NSSize(width: 0, height: 0) + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: 0, + height: CGFloat.greatestFiniteMagnitude + ) + textView.setAccessibilityIdentifier("overlay-transcript-live") + textView.setAccessibilityLabel("Live transcript") + return textView + } + + private func update( + _ textView: LiveTranscriptNativeTextView, + coordinator: Coordinator + ) { + let rendered = attributedTranscript() + guard textView.attributedString() != rendered else { return } + + let previousSelection = textView.selectedRange() + let wasFollowingTail = coordinator.followsTail + coordinator.applyingUpdate = true + textView.textStorage?.setAttributedString(rendered) + + let updatedLength = rendered.length + if previousSelection.length > 0 || !wasFollowingTail { + textView.setSelectedRange( + LiveTranscriptSelectionPolicy.preservedRange( + previousSelection, + updatedLength: updatedLength + ) + ) + } else { + let tail = NSRange(location: updatedLength, length: 0) + textView.setSelectedRange(tail) + DispatchQueue.main.async { [weak textView, weak coordinator] in + guard let textView, coordinator?.followsTail == true else { return } + textView.scrollRangeToVisible(tail) + } + } + coordinator.applyingUpdate = false + } + + private func attributedTranscript() -> NSAttributedString { + let size = 15 * textScale + let descriptor = NSFontDescriptor(fontAttributes: [ + .family: FontLoader.spaceGrotesk, + .traits: [NSFontDescriptor.TraitKey.weight: NSFont.Weight.medium.rawValue], + ]) + let font = + NSFont(descriptor: descriptor, size: size) + ?? .systemFont(ofSize: size, weight: .medium) + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 5 + let result = NSMutableAttributedString() + + for run in runs { + let text: String + let color: NSColor + var extra: [NSAttributedString.Key: Any] = [:] + switch run { + case .text(let value): + text = value + color = NSColor(CSColor.textBody) + case .highlight(let highlight): + text = highlight.after + switch highlight.kind { + case .lexiconCorrected: + color = NSColor(highlight.taught ? CSColor.oliveLight : CSColor.terracottaLight) + case .speechGap: + color = NSColor(CSColor.amber) + extra[.underlineStyle] = NSUnderlineStyle.single.rawValue + extra[.underlineColor] = NSColor(CSColor.amber) + } + } + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + attributes.merge(extra) { _, replacement in replacement } + result.append(NSAttributedString(string: text, attributes: attributes)) + } + return result + } + + @MainActor + final class Coordinator: NSObject, NSTextViewDelegate { + var followsTail = true + var applyingUpdate = false + + func textViewDidChangeSelection(_ notification: Notification) { + guard !applyingUpdate, + let textView = notification.object as? NSTextView + else { return } + followsTail = LiveTranscriptSelectionPolicy.followsTail( + selection: textView.selectedRange(), + textLength: (textView.string as NSString).length + ) + } + } +} + +/// First-click selection is important because the overlay is deliberately a +/// non-activating panel: it must not steal focus merely by appearing, but an +/// explicit click in the transcript must immediately begin a drag selection. +final class LiveTranscriptNativeTextView: NSTextView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } +} diff --git a/macos/CodescribeTests/LiveTranscriptTextViewTests.swift b/macos/CodescribeTests/LiveTranscriptTextViewTests.swift new file mode 100644 index 00000000..7e3113b1 --- /dev/null +++ b/macos/CodescribeTests/LiveTranscriptTextViewTests.swift @@ -0,0 +1,73 @@ +import AppKit +import XCTest + +@testable import Codescribe + +@MainActor +final class LiveTranscriptTextViewTests: XCTestCase { + func testLiveTranscriptIsReadOnlySelectableAndAcceptsFirstClick() { + let textView = LiveTranscriptTextView.makeTextView() + + XCTAssertFalse(textView.isEditable) + XCTAssertTrue(textView.isSelectable) + XCTAssertTrue(textView.acceptsFirstMouse(for: nil)) + XCTAssertEqual( + textView.accessibilityIdentifier(), + "overlay-transcript-live" + ) + } + + func testSelectionSurvivesAnAppendAtTheSameUtf16Range() { + let selected = NSRange(location: 6, length: 8) + + XCTAssertEqual( + LiveTranscriptSelectionPolicy.preservedRange(selected, updatedLength: 42), + selected, + "new live words must not throw away an earlier selection" + ) + XCTAssertFalse( + LiveTranscriptSelectionPolicy.followsTail(selection: selected, textLength: 42), + "an active selection pauses automatic tail scrolling, not transcription" + ) + } + + func testSelectionIsClampedWhenTheOpenTailIsReplaced() { + XCTAssertEqual( + LiveTranscriptSelectionPolicy.preservedRange( + NSRange(location: 8, length: 20), + updatedLength: 15 + ), + NSRange(location: 8, length: 7) + ) + XCTAssertTrue( + LiveTranscriptSelectionPolicy.followsTail( + selection: NSRange(location: 15, length: 0), + textLength: 15 + ) + ) + } + + func testNativeCopyUsesOnlyTheCurrentSelection() throws { + let pasteboard = NSPasteboard.general + let oldItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in + let copy = NSPasteboardItem() + for type in item.types { + if let data = item.data(forType: type) { + copy.setData(data, forType: type) + } + } + return copy + } + defer { + pasteboard.clearContents() + pasteboard.writeObjects(oldItems) + } + + let textView = LiveTranscriptTextView.makeTextView() + textView.string = "alpha beta gamma" + textView.setSelectedRange(NSRange(location: 6, length: 4)) + textView.copy(nil) + + XCTAssertEqual(pasteboard.string(forType: .string), "beta") + } +} From f2e8c7c387db7b99ae8728b2bde720808d055f81 Mon Sep 17 00:00:00 2001 From: div0-space Date: Sat, 15 Aug 2026 08:55:29 +0200 Subject: [PATCH 10/18] [claude/vc-workflow] fix: stop signing runs from borrowing the user keychain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Codescribe run on 2026-08-15 popped "Codescribe wants to use the 'Vibecrafted-signing' keychain", with a password no human has. Measured at the time: the user search list AND the default keychain both pointed at a release's ephemeral keychain, while that release was still running. - add scripts/lib/keychain-session.sh — an ephemeral signing keychain that always gives the user domain back. It never takes the login session's default keychain (opt-in via KEYCHAIN_SESSION_SET_DEFAULT), snapshots the search list as structured argv instead of one joined string, traps EXIT INT TERM HUP, and unlists BEFORE deleting so cleanup still works when the keychain file was already destroyed - make restoration remove-self rather than replay-snapshot: two overlapping releases can no longer drop each other's keychain or resurrect a dead one - add scripts/keychain-doctor.sh — read-only diagnosis grading each entry resident/FOREIGN/STALE and the default ok/HIJACKED, printing a recovery line derived from the surviving entries, never canned login-only, and never mutating - add scripts/tests/keychain-session-test.sh (43 assertions) against a fake `security` and a temp HOME: spaces in paths, multiple keychains, failed build, SIGINT/SIGTERM, vanished keychain file, concurrent sessions, stale-entry reclaim, empty-list refusal, no secret leakage - wire release.yml onto the library; its cleanup step only deleted the keychain and never restored the search list - add the keychain-domain-clean canary and its GATE LEDGER row Authored-By: claude --- .github/workflows/release.yml | 51 ++- Makefile | 12 +- scripts/canaries.sh | 24 +- scripts/keychain-doctor.sh | 161 +++++++++ scripts/lib/keychain-session.sh | 458 ++++++++++++++++++++++++ scripts/tests/keychain-session-test.sh | 475 +++++++++++++++++++++++++ 6 files changed, 1162 insertions(+), 19 deletions(-) create mode 100755 scripts/keychain-doctor.sh create mode 100755 scripts/lib/keychain-session.sh create mode 100755 scripts/tests/keychain-session-test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba334c3c..a5ea8ecd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,21 +65,28 @@ jobs: env: CODESIGN_CERTIFICATE_BASE64: ${{ secrets.CODESIGN_CERTIFICATE_BASE64 }} CODESIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODESIGN_CERTIFICATE_PASSWORD }} + KEYCHAIN_SESSION_STATE_DIR: ${{ runner.temp }}/keychain-session run: | + set -euo pipefail CERT_PATH="$RUNNER_TEMP/codesign.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/codesign.keychain-db" echo "$CODESIGN_CERTIFICATE_BASE64" | base64 -D > "$CERT_PATH" - security create-keychain -p "" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "" "$KEYCHAIN_PATH" + + # The search-list / default-keychain dance is NOT inlined here any + # more. This step used to prepend an ephemeral keychain to the user + # search list and never put the list back — it relied on + # `delete-keychain` unlisting it, which only works while the file + # still exists. On a self-hosted runner that leaves the operator's + # own keychain domain pointing at a deleted path (2026-08-15 P0). + # scripts/lib/keychain-session.sh snapshots as structured argv, + # unlists before deleting, and is concurrency-safe. + KEYCHAIN_PATH="$(scripts/lib/keychain-session.sh begin codescribe-signing)" + echo "CODESCRIBE_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + # Read into a variable, never echo. The ephemeral password exists so + # the keychain is not world-openable on a shared runner. + KEYCHAIN_PASSWORD="$(cat "$(scripts/lib/keychain-session.sh password-file codescribe-signing)")" security import "$CERT_PATH" -P "$CODESIGN_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - existing_keychains=() - while IFS= read -r keychain; do - keychain="${keychain//\"/}" - [[ -n "$keychain" ]] && existing_keychains+=("$keychain") - done < <(security list-keychains -d user) - security list-keychains -d user -s "$KEYCHAIN_PATH" "${existing_keychains[@]}" - security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null security find-identity -v -p codesigning "$KEYCHAIN_PATH" # The identity now lives in the keychain; the raw PKCS12 has no # further use. Overwrite-then-unlink so the private key material @@ -187,12 +194,24 @@ jobs: appcast.xml generate_release_notes: true - - name: Remove signing keychain + - name: Release the signing keychain # Runs on every outcome: a failed build must not leave the imported - # Developer ID identity resident on the runner. + # Developer ID identity resident on the runner, AND must not leave the + # ephemeral keychain in the user search list or as the default + # keychain. `end` unlists first and deletes second, so it still cleans + # up when the keychain file has already been destroyed — the exact + # sequence that poisoned the operator's host on 2026-08-15. if: always() + env: + KEYCHAIN_SESSION_STATE_DIR: ${{ runner.temp }}/keychain-session run: | - KEYCHAIN_PATH="$RUNNER_TEMP/codesign.keychain-db" - if [ -f "$KEYCHAIN_PATH" ]; then - security delete-keychain "$KEYCHAIN_PATH" + if [ ! -x scripts/lib/keychain-session.sh ]; then + echo "::warning::checkout missing; cannot release the signing keychain" + exit 0 fi + scripts/lib/keychain-session.sh end codescribe-signing + # Read-only verdict on what the runner's keychain domain looks like + # now. Advisory: a dirty domain must be visible, not silent, but it + # must not fail an otherwise successful release. + scripts/keychain-doctor.sh || \ + echo "::warning::runner keychain domain still has stale entries after cleanup" diff --git a/Makefile b/Makefile index 2b337579..1fcfcf55 100644 --- a/Makefile +++ b/Makefile @@ -311,7 +311,8 @@ bump-major: # gate: verify class=hermetic ci=yes -- the workspace test set + doctests + env registry + this ledger; the command rust.yml runs # gate: verify-canaries class=hermetic ci=no -- claim-vs-execution canaries that read repo files only (scripts/canaries.sh); each row is born from a named incident # gate: verify-swift-format class=static ci=no -- swift-format lint --strict over macos/Codescribe + macos/CodescribeTests; skips the generated UniFFI binding; no Swift tests (that is test-swift) -# gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity (scripts/canaries.sh --host) +# gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity, keychain domain cleanliness (scripts/canaries.sh --host) +# gate: test-keychain-session class=hermetic ci=no -- ephemeral signing-keychain contract (scripts/tests/keychain-session-test.sh) against a FAKE security binary and a temp HOME; touches no real keychain # gate: verify-dmg class=operator ci=no -- fail-closed payload check against an already-built DMG; release.yml runs the same check via scripts/verify-dmg-payload.sh, not via this target # gate: test class=operator ci=no -- workspace tests + #[ignore] real-API tests + STT pipeline; sources ~/.codescribe/.env and opens Console # gate: test-quick class=operator ci=no -- workspace tests only, but still sources ~/.codescribe/.env and opens Console @@ -1110,6 +1111,15 @@ verify-canaries: smoke-canaries: @bash scripts/canaries.sh --host +# The signing keychain borrows the operator's user keychain domain. This proves +# it always gives it back — on success, on failure, on Ctrl-C, under concurrent +# releases, and when the keychain file was destroyed before cleanup ran (the +# 2026-08-15 P0). Hermetic: `security` is a fake binary in a temp dir and HOME +# is redirected, so running it on this host cannot touch a real keychain. +.PHONY: test-keychain-session +test-keychain-session: + @bash scripts/tests/keychain-session-test.sh + .PHONY: canary-catalog canary-catalog: @bash scripts/canaries.sh --list diff --git a/scripts/canaries.sh b/scripts/canaries.sh index 1aed4901..11a01c84 100755 --- a/scripts/canaries.sh +++ b/scripts/canaries.sh @@ -69,7 +69,8 @@ measure-uses-product-path|hermetic|the latency benchmark measures a hard-coded m dist-inputs|host|a release chain discovers a missing key minutes (or one notarisation) too late|2026-08-09: 'VAR=x make a && make b' scoped the licence key to the first command; SUPublicEDKey failed AFTER notarisation appcast-feed-live|host|the shipped SUFeedURL points at a feed that does not exist|2026-08-08: appcast never published — SUFeedURL 404, updater dead on arrival for every shipped build store-purity|host|the test suite writes fixtures into the operator's live corrections store|2026-08-04 audit: 61 % of corrections.jsonl was Swift-test fixtures; leak closed by d2dec16a on 2026-08-09 -sparkle-key-parity|host|the installed app verifies updates against a different key than the one we sign with|2026-08-09: local release had no SUPublicEDKey source at all; parity was unverifiable" +sparkle-key-parity|host|the installed app verifies updates against a different key than the one we sign with|2026-08-09: local release had no SUPublicEDKey source at all; parity was unverifiable +keychain-domain-clean|host|a release borrowed the operator's user keychain domain and did not give it back|2026-08-15: the user search list AND the default keychain both pointed at /private/tmp/vibecrafted-release-3.7.1.*/dist/Vibecrafted-signing.keychain-db while build-vibecrafted-release.sh was still running — every app in the login session, Codescribe included, resolved there first and asked for a uuidgen password no human has" if [[ "$LIST" == "1" ]]; then echo "== Canary catalog ==" @@ -216,6 +217,24 @@ canary_sparkle_key_parity() { fi } +# --------------------------------------------------------------------------- +# keychain-domain-clean (host) +# A signing run borrows the operator's user keychain domain: it prepends an +# ephemeral keychain to the search list and makes it the default. If it does +# not hand both back, every later keychain access on this host asks for a +# password to a file that may no longer exist. scripts/keychain-doctor.sh is +# the read-only judge; this row is the standing alarm. It never mutates. +# --------------------------------------------------------------------------- +canary_keychain_domain_clean() { + local out rc + out="$(bash scripts/keychain-doctor.sh 2>&1)"; rc=$? + case "$rc" in + 0) record "keychain-domain-clean" "PASS" "user search list and default keychain all resolve to existing files" ;; + 1) record "keychain-domain-clean" "FAIL" "$(printf '%s' "$out" | awk '/STALE/{print "stale: " $3; exit}') — run scripts/keychain-doctor.sh for the derived recovery line" ;; + *) record "keychain-domain-clean" "SKIP" "keychain domain unreadable (no /usr/bin/security?) — not a macOS host" ;; + esac +} + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- @@ -227,8 +246,9 @@ if [[ "$HOST" == "1" ]]; then canary_appcast_feed_live canary_store_purity canary_sparkle_key_parity + canary_keychain_domain_clean else - record "host-canaries" "SKIP" "run with --host for dist-inputs, appcast-feed-live, store-purity, sparkle-key-parity" + record "host-canaries" "SKIP" "run with --host for dist-inputs, appcast-feed-live, store-purity, sparkle-key-parity, keychain-domain-clean" fi echo "--------------------------------------------------------------------------" diff --git a/scripts/keychain-doctor.sh b/scripts/keychain-doctor.sh new file mode 100755 index 00000000..01722d1a --- /dev/null +++ b/scripts/keychain-doctor.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# ============================================================================ +# keychain-doctor.sh — READ-ONLY diagnosis of the macOS user keychain domain +# ============================================================================ +# Born from the same 2026-08-15 P0 as scripts/lib/keychain-session.sh: a +# release left its ephemeral signing keychain in the user search list and as +# the default keychain, then the directory holding it was wiped. Every later +# keychain access — including Codescribe's own — prompted the operator to +# unlock a file that no longer existed, with a password that could not work. +# +# This tool NEVER mutates. It runs `security list-keychains -d user` and +# `security default-keychain -d user` (both read-only forms — no `-s`), grades +# what it finds, and prints the exact recovery command line derived from the +# ACTUAL current list. It deliberately does not offer to run that line: fixing +# a poisoned keychain domain is an operator button, not an agent's. +# +# Note the recovery is *derived*, not canned. "Just set login.keychain-db" is +# wrong for any operator who legitimately keeps a second keychain in the search +# list — it would silently delete their entry. The doctor prints the surviving +# entries, in order, exactly as they should be re-installed. +# +# scripts/keychain-doctor.sh → human report +# scripts/keychain-doctor.sh --quiet → exit code only +# +# Exit: 0 healthy · 1 stale/missing paths found · 2 could not read the domain +# +# Contract tests: scripts/tests/keychain-session-test.sh +# ============================================================================ +set -uo pipefail + +SECURITY_BIN="${KEYCHAIN_SESSION_SECURITY_BIN:-/usr/bin/security}" +QUIET=0 +[[ "${1:-}" == "--quiet" ]] && QUIET=1 + +say() { (( QUIET )) || printf '%s\n' "$*"; } + +unquote() { + local line="$1" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + line="${line#\"}" + line="${line%\"}" + printf '%s' "$line" +} + +command -v "$SECURITY_BIN" >/dev/null 2>&1 || { + printf 'keychain-doctor: %s is not executable\n' "$SECURITY_BIN" >&2 + exit 2 +} + +# A keychain is "resident" when it lives where the operator's keychains live. +# Anything else in the user search list came from a build: a release staging +# directory, $RUNNER_TEMP, /private/tmp. Existing on disk does NOT make it +# healthy — the 2026-08-15 prompt fired against a keychain whose file was +# perfectly intact, because a release was holding it open as the default. +is_resident() { + case "$1" in + "$HOME/Library/Keychains/"*) return 0 ;; + /Library/Keychains/*) return 0 ;; + /System/Library/Keychains/*) return 0 ;; + *) return 1 ;; + esac +} + +declare -a entries=() resident=() stale=() foreign=() +while IFS= read -r raw; do + entry="$(unquote "$raw")" + [[ -n "$entry" ]] || continue + entries+=("$entry") + if [[ ! -e "$entry" ]]; then + stale+=("$entry") + elif is_resident "$entry"; then + resident+=("$entry") + else + foreign+=("$entry") + fi +done < <("$SECURITY_BIN" list-keychains -d user 2>/dev/null) + +(( ${#entries[@]} > 0 )) || { + printf 'keychain-doctor: user search list is empty or unreadable\n' >&2 + exit 2 +} + +default_raw="$("$SECURITY_BIN" default-keychain -d user 2>/dev/null | head -n1)" +default="$(unquote "$default_raw")" +default_grade=ok +if [[ -n "$default" && ! -e "$default" ]]; then + default_grade=STALE +elif [[ -n "$default" ]] && ! is_resident "$default"; then + default_grade=HIJACKED +fi + +say "=== user keychain search list (${#entries[@]} entr$( ((${#entries[@]}==1)) && echo y || echo ies)) ===" +for entry in "${entries[@]}"; do + if [[ ! -e "$entry" ]]; then + say " STALE $entry (no such file)" + elif is_resident "$entry"; then + say " ok $entry" + else + say " FOREIGN $entry (exists, but lives outside the operator's keychain directory — a build left it here)" + fi +done +say "" +say "=== default keychain ===" +case "$default_grade" in + STALE) say " STALE $default (no such file)" ;; + HIJACKED) say " HIJACKED $default" ;; + *) say " ok ${default:-}" ;; +esac + +if (( ${#stale[@]} == 0 && ${#foreign[@]} == 0 )) && [[ "$default_grade" == "ok" ]]; then + say "" + say "keychain-doctor: healthy — the search list holds only the operator's own" + say "keychains and the default keychain is one of them." + exit 0 +fi + +if [[ "$default_grade" == "HIJACKED" ]]; then + say "" + say " The DEFAULT keychain is a build keychain. This is the state that makes" + say " unrelated apps pop \" wants to use the '' keychain\": every" + say " process in this login session resolves to it first, and its password is" + say " a value generated inside the release — no human knows it. If a release" + say " is running right now, that is the cause and it will end with the run;" + say " if none is, the run died without giving the domain back." +fi + +# The recovery is derived from what is actually here, not canned. Hardcoding +# "just set login.keychain-db" would silently delete the entry of any operator +# who legitimately keeps a second keychain. +replacement="" +if (( ${#resident[@]} > 0 )); then + replacement="${resident[0]}" +elif [[ -e "${HOME}/Library/Keychains/login.keychain-db" ]]; then + replacement="${HOME}/Library/Keychains/login.keychain-db" +fi + +say "" +say "=== recovery (OPERATOR RUNS THIS — the doctor does not) ===" +say " Do NOT run this while a release is signing; it would pull the keychain" +say " out from under it. Check first: pgrep -fl 'release|codesign|notarytool'" +say "" +if (( ${#resident[@]} == 0 )); then + say " Nothing of the operator's own survives in the list. Restore the login keychain:" + say " security list-keychains -d user -s \"\$HOME/Library/Keychains/login.keychain-db\"" + say " security default-keychain -d user -s \"\$HOME/Library/Keychains/login.keychain-db\"" +else + printf -v args '%q ' "${resident[@]}" + say " Re-install only the operator's own entries, in their current order:" + say " security list-keychains -d user -s ${args% }" + [[ "$default_grade" == "ok" ]] || \ + say " security default-keychain -d user -s $(printf '%q' "$replacement")" +fi +say " security unlock-keychain \"\$HOME/Library/Keychains/login.keychain-db\"" +say "" +say " Cause is a release/signing run that borrowed the user keychain domain." +say " scripts/lib/keychain-session.sh is the hardened path: it never takes the" +say " default keychain, and it unlists before deleting. Anything still" +say " hand-rolling 'security list-keychains -s' should move onto it." + +exit 1 diff --git a/scripts/lib/keychain-session.sh b/scripts/lib/keychain-session.sh new file mode 100755 index 00000000..ff55f7f2 --- /dev/null +++ b/scripts/lib/keychain-session.sh @@ -0,0 +1,458 @@ +#!/usr/bin/env bash +# ============================================================================ +# keychain-session.sh — an ephemeral signing keychain that always gives the +# operator's search list back +# ============================================================================ +# BORN FROM (2026-08-15, P0): a normal Codescribe run popped +# +# "Codescribe wants to use the 'Vibecrafted-signing' keychain" +# +# and the login password did not open it, because it was not the login +# keychain. The host's user domain looked like this: +# +# search list: "/private/tmp/vibecrafted-release-3.7.1.rki1gq/.../Vibecrafted-signing.keychain-db" +# "/Users//Library/Keychains/login.keychain-db" +# default: "/private/tmp/vibecrafted-release-3.7.1.rki1gq/.../Vibecrafted-signing.keychain-db" +# +# A release had prepended its ephemeral signing keychain to the *user* search +# list and made it the *default* keychain. Measured at the time: the keychain +# file still existed and the release (`build-vibecrafted-release.sh`, pid +# 66496) was still running. So this was not only a cleanup bug — the host was +# poisoned WHILE the release was healthy and mid-flight, and it would have +# stayed poisoned for the whole run. +# +# FOUR FAILURES PRODUCED IT, and this file exists to make all four impossible: +# +# 0. Taking the login session's default keychain. `security default-keychain +# -d user -s` is global to the login session, not scoped to the release +# shell. Every other process — Codescribe included — then resolves to the +# release's keychain first and prompts for a uuidgen password nobody has. +# Signing does not need it: import, set-key-partition-list, find-identity +# and codesign all take the keychain path explicitly. => We do NOT set the +# default keychain unless KEYCHAIN_SESSION_SET_DEFAULT=1 says so, and the +# restore path below exists for domains an older run already took over. +# +# 1. "delete-keychain also unlists it." It does — but only while the file is +# still there. Put the ephemeral keychain under `mktemp -d` (which is what +# the release did) and the directory can vanish first; `delete-keychain` +# then fails, the `|| true` swallows it, and the search-list entry is +# immortal. => We ALWAYS unlist explicitly, and we unlist BEFORE deleting. +# +# 2. `trap cleanup EXIT` alone. Bash runs an EXIT trap for normal and error +# exits, but a Ctrl-C or a SIGTERM during a 30-minute notarization wait can +# tear the shell down without it. => We trap EXIT INT TERM HUP, and the +# handler is idempotent so double delivery is harmless. +# +# 3. Snapshot-and-restore under concurrency. Naive restore is itself a +# contamination source: +# A snapshots [login] -> list [tempA, login] +# B snapshots [tempA, login] -> list [tempB, tempA, login] +# A restores its snapshot -> list [login] (tempB dropped, B breaks) +# B restores its snapshot -> list [tempA, login] (tempA RESURRECTED, dead path) +# => We never write a remembered list back. Restore means "read the current +# list, remove exactly the entry this session created, write the rest." +# In a single run that reproduces the prior list exactly; under concurrency +# nobody clobbers anybody. The snapshot is kept for the default-keychain +# decision and for the doctor's forensics, not as a thing to replay. +# +# The snapshot is stored as one NUL-terminated path per record — never as a +# shell-concatenated string. The predecessor did +# existing="$(security list-keychains -d user | tr -d '"' | tr '\n' ' ')" +# security list-keychains -d user -s "$TEMP" $existing +# which splits any keychain path containing a space into two nonexistent +# entries, and strips every space out of the default keychain path besides. +# +# Usage (sourceable — the release path): +# . scripts/lib/keychain-session.sh +# keychain_session_begin codescribe-signing # arms the traps itself +# security import ... -k "$KEYCHAIN_SESSION_PATH" ... +# codesign --keychain "$KEYCHAIN_SESSION_PATH" ... +# keychain_session_end # optional; traps cover it +# +# Usage (executable — for CI step boundaries, where each `run:` block is its +# own shell and traps cannot span them): +# scripts/lib/keychain-session.sh begin