From 1a461baa9bcbd5073d307b6f3ce7a90447e4d298 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Thu, 6 Aug 2026 10:00:14 -0400 Subject: [PATCH 1/4] feat(desktop): import Shepherd execution evidence Normalize Shepherd flat JSON traces into a Buzz-owned, redacted evidence envelope without requiring Shepherd at runtime. Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/shepherd.rs | 12 ++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src-tauri/src/managed_agents/shepherd.rs | 175 ++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 desktop/src-tauri/src/commands/shepherd.rs create mode 100644 desktop/src-tauri/src/managed_agents/shepherd.rs diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..2d6ff82f84 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -54,6 +54,7 @@ mod project_terminal; mod qr_download; mod relay_members; mod relay_reconnect; +mod shepherd; mod social; mod team_snapshot; mod teams; @@ -106,6 +107,7 @@ pub use project_terminal::*; pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; +pub use shepherd::*; pub use social::*; pub use team_snapshot::*; pub use teams::*; diff --git a/desktop/src-tauri/src/commands/shepherd.rs b/desktop/src-tauri/src/commands/shepherd.rs new file mode 100644 index 0000000000..235fe78176 --- /dev/null +++ b/desktop/src-tauri/src/commands/shepherd.rs @@ -0,0 +1,12 @@ +//! Tauri surface for importing optional Shepherd execution evidence. + +use crate::managed_agents::shepherd::{normalize_shepherd_export, ShepherdEvidenceEnvelope}; + +/// Normalize a Shepherd flat JSON trace export without retaining raw payloads. +#[tauri::command] +pub fn normalize_shepherd_trace( + export_json: String, + source_run_ref: Option, +) -> Result { + normalize_shepherd_export(&export_json, source_run_ref) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d22b95224b..ee35ad5bd1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -902,6 +902,7 @@ pub fn run() { archive::read_archived_observer_events_for_channel, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, + normalize_shepherd_trace, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 986ce4e0c0..fd0fa5561d 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -31,6 +31,7 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +pub(crate) mod shepherd; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; diff --git a/desktop/src-tauri/src/managed_agents/shepherd.rs b/desktop/src-tauri/src/managed_agents/shepherd.rs new file mode 100644 index 0000000000..fc2249a186 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/shepherd.rs @@ -0,0 +1,175 @@ +//! Optional Shepherd execution-evidence adapter. +//! +//! Shepherd remains an external execution producer. This module accepts its +//! flat JSON trace export and converts it into a small Buzz-owned envelope. +//! Raw effect payloads are deliberately not retained because they may contain +//! prompts, tool results, or file content. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +const MAX_EXPORT_BYTES: usize = 16 * 1024 * 1024; + +#[derive(Debug, Deserialize)] +struct ShepherdExport { + total_effects: usize, + #[serde(default)] + effect_types: Vec, + timeline: Vec, +} + +/// A redacted Shepherd boundary event safe to join to Buzz's evidence plane. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShepherdEvidenceEvent { + pub sequence: u64, + pub scope_id: Option, + pub effect_type: String, + pub phase: Option, + pub binding: Option, + pub path: Option, + pub operation_id: Option, + pub payload_sha256: String, +} + +/// Buzz-owned representation of one imported Shepherd trace. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShepherdEvidenceEnvelope { + pub schema: &'static str, + pub source: &'static str, + pub source_run_ref: Option, + pub coverage: &'static str, + pub total_effects: usize, + pub effect_types: Vec, + pub events: Vec, +} + +fn optional_string(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value + .get(*key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn sequence(value: &Value, fallback: usize) -> Result { + value + .get("_sequence") + .or_else(|| value.get("sequence")) + .and_then(Value::as_u64) + .or_else(|| u64::try_from(fallback).ok()) + .ok_or_else(|| "Shepherd trace sequence exceeds the supported range".to_string()) +} + +fn normalize_event(value: &Value, fallback: usize) -> Result { + let object = value + .as_object() + .ok_or_else(|| format!("Shepherd timeline entry {fallback} must be an object"))?; + let effect_type = optional_string(value, &["effect_type", "effectType", "kind"]) + .ok_or_else(|| format!("Shepherd timeline entry {fallback} is missing effect_type"))?; + let canonical = serde_json::to_vec(object) + .map_err(|error| format!("failed to hash Shepherd timeline entry {fallback}: {error}"))?; + Ok(ShepherdEvidenceEvent { + sequence: sequence(value, fallback)?, + scope_id: optional_string(value, &["_scope_id", "scope_id", "scopeId"]), + effect_type, + phase: optional_string(value, &["phase"]), + binding: optional_string(value, &["binding"]), + path: optional_string(value, &["path"]), + operation_id: optional_string(value, &["operation_id", "operationId"]), + payload_sha256: hex::encode(Sha256::digest(canonical)), + }) +} + +/// Convert a Shepherd flat JSON export into redacted Buzz evidence. +pub fn normalize_shepherd_export( + export_json: &str, + source_run_ref: Option, +) -> Result { + if export_json.len() > MAX_EXPORT_BYTES { + return Err("Shepherd trace export exceeds the 16 MiB import limit".to_string()); + } + let export: ShepherdExport = serde_json::from_str(export_json) + .map_err(|error| format!("invalid Shepherd trace export: {error}"))?; + if export.total_effects != export.timeline.len() { + return Err(format!( + "Shepherd trace total_effects mismatch: declared {}, found {}", + export.total_effects, + export.timeline.len() + )); + } + + let events = export + .timeline + .iter() + .enumerate() + .map(|(index, value)| normalize_event(value, index + 1)) + .collect::, _>>()?; + let mut seen_sequences = BTreeSet::new(); + if events + .iter() + .any(|event| !seen_sequences.insert(event.sequence)) + { + return Err("Shepherd trace contains duplicate event sequences".to_string()); + } + + let observed_types = events + .iter() + .map(|event| event.effect_type.clone()) + .collect::>(); + let declared_types = export.effect_types.into_iter().collect::>(); + if !declared_types.is_empty() && declared_types != observed_types { + return Err("Shepherd trace effect_types do not match timeline events".to_string()); + } + + Ok(ShepherdEvidenceEnvelope { + schema: "buzz.external-execution-evidence.v1", + source: "shepherd", + source_run_ref: source_run_ref.filter(|value| !value.trim().is_empty()), + coverage: "boundary-effects-only", + total_effects: events.len(), + effect_types: observed_types.into_iter().collect(), + events, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_and_redacts_a_shepherd_export() { + let input = serde_json::json!({ + "total_effects": 2, + "effect_types": ["FilePatch", "task_started"], + "timeline": [ + {"_sequence": 1, "_scope_id": "scope-1", "effect_type": "task_started", "prompt": "secret"}, + {"_sequence": 2, "_scope_id": "scope-1", "effect_type": "FilePatch", "phase": "proposed", "binding": "repo", "path": "src/lib.rs", "content": "private"} + ] + }); + let result = normalize_shepherd_export(&input.to_string(), Some("run-7".into())) + .expect("normalize Shepherd export"); + + assert_eq!(result.schema, "buzz.external-execution-evidence.v1"); + assert_eq!(result.source_run_ref.as_deref(), Some("run-7")); + assert_eq!(result.effect_types, vec!["FilePatch", "task_started"]); + assert_eq!(result.events[1].path.as_deref(), Some("src/lib.rs")); + let serialized = serde_json::to_string(&result).expect("serialize result"); + assert!(!serialized.contains("secret")); + assert!(!serialized.contains("private")); + } + + #[test] + fn rejects_inconsistent_or_duplicate_traces() { + let mismatch = r#"{"total_effects":2,"timeline":[]}"#; + assert!(normalize_shepherd_export(mismatch, None).is_err()); + + let duplicate = r#"{"total_effects":2,"timeline":[{"_sequence":1,"effect_type":"a"},{"_sequence":1,"effect_type":"b"}]}"#; + assert!(normalize_shepherd_export(duplicate, None).is_err()); + } +} From c81b1023bd3d9a0ecbafa87bc20a54a53a502b94 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Thu, 6 Aug 2026 10:59:45 -0400 Subject: [PATCH 2/4] feat(desktop): complete Shepherd evidence workflow Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- desktop/src-tauri/src/archive/mod.rs | 1 + desktop/src-tauri/src/archive/shepherd.rs | 230 ++++++++++++++++++ desktop/src-tauri/src/archive/store.rs | 16 ++ desktop/src-tauri/src/commands/shepherd.rs | 202 +++++++++++++++ desktop/src-tauri/src/lib.rs | 4 + .../fixtures/shepherd-0.3.0-export.json | 19 ++ .../src-tauri/src/managed_agents/shepherd.rs | 29 ++- .../agents/ui/ShepherdEvidencePanel.tsx | 107 ++++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 59 +++++ desktop/src/shared/api/tauriShepherd.ts | 67 +++++ 10 files changed, 726 insertions(+), 8 deletions(-) create mode 100644 desktop/src-tauri/src/archive/shepherd.rs create mode 100644 desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json create mode 100644 desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx create mode 100644 desktop/src/shared/api/tauriShepherd.ts diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 42c6812674..7ea3e557df 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -18,6 +18,7 @@ //! == agent) is applied fail-closed. mod pipeline; +pub mod shepherd; pub mod store; use pipeline::{commit_archive, plan_archive, query_buckets}; diff --git a/desktop/src-tauri/src/archive/shepherd.rs b/desktop/src-tauri/src/archive/shepherd.rs new file mode 100644 index 0000000000..1cf0866cf0 --- /dev/null +++ b/desktop/src-tauri/src/archive/shepherd.rs @@ -0,0 +1,230 @@ +//! Owner-local persistence for redacted Shepherd execution evidence. + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::{ + app_state::AppState, + managed_agents::shepherd::{normalize_shepherd_export, ShepherdEvidenceEnvelope}, +}; + +use super::{identity_pubkey, now_secs, run_archive_db_task}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportShepherdEvidenceRequest { + pub agent_pubkey: String, + pub channel_id: String, + pub session_id: String, + pub turn_id: Option, + pub source_run_ref: String, + pub export_json: String, +} + +/// One persisted external-execution evidence record. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredShepherdEvidence { + pub agent_pubkey: String, + pub channel_id: String, + pub session_id: String, + pub turn_id: Option, + pub source_run_ref: String, + pub imported_at: i64, + pub evidence: ShepherdEvidenceEnvelope, +} + +fn required(value: String, name: &str, max: usize) -> Result { + let value = value.trim().to_string(); + if value.is_empty() || value.len() > max { + return Err(format!("{name} must contain 1 to {max} characters")); + } + Ok(value) +} + +fn validate_agent_pubkey(value: String) -> Result { + let value = required(value, "agentPubkey", 64)?.to_ascii_lowercase(); + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("agentPubkey must be a 64-character hexadecimal public key".to_string()); + } + Ok(value) +} + +/// Validate, redact, and persist one Shepherd trace under an exact Buzz scope. +#[tauri::command] +pub async fn import_shepherd_evidence( + state: State<'_, AppState>, + request: ImportShepherdEvidenceRequest, +) -> Result { + let identity = identity_pubkey(&state)?; + let agent_pubkey = validate_agent_pubkey(request.agent_pubkey)?; + let channel_id = required(request.channel_id, "channelId", 256)?; + let session_id = required(request.session_id, "sessionId", 256)?; + let turn_id = request + .turn_id + .map(|value| required(value, "turnId", 256)) + .transpose()?; + let source_run_ref = required(request.source_run_ref, "sourceRunRef", 256)?; + let evidence = normalize_shepherd_export(&request.export_json, Some(source_run_ref.clone()))?; + let imported_at = now_secs(); + let record = StoredShepherdEvidence { + agent_pubkey, + channel_id, + session_id, + turn_id, + source_run_ref, + imported_at, + evidence, + }; + let stored = record.clone(); + run_archive_db_task(move |conn| persist(conn, &identity, &stored)).await?; + Ok(record) +} + +fn persist( + conn: &Connection, + identity: &str, + record: &StoredShepherdEvidence, +) -> Result<(), String> { + let evidence_json = serde_json::to_string(&record.evidence) + .map_err(|error| format!("failed to encode Shepherd evidence: {error}"))?; + conn.execute( + "INSERT INTO shepherd_evidence + (identity_pubkey, agent_pubkey, channel_id, session_id, turn_id, + source_run_ref, evidence_json, imported_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (identity_pubkey, source_run_ref) DO UPDATE SET + agent_pubkey = excluded.agent_pubkey, + channel_id = excluded.channel_id, + session_id = excluded.session_id, + turn_id = excluded.turn_id, + evidence_json = excluded.evidence_json, + imported_at = excluded.imported_at", + params![ + identity, + record.agent_pubkey, + record.channel_id, + record.session_id, + record.turn_id, + record.source_run_ref, + evidence_json, + record.imported_at, + ], + ) + .map_err(|error| format!("failed to persist Shepherd evidence: {error}"))?; + Ok(()) +} + +/// Read Shepherd evidence for one exact Buzz agent/channel/session scope. +#[tauri::command] +pub async fn read_shepherd_evidence( + state: State<'_, AppState>, + agent_pubkey: String, + channel_id: String, + session_id: String, +) -> Result, String> { + let identity = identity_pubkey(&state)?; + let agent_pubkey = validate_agent_pubkey(agent_pubkey)?; + let channel_id = required(channel_id, "channelId", 256)?; + let session_id = required(session_id, "sessionId", 256)?; + run_archive_db_task(move |conn| read(conn, &identity, &agent_pubkey, &channel_id, &session_id)) + .await +} + +fn read( + conn: &Connection, + identity: &str, + agent_pubkey: &str, + channel_id: &str, + session_id: &str, +) -> Result, String> { + let mut statement = conn + .prepare( + "SELECT agent_pubkey, channel_id, session_id, turn_id, + source_run_ref, imported_at, evidence_json + FROM shepherd_evidence + WHERE identity_pubkey = ?1 AND agent_pubkey = ?2 + AND channel_id = ?3 AND session_id = ?4 + ORDER BY imported_at ASC, source_run_ref ASC", + ) + .map_err(|error| format!("failed to prepare Shepherd evidence read: {error}"))?; + let rows = statement + .query_map( + params![identity, agent_pubkey, channel_id, session_id], + |row| { + let evidence_json: String = row.get(6)?; + let evidence = serde_json::from_str(&evidence_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 6, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + Ok(StoredShepherdEvidence { + agent_pubkey: row.get(0)?, + channel_id: row.get(1)?, + session_id: row.get(2)?, + turn_id: row.get(3)?, + source_run_ref: row.get(4)?, + imported_at: row.get(5)?, + evidence, + }) + }, + ) + .map_err(|error| format!("failed to read Shepherd evidence: {error}"))?; + rows.collect::, _>>() + .map_err(|error| format!("failed to decode Shepherd evidence row: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::archive::store::{open_archive_db, SCHEMA}; + + fn record(run: &str) -> StoredShepherdEvidence { + let export = serde_json::json!({ + "total_effects": 1, + "effect_types": ["task_started"], + "timeline": [{"_sequence": 1, "effect_type": "task_started", "prompt": "secret"}] + }); + StoredShepherdEvidence { + agent_pubkey: "a".repeat(64), + channel_id: "channel".into(), + session_id: "session".into(), + turn_id: Some("turn".into()), + source_run_ref: run.into(), + imported_at: 7, + evidence: normalize_shepherd_export(&export.to_string(), Some(run.into())) + .expect("normalize"), + } + } + + #[test] + fn persists_reads_and_replaces_by_owner_and_run() { + let directory = tempfile::tempdir().expect("tempdir"); + let conn = open_archive_db(&directory.path().join("archive.db")).expect("open"); + let first = record("run-1"); + persist(&conn, "owner-a", &first).expect("persist"); + persist(&conn, "owner-a", &first).expect("idempotent replace"); + assert_eq!( + read(&conn, "owner-a", &first.agent_pubkey, "channel", "session") + .expect("read") + .len(), + 1 + ); + assert!( + read(&conn, "owner-b", &first.agent_pubkey, "channel", "session") + .expect("read other owner") + .is_empty() + ); + } + + #[test] + fn schema_creates_the_shepherd_table() { + let conn = Connection::open_in_memory().expect("open"); + conn.execute_batch(SCHEMA).expect("schema"); + conn.prepare("SELECT source_run_ref FROM shepherd_evidence") + .expect("table exists"); + } +} diff --git a/desktop/src-tauri/src/archive/store.rs b/desktop/src-tauri/src/archive/store.rs index ae0ef92e4b..95bc8e7c2f 100644 --- a/desktop/src-tauri/src/archive/store.rs +++ b/desktop/src-tauri/src/archive/store.rs @@ -70,6 +70,22 @@ CREATE TABLE IF NOT EXISTS observer_channel_index ( CREATE INDEX IF NOT EXISTS idx_observer_channel ON observer_channel_index (identity_pubkey, relay_url, channel_id, created_at DESC, id DESC); +-- Redacted evidence imported from optional external execution backends. +CREATE TABLE IF NOT EXISTS shepherd_evidence ( + identity_pubkey TEXT NOT NULL, + agent_pubkey TEXT NOT NULL, + channel_id TEXT NOT NULL, + session_id TEXT NOT NULL, + turn_id TEXT, + source_run_ref TEXT NOT NULL, + evidence_json TEXT NOT NULL, + imported_at INTEGER NOT NULL, + PRIMARY KEY (identity_pubkey, source_run_ref) +); +CREATE INDEX IF NOT EXISTS idx_shepherd_evidence_session + ON shepherd_evidence + (identity_pubkey, agent_pubkey, channel_id, session_id, imported_at ASC); + -- One-row migration state table: tracks which idempotent migrations have run. CREATE TABLE IF NOT EXISTS archive_migrations ( name TEXT PRIMARY KEY, diff --git a/desktop/src-tauri/src/commands/shepherd.rs b/desktop/src-tauri/src/commands/shepherd.rs index 235fe78176..3557cc1454 100644 --- a/desktop/src-tauri/src/commands/shepherd.rs +++ b/desktop/src-tauri/src/commands/shepherd.rs @@ -1,7 +1,188 @@ //! Tauri surface for importing optional Shepherd execution evidence. +use std::{path::PathBuf, process::Command}; + +use serde::{Deserialize, Serialize}; + use crate::managed_agents::shepherd::{normalize_shepherd_export, ShepherdEvidenceEnvelope}; +const MAX_COMMAND_OUTPUT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShepherdAdapterStatus { + installed: bool, + version: Option, + supported: bool, + detail: String, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShepherdSettlementAction { + Select, + Apply, + Discard, +} + +impl ShepherdSettlementAction { + fn command(self) -> &'static str { + match self { + Self::Select => "select", + Self::Apply => "apply", + Self::Discard => "discard", + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShepherdSettlementResult { + action: String, + source_run_ref: String, + message: String, +} + +fn parse_version(output: &str) -> Option { + output + .split_whitespace() + .find(|part| { + part.trim_start_matches('v') + .split('.') + .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit())) + && part.contains('.') + }) + .map(|part| part.trim_start_matches('v').to_string()) +} + +fn supported_version(version: &str) -> bool { + version + .split('.') + .next() + .and_then(|major| major.parse::().ok()) + == Some(0) + && version + .split('.') + .nth(1) + .and_then(|minor| minor.parse::().ok()) + .is_some_and(|minor| minor >= 3) +} + +/// Detect a local Shepherd installation without installing or modifying it. +#[tauri::command] +pub async fn shepherd_adapter_status() -> ShepherdAdapterStatus { + tokio::task::spawn_blocking( + || match Command::new("shepherd").arg("--version").output() { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = parse_version(&stdout).or_else(|| parse_version(&stderr)); + let supported = version.as_deref().is_some_and(supported_version); + ShepherdAdapterStatus { + installed: true, + version, + supported, + detail: if supported { + "Shepherd is available for evidence import and settlement".to_string() + } else { + "Shepherd is installed, but Buzz requires version 0.3 or newer".to_string() + }, + } + } + Ok(output) => ShepherdAdapterStatus { + installed: true, + version: None, + supported: false, + detail: format!("Shepherd version probe exited with {}", output.status), + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => ShepherdAdapterStatus { + installed: false, + version: None, + supported: false, + detail: "Shepherd is not installed; Buzz remains fully functional without it" + .to_string(), + }, + Err(error) => ShepherdAdapterStatus { + installed: false, + version: None, + supported: false, + detail: format!("Shepherd could not be probed: {error}"), + }, + }, + ) + .await + .unwrap_or_else(|error| ShepherdAdapterStatus { + installed: false, + version: None, + supported: false, + detail: format!("Shepherd probe task failed: {error}"), + }) +} + +fn validate_run_ref(value: String) -> Result { + let value = value.trim().to_string(); + if value.is_empty() + || value.len() > 256 + || value.starts_with('-') + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:-/@-".contains(&byte)) + { + return Err("sourceRunRef contains unsupported characters".to_string()); + } + Ok(value) +} + +fn bounded_output(output: &[u8]) -> String { + let keep = output.len().min(MAX_COMMAND_OUTPUT_BYTES); + String::from_utf8_lossy(&output[..keep]).trim().to_string() +} + +/// Execute an explicitly owner-confirmed Shepherd settlement action. +#[tauri::command] +pub async fn settle_shepherd_run( + workspace_path: String, + source_run_ref: String, + action: ShepherdSettlementAction, +) -> Result { + let source_run_ref = validate_run_ref(source_run_ref)?; + let workspace = PathBuf::from(workspace_path) + .canonicalize() + .map_err(|error| format!("invalid Shepherd workspace: {error}"))?; + if !workspace.is_dir() { + return Err("Shepherd workspace must be a directory".to_string()); + } + if !workspace.join(".vcscore").is_dir() { + return Err("the selected directory is not an initialized Shepherd workspace".to_string()); + } + let action_name = action.command().to_string(); + tokio::task::spawn_blocking(move || { + let output = Command::new("shepherd") + .args(["run", action.command(), &source_run_ref]) + .current_dir(&workspace) + .output() + .map_err(|error| format!("failed to launch Shepherd: {error}"))?; + if !output.status.success() { + let detail = bounded_output(&output.stderr) + .replace(&workspace.display().to_string(), ""); + return Err(if detail.is_empty() { + format!("Shepherd {action_name} failed with {}", output.status) + } else { + format!("Shepherd {action_name} failed: {detail}") + }); + } + let message = + bounded_output(&output.stdout).replace(&workspace.display().to_string(), ""); + Ok(ShepherdSettlementResult { + action: action_name, + source_run_ref, + message, + }) + }) + .await + .map_err(|error| format!("Shepherd settlement task failed: {error}"))? +} + /// Normalize a Shepherd flat JSON trace export without retaining raw payloads. #[tauri::command] pub fn normalize_shepherd_trace( @@ -10,3 +191,24 @@ pub fn normalize_shepherd_trace( ) -> Result { normalize_shepherd_export(&export_json, source_run_ref) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_gates_supported_versions() { + assert_eq!(parse_version("shepherd 0.3.0"), Some("0.3.0".into())); + assert_eq!(parse_version("shepherd v0.4.2"), Some("0.4.2".into())); + assert!(supported_version("0.3.0")); + assert!(!supported_version("0.2.9")); + assert!(!supported_version("1.0.0")); + } + + #[test] + fn run_refs_cannot_inject_arguments_or_shell_syntax() { + assert!(validate_run_ref("run:abc/123".into()).is_ok()); + assert!(validate_run_ref("--help".into()).is_err()); + assert!(validate_run_ref("abc; touch /tmp/pwned".into()).is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ee35ad5bd1..5116695657 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -902,7 +902,11 @@ pub fn run() { archive::read_archived_observer_events_for_channel, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, + archive::shepherd::import_shepherd_evidence, + archive::shepherd::read_shepherd_evidence, normalize_shepherd_trace, + shepherd_adapter_status, + settle_shepherd_run, is_auto_update_supported, set_window_vibrancy, #[cfg(target_os = "macos")] diff --git a/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json b/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json new file mode 100644 index 0000000000..d8316dee80 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json @@ -0,0 +1,19 @@ +{ + "total_effects": 6, + "effect_types": [ + "task_started", + "prompt_sent", + "agent_message", + "tool_call_started", + "tool_call_completed", + "task_completed" + ], + "timeline": [ + {"_sequence":0,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"task_started","task_name":"BuzzFixture","provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.3105228,"inputs":{},"scope_id":"","parent_scope_id":null,"device_name":null,"stage_name":null}, + {"_sequence":1,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"prompt_sent","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310556,"system_prompt":"","user_prompt":"fixture prompt","total_tokens":0,"input_tokens":0,"model_id":""}, + {"_sequence":2,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"agent_message","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310564,"content":"fixture response","is_partial":false}, + {"_sequence":3,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"tool_call_started","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310573,"tool_call_id":"tc1","tool_name":"bash","params":{"command":"echo hi"}}, + {"_sequence":4,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"tool_call_completed","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.31058,"tool_call_id":"tc1","tool_name":"bash","success":true,"output":"hi","duration_ms":0.0}, + {"_sequence":5,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"task_completed","task_name":"BuzzFixture","provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310587,"outputs":{},"duration_ms":100.0,"device_name":null,"stage_name":null,"metadata":{}} + ] +} diff --git a/desktop/src-tauri/src/managed_agents/shepherd.rs b/desktop/src-tauri/src/managed_agents/shepherd.rs index fc2249a186..e88d800824 100644 --- a/desktop/src-tauri/src/managed_agents/shepherd.rs +++ b/desktop/src-tauri/src/managed_agents/shepherd.rs @@ -21,7 +21,7 @@ struct ShepherdExport { } /// A redacted Shepherd boundary event safe to join to Buzz's evidence plane. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ShepherdEvidenceEvent { pub sequence: u64, @@ -35,13 +35,13 @@ pub struct ShepherdEvidenceEvent { } /// Buzz-owned representation of one imported Shepherd trace. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ShepherdEvidenceEnvelope { - pub schema: &'static str, - pub source: &'static str, + pub schema: String, + pub source: String, pub source_run_ref: Option, - pub coverage: &'static str, + pub coverage: String, pub total_effects: usize, pub effect_types: Vec, pub events: Vec, @@ -128,10 +128,10 @@ pub fn normalize_shepherd_export( } Ok(ShepherdEvidenceEnvelope { - schema: "buzz.external-execution-evidence.v1", - source: "shepherd", + schema: "buzz.external-execution-evidence.v1".to_string(), + source: "shepherd".to_string(), source_run_ref: source_run_ref.filter(|value| !value.trim().is_empty()), - coverage: "boundary-effects-only", + coverage: "boundary-effects-only".to_string(), total_effects: events.len(), effect_types: observed_types.into_iter().collect(), events, @@ -172,4 +172,17 @@ mod tests { let duplicate = r#"{"total_effects":2,"timeline":[{"_sequence":1,"effect_type":"a"},{"_sequence":1,"effect_type":"b"}]}"#; assert!(normalize_shepherd_export(duplicate, None).is_err()); } + + #[test] + fn accepts_real_shepherd_0_3_0_export_and_redacts_payloads() { + let input = include_str!("fixtures/shepherd-0.3.0-export.json"); + let result = normalize_shepherd_export(input, Some("real-0.3.0".into())) + .expect("normalize real Shepherd 0.3.0 export"); + assert_eq!(result.total_effects, 6); + assert_eq!(result.events.first().map(|event| event.sequence), Some(0)); + let serialized = serde_json::to_string(&result).expect("serialize"); + assert!(!serialized.contains("fixture prompt")); + assert!(!serialized.contains("fixture response")); + assert!(!serialized.contains("echo hi")); + } } diff --git a/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx b/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx new file mode 100644 index 0000000000..6996f6804c --- /dev/null +++ b/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import { ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; + +import { + readShepherdEvidence, + settleShepherdRun, + type StoredShepherdEvidence, +} from "@/shared/api/tauriShepherd"; +import { Button } from "@/shared/ui/button"; + +type Props = { + agentPubkey: string; + channelId: string; + sessionId: string; + refreshKey?: number; +}; + +export function ShepherdEvidencePanel({ + agentPubkey, + channelId, + sessionId, + refreshKey = 0, +}: Props) { + const [records, setRecords] = React.useState([]); + + React.useEffect(() => { + let active = true; + void readShepherdEvidence(agentPubkey, channelId, sessionId) + .then((value) => active && setRecords(value)) + .catch(() => active && setRecords([])); + return () => { + active = false; + }; + }, [agentPubkey, channelId, sessionId, refreshKey]); + + if (records.length === 0) return null; + + async function settle( + record: StoredShepherdEvidence, + action: "select" | "apply" | "discard", + ) { + const workspacePath = window.prompt( + `Enter the local Shepherd workspace for ${record.sourceRunRef}:`, + ); + if (!workspacePath) return; + if ( + (action === "apply" || action === "discard") && + !window.confirm( + `${action === "apply" ? "Apply" : "Discard"} Shepherd run ${record.sourceRunRef}?`, + ) + ) + return; + try { + const result = await settleShepherdRun( + workspacePath, + record.sourceRunRef, + action, + ); + toast.success(result.message || `Shepherd run ${action} completed.`); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Shepherd ${action} failed.`, + ); + } + } + + return ( +
+
+ Shepherd evidence +
+

+ Boundary effects only. Raw prompts, tool output, and file contents are + not retained. +

+ {records.map((record) => ( +
+

+ Run {record.sourceRunRef} · {record.evidence.totalEffects} effects +

+

+ {record.evidence.effectTypes.join(", ") || "No typed effects"} +

+
+ {(["select", "apply", "discard"] as const).map((action) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 641b81490b..96431d6c85 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -5,12 +5,14 @@ import { Settings, Sparkles, TerminalSquare, + Upload, } from "lucide-react"; import { toast } from "sonner"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { + deriveLatestSessionId, mergeObserverEventWindows, observerEventScrollId, scopeByChannel, @@ -18,6 +20,7 @@ import { import { deriveTranscriptBlockIds } from "@/features/agents/ui/agentSessionTranscriptGrouping"; import type { ObserverEvent } from "@/features/agents/ui/agentSessionTypes"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; +import { ShepherdEvidencePanel } from "@/features/agents/ui/ShepherdEvidencePanel"; import { useArchivedChannelEvents, useObserverEvents, @@ -41,6 +44,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { importShepherdEvidence } from "@/shared/api/tauriShepherd"; import { DropdownMenu, DropdownMenuContent, @@ -128,6 +132,10 @@ export function AgentSessionThreadPanel({ () => mergeObserverEventWindows(scopedEvents, archivedChannelEvents), [scopedEvents, archivedChannelEvents], ); + const latestSessionId = React.useMemo( + () => deriveLatestSessionId(combinedHeaderEvents), + [combinedHeaderEvents], + ); const latestActivityAt = React.useMemo( () => getLatestActivityTimestamp(combinedHeaderEvents), [combinedHeaderEvents], @@ -160,6 +168,7 @@ export function AgentSessionThreadPanel({ scopeKey: rawFeedScopeKey, show: false, })); + const [shepherdRefreshKey, setShepherdRefreshKey] = React.useState(0); const showRawFeed = rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show; const handleRawFeedChange = React.useCallback( @@ -269,6 +278,31 @@ export function AgentSessionThreadPanel({ } } + async function handleImportShepherdTrace() { + if (!sessionChannelId || !latestSessionId) return; + const sourceRunRef = window.prompt("Enter the Shepherd run reference:"); + if (!sourceRunRef) return; + const exportJson = window.prompt("Paste the Shepherd JSON trace export:"); + if (!exportJson) return; + try { + const record = await importShepherdEvidence({ + agentPubkey: agent.pubkey, + channelId: sessionChannelId, + sessionId: latestSessionId, + sourceRunRef, + exportJson, + }); + toast.success( + `Imported ${record.evidence.totalEffects} redacted Shepherd effects.`, + ); + setShepherdRefreshKey((value) => value + 1); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Shepherd import failed.", + ); + } + } + const agentHeaderActions = ( {isLive ? ( @@ -385,6 +419,23 @@ export function AgentSessionThreadPanel({ /> + void handleImportShepherdTrace()} + title="Import a Shepherd JSON trace into this session." + > + + + + Import Shepherd trace + + + Validate, redact, and attach evidence to this session. + + + +
+ {sessionChannelId && latestSessionId ? ( + + ) : null} { + return invokeTauri("import_shepherd_evidence", { request: input }); +} + +export async function readShepherdEvidence( + agentPubkey: string, + channelId: string, + sessionId: string, +): Promise { + return invokeTauri("read_shepherd_evidence", { + agentPubkey, + channelId, + sessionId, + }); +} + +export async function settleShepherdRun( + workspacePath: string, + sourceRunRef: string, + action: "select" | "apply" | "discard", +): Promise<{ action: string; sourceRunRef: string; message: string }> { + return invokeTauri("settle_shepherd_run", { + workspacePath, + sourceRunRef, + action, + }); +} From 47aec99b371792475c62c7300302f644c310b0e4 Mon Sep 17 00:00:00 2001 From: gagan114662 Date: Thu, 6 Aug 2026 11:45:25 -0400 Subject: [PATCH 3/4] fix(desktop): satisfy Shepherd frontend checks Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../fixtures/shepherd-0.3.0-export.json | 103 +++++++++++++++++- .../agents/ui/ShepherdEvidencePanel.tsx | 13 +-- .../channels/ui/AgentSessionThreadPanel.tsx | 2 +- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json b/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json index d8316dee80..39b7868d73 100644 --- a/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json +++ b/desktop/src-tauri/src/managed_agents/fixtures/shepherd-0.3.0-export.json @@ -9,11 +9,102 @@ "task_completed" ], "timeline": [ - {"_sequence":0,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"task_started","task_name":"BuzzFixture","provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.3105228,"inputs":{},"scope_id":"","parent_scope_id":null,"device_name":null,"stage_name":null}, - {"_sequence":1,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"prompt_sent","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310556,"system_prompt":"","user_prompt":"fixture prompt","total_tokens":0,"input_tokens":0,"model_id":""}, - {"_sequence":2,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"agent_message","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310564,"content":"fixture response","is_partial":false}, - {"_sequence":3,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"tool_call_started","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310573,"tool_call_id":"tc1","tool_name":"bash","params":{"command":"echo hi"}}, - {"_sequence":4,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"tool_call_completed","task_name":null,"provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.31058,"tool_call_id":"tc1","tool_name":"bash","success":true,"output":"hi","duration_ms":0.0}, - {"_sequence":5,"_source_context":null,"_scope_id":null,"_scope_depth":0,"effect_type":"task_completed","task_name":"BuzzFixture","provider_id":null,"binding_name":null,"context_id":null,"timestamp":1786027107.310587,"outputs":{},"duration_ms":100.0,"device_name":null,"stage_name":null,"metadata":{}} + { + "_sequence": 0, + "_source_context": null, + "_scope_id": null, + "_scope_depth": 0, + "effect_type": "task_started", + "task_name": "BuzzFixture", + "provider_id": null, + "binding_name": null, + "context_id": null, + "timestamp": 1786027107.3105228, + "inputs": {}, + "scope_id": "", + "parent_scope_id": null, + "device_name": null, + "stage_name": null + }, + { + "_sequence": 1, + "_source_context": null, + "_scope_id": null, + "_scope_depth": 0, + "effect_type": "prompt_sent", + "task_name": null, + "provider_id": null, + "binding_name": null, + "context_id": null, + "timestamp": 1786027107.310556, + "system_prompt": "", + "user_prompt": "fixture prompt", + "total_tokens": 0, + "input_tokens": 0, + "model_id": "" + }, + { + "_sequence": 2, + "_source_context": null, + "_scope_id": null, + "_scope_depth": 0, + "effect_type": "agent_message", + "task_name": null, + "provider_id": null, + "binding_name": null, + "context_id": null, + "timestamp": 1786027107.310564, + "content": "fixture response", + "is_partial": false + }, + { + "_sequence": 3, + "_source_context": null, + "_scope_id": null, + "_scope_depth": 0, + "effect_type": "tool_call_started", + "task_name": null, + "provider_id": null, + "binding_name": null, + "context_id": null, + "timestamp": 1786027107.310573, + "tool_call_id": "tc1", + "tool_name": "bash", + "params": { "command": "echo hi" } + }, + { + "_sequence": 4, + "_source_context": null, + "_scope_id": null, + "_scope_depth": 0, + "effect_type": "tool_call_completed", + "task_name": null, + "provider_id": null, + "binding_name": null, + "context_id": null, + "timestamp": 1786027107.31058, + "tool_call_id": "tc1", + "tool_name": "bash", + "success": true, + "output": "hi", + "duration_ms": 0.0 + }, + { + "_sequence": 5, + "_source_context": null, + "_scope_id": null, + "_scope_depth": 0, + "effect_type": "task_completed", + "task_name": "BuzzFixture", + "provider_id": null, + "binding_name": null, + "context_id": null, + "timestamp": 1786027107.310587, + "outputs": {}, + "duration_ms": 100.0, + "device_name": null, + "stage_name": null, + "metadata": {} + } ] } diff --git a/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx b/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx index 6996f6804c..88ce1dac5f 100644 --- a/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx +++ b/desktop/src/features/agents/ui/ShepherdEvidencePanel.tsx @@ -13,14 +13,12 @@ type Props = { agentPubkey: string; channelId: string; sessionId: string; - refreshKey?: number; }; export function ShepherdEvidencePanel({ agentPubkey, channelId, sessionId, - refreshKey = 0, }: Props) { const [records, setRecords] = React.useState([]); @@ -32,7 +30,7 @@ export function ShepherdEvidencePanel({ return () => { active = false; }; - }, [agentPubkey, channelId, sessionId, refreshKey]); + }, [agentPubkey, channelId, sessionId]); if (records.length === 0) return null; @@ -60,9 +58,7 @@ export function ShepherdEvidencePanel({ toast.success(result.message || `Shepherd run ${action} completed.`); } catch (error) { toast.error( - error instanceof Error - ? error.message - : `Shepherd ${action} failed.`, + error instanceof Error ? error.message : `Shepherd ${action} failed.`, ); } } @@ -81,7 +77,10 @@ export function ShepherdEvidencePanel({

{records.map((record) => (
-

+

Run {record.sourceRunRef} · {record.evidence.totalEffects} effects

diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 96431d6c85..ccddf6d7b0 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -545,10 +545,10 @@ export function AgentSessionThreadPanel({

{sessionChannelId && latestSessionId ? ( ) : null} Date: Thu, 6 Aug 2026 12:38:26 -0400 Subject: [PATCH 4/4] fix(desktop): clear repository checker warnings Co-authored-by: gagan114662 Signed-off-by: gagan114662 --- .../features/agents/lib/personaCatalogRelay.test.mjs | 4 ++-- .../src/features/terminal/TerminalSubstrate.test.mjs | 12 ++++++++++++ desktop/src/features/terminal/TerminalSubstrate.tsx | 4 +++- desktop/src/shared/styles/globals/terminal.css | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..f3727598c0 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index ef138396b9..07a041a7ff 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -404,6 +404,18 @@ test("later reveals do not replay a consumed splash", async () => { await expectWelcome(subject.view, false); }); +test("a hidden dock clears its inline height without an important CSS override", async () => { + const subject = fixture({ mode: "docked", visible: true }); + await ready(subject.view); + const substrate = subject.view.container.querySelector( + ".buzz-terminal-substrate", + ); + assert.equal(substrate.style.height, "320px"); + + subject.rerender({ mode: "docked", visible: false }); + assert.equal(substrate.style.height, "0px"); +}); + test("a consumed splash stays absent after substrate remount", async () => { const first = fixture({ frame: EMPTY_FRAME, diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 81ac91528a..819f483e11 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -477,7 +477,9 @@ export function TerminalSubstrate({ data-terminal-visible={visible ? "true" : "false"} style={{ ...terminalStyle, - ...(mode === "docked" ? { height: dockHeight } : undefined), + ...(mode === "docked" + ? { height: visible ? dockHeight : 0 } + : undefined), }} onWheel={(event) => { event.preventDefault(); diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 26b5f42ba8..47a5a88925 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -248,7 +248,7 @@ } .buzz-terminal-substrate[data-terminal-visible="false"] { - height: 0 !important; + height: 0; min-height: 0; pointer-events: none; transform: translateY(16px);