From bdd8f022009fbd01c8300e9c9fba67de13c6c6cc Mon Sep 17 00:00:00 2001 From: Daniel Schwartz Date: Tue, 28 Jul 2026 20:21:10 -0700 Subject: [PATCH 1/2] feat(desktop): manage provider owner attestations --- .../src/commands/agent_attestation.rs | 194 ++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/managed_agents/backend.rs | 91 ++++++-- desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src/managed_agents/owner_attestation.rs | 85 ++++++++ .../src-tauri/src/managed_agents/runtime.rs | 9 +- desktop/src-tauri/src/managed_agents/types.rs | 15 ++ .../profile/ui/UserProfileAgentActions.tsx | 160 ++++++++++++++- .../features/profile/ui/UserProfilePanel.tsx | 9 + .../profile/ui/useOwnerAttestationActions.ts | 82 ++++++++ desktop/src/shared/api/tauri.ts | 14 ++ desktop/src/shared/api/tauriManagedAgents.ts | 21 ++ desktop/src/shared/api/types.ts | 9 + 14 files changed, 673 insertions(+), 22 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_attestation.rs create mode 100644 desktop/src-tauri/src/managed_agents/owner_attestation.rs create mode 100644 desktop/src/features/profile/ui/useOwnerAttestationActions.ts diff --git a/desktop/src-tauri/src/commands/agent_attestation.rs b/desktop/src-tauri/src/commands/agent_attestation.rs new file mode 100644 index 0000000000..20cc0eb842 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_attestation.rs @@ -0,0 +1,194 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + build_managed_agent_summary, load_managed_agents, load_personas, provider_update_auth, + resolve_provider_binary, save_managed_agents, BackendKind, ManagedAgentRecord, + ManagedAgentSummary, + }, +}; + +const MIN_TTL_SECONDS: u64 = 5 * 60; +const MAX_TTL_SECONDS: u64 = 31 * 24 * 60 * 60; + +#[derive(Clone)] +struct ProviderTarget { + provider_id: String, + provider_config: serde_json::Value, + backend_agent_id: String, + pubkey: String, + prior_auth_tag: Option, +} + +fn now_unix() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| format!("system clock is before Unix epoch: {error}")) +} + +fn provider_target(record: &ManagedAgentRecord) -> Result { + let BackendKind::Provider { id, config } = &record.backend else { + return Err("owner attestation lifecycle is available only for provider agents".into()); + }; + let backend_agent_id = record + .backend_agent_id + .clone() + .ok_or_else(|| "provider agent has no deployed backend_agent_id".to_string())?; + Ok(ProviderTarget { + provider_id: id.clone(), + provider_config: config.clone(), + backend_agent_id, + pubkey: record.pubkey.clone(), + prior_auth_tag: record.auth_tag.clone(), + }) +} + +fn mint_bounded_auth_tag( + state: &AppState, + agent_pubkey: &str, + ttl_seconds: u64, +) -> Result { + if !(MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&ttl_seconds) { + return Err(format!( + "attestation lifetime must be between {MIN_TTL_SECONDS} and {MAX_TTL_SECONDS} seconds" + )); + } + let expires_at = now_unix()? + .checked_add(ttl_seconds) + .ok_or_else(|| "attestation expiry overflow".to_string())?; + let owner_keys = state.signing_keys()?; + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|error| format!("failed to bridge owner keys: {error}"))?; + let compat_agent = nostr::PublicKey::from_hex(agent_pubkey) + .map_err(|error| format!("invalid managed agent pubkey: {error}"))?; + buzz_sdk_pkg::nip_oa::compute_auth_tag( + &compat_owner, + &compat_agent, + &format!("created_at<{expires_at}"), + ) + .map_err(|error| format!("failed to compute owner attestation: {error}")) +} + +fn invoke_provider_update(target: &ProviderTarget, auth_tag: Option) -> Result<(), String> { + let binary = resolve_provider_binary(&target.provider_id)?; + provider_update_auth( + &binary, + &target.backend_agent_id, + &target.pubkey, + auth_tag.as_deref(), + &target.provider_config, + ) + .map(|_| ()) +} + +async fn update_owner_attestation( + pubkey: String, + auth_tag: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let target = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + provider_target(record)? + }; + + let provider_request_target = target.clone(); + let provider_auth_tag = auth_tag.clone(); + tokio::task::spawn_blocking(move || { + invoke_provider_update(&provider_request_target, provider_auth_tag) + }) + .await + .map_err(|error| format!("provider auth_update task failed: {error}"))??; + + let save_result = (|| -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let record = records + .iter_mut() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} disappeared during auth_update"))?; + let current_target = provider_target(record)?; + if current_target.provider_id != target.provider_id + || current_target.backend_agent_id != target.backend_agent_id + { + return Err("provider identity changed during auth_update".to_string()); + } + record.auth_tag = auth_tag; + record.updated_at = crate::util::now_iso(); + record.last_error = None; + save_managed_agents(&app, &records)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app).unwrap_or_default(); + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} disappeared after auth_update"))?; + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + ) + })(); + + match save_result { + Ok(summary) => Ok(summary), + Err(save_error) => { + let rollback_target = target.clone(); + let rollback_tag = target.prior_auth_tag.clone(); + let rollback = tokio::task::spawn_blocking(move || { + invoke_provider_update(&rollback_target, rollback_tag) + }) + .await + .map_err(|error| format!("provider rollback task failed: {error}")) + .and_then(|result| result); + match rollback { + Ok(()) => Err(format!( + "could not save owner attestation; provider was rolled back: {save_error}" + )), + Err(rollback_error) => Err(format!( + "could not save owner attestation and provider rollback failed: {save_error}; {rollback_error}" + )), + } + } + } +} + +#[tauri::command] +pub async fn attest_managed_agent_owner( + pubkey: String, + ttl_seconds: u64, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let auth_tag = mint_bounded_auth_tag(&state, &pubkey, ttl_seconds)?; + update_owner_attestation(pubkey, Some(auth_tag), app, state).await +} + +#[tauri::command] +pub async fn revoke_managed_agent_owner_attestation( + pubkey: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + update_owner_attestation(pubkey, None, app, state).await +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1c89ee4f77..4d3a35a2b3 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_attestation; mod agent_auth; mod agent_config; mod agent_discovery; @@ -60,6 +61,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use agent_attestation::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5346791ccf..5ada930f1f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -798,6 +798,8 @@ pub fn run() { reconcile_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, create_managed_agent, + attest_managed_agent_owner, + revoke_managed_agent_owner_attestation, start_managed_agent, stop_managed_agent, set_agent_managed_profiles, diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 2a72af92d7..4c73519fe0 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -180,9 +180,9 @@ pub fn invoke_provider( stdout_buf.truncate(STDOUT_CAP); let stderr = String::from_utf8_lossy(&stderr_bytes); - let env_secrets = env_secrets_from_request(request); - let env_secret_refs: Vec<&str> = env_secrets.iter().map(String::as_str).collect(); - let stderr_redacted = redact_secrets_with(&stderr, &env_secret_refs); + let request_secrets = secrets_from_request(request); + let request_secret_refs: Vec<&str> = request_secrets.iter().map(String::as_str).collect(); + let stderr_redacted = redact_secrets_with(&stderr, &request_secret_refs); let exit_info = exit_status .code() @@ -225,7 +225,7 @@ pub fn invoke_provider( if response.get("ok").and_then(|v| v.as_bool()) == Some(false) { let error = response["error"].as_str().unwrap_or("unknown error"); - return Err(redact_secrets_with(error, &env_secret_refs)); + return Err(redact_secrets_with(error, &request_secret_refs)); } Ok(response) @@ -323,8 +323,8 @@ fn redact_secrets_with(s: &str, extras: &[&str]) -> String { /// to feed into [`redact_secrets_with`]. Returns an empty Vec if the /// request shape doesn't match, which is fine — falls back to the default /// prefix-based scrubbing. -fn env_secrets_from_request(request: &serde_json::Value) -> Vec { - request +fn secrets_from_request(request: &serde_json::Value) -> Vec { + let mut secrets: Vec = request .get("agent") .and_then(|a| a.get("env_vars")) .and_then(|e| e.as_object()) @@ -335,7 +335,18 @@ fn env_secrets_from_request(request: &serde_json::Value) -> Vec { .map(String::from) .collect() }) - .unwrap_or_default() + .unwrap_or_default(); + if let Some(auth_tag) = request.get("auth_tag").and_then(|value| value.as_str()) { + if !auth_tag.is_empty() { + secrets.push(auth_tag.to_string()); + if let Ok(parts) = serde_json::from_str::>(auth_tag) { + if let Some(signature) = parts.get(3).filter(|value| !value.is_empty()) { + secrets.push(signature.clone()); + } + } + } + } + secrets } /// Public-in-crate helper: redact every non-empty value from `env` (plus @@ -376,6 +387,45 @@ pub fn provider_deploy( .ok_or_else(|| "deploy response missing agent_id".to_string()) } +/// Install, rotate, or remove an owner attestation for an already-deployed +/// provider agent. +/// +/// `auth_tag = Some(...)` installs or rotates the derived NIP-OA credential; +/// `None` removes it. The owner private key never crosses this boundary. +pub fn provider_update_auth( + binary: &Path, + agent_id: &str, + agent_pubkey: &str, + auth_tag: Option<&str>, + provider_config: &serde_json::Value, +) -> Result { + let request = serde_json::json!({ + "op": "auth_update", + "request_id": uuid::Uuid::new_v4().to_string(), + "agent_id": agent_id, + "agent_pubkey": agent_pubkey, + "auth_tag": auth_tag, + "provider_config": provider_config, + }); + let response = invoke_provider(binary, &request, Duration::from_secs(60))?; + if response.get("agent_id").and_then(|value| value.as_str()) != Some(agent_id) { + return Err("auth_update response agent_id mismatch".to_string()); + } + let expected_state = if auth_tag.is_some() { + "present" + } else { + "absent" + }; + if response + .get("credential_state") + .and_then(|value| value.as_str()) + != Some(expected_state) + { + return Err("auth_update response credential_state mismatch".to_string()); + } + Ok(response) +} + /// Validate provider_config: flat object, scalar values, no secret-like keys. pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> { let obj = config @@ -586,7 +636,7 @@ mod tests { } #[test] - fn env_secrets_from_request_extracts_string_values() { + fn secrets_from_request_extracts_string_values() { let req = serde_json::json!({ "op": "deploy", "agent": { @@ -597,19 +647,30 @@ mod tests { }, }, }); - let secrets = env_secrets_from_request(&req); + let secrets = secrets_from_request(&req); assert!(secrets.iter().any(|v| v == "sk-ant-test")); // Empty and non-string values are filtered out. assert_eq!(secrets.len(), 1); } #[test] - fn env_secrets_from_request_handles_missing_shape() { - assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); - assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); - assert!( - env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty() - ); + fn secrets_from_request_handles_missing_shape() { + assert!(secrets_from_request(&serde_json::json!({})).is_empty()); + assert!(secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); + assert!(secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty()); + } + + #[test] + fn secrets_from_request_extracts_auth_tag_and_signature() { + let signature = "a".repeat(128); + let auth_tag = + serde_json::json!(["auth", "b".repeat(64), "created_at<2000000000", signature]) + .to_string(); + let request = serde_json::json!({"op": "auth_update", "auth_tag": auth_tag}); + let secrets = secrets_from_request(&request); + assert_eq!(secrets.len(), 2); + assert_eq!(secrets[0], auth_tag); + assert_eq!(secrets[1], signature); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..926210d8a0 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; mod nest; +mod owner_attestation; mod persona_avatars; pub(crate) mod persona_events; mod personas; @@ -57,6 +58,7 @@ pub(crate) use global_config::{ }; pub(crate) use managed_node_paths::*; pub use nest::*; +pub(crate) use owner_attestation::owner_attestation_summary; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; diff --git a/desktop/src-tauri/src/managed_agents/owner_attestation.rs b/desktop/src-tauri/src/managed_agents/owner_attestation.rs new file mode 100644 index 0000000000..69f57247e4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/owner_attestation.rs @@ -0,0 +1,85 @@ +use sha2::{Digest, Sha256}; + +use super::{ManagedAgentOwnerAttestation, ManagedAgentRecord}; + +pub(crate) fn owner_attestation_summary( + record: &ManagedAgentRecord, +) -> ManagedAgentOwnerAttestation { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + summarize_auth_tag(&record.pubkey, record.auth_tag.as_deref(), now) +} + +fn summarize_auth_tag( + agent_pubkey: &str, + auth_tag: Option<&str>, + now: u64, +) -> ManagedAgentOwnerAttestation { + let Some(auth_tag) = auth_tag.map(str::trim).filter(|value| !value.is_empty()) else { + return ManagedAgentOwnerAttestation { + state: "missing".to_string(), + owner_pubkey: None, + conditions: None, + expires_at: None, + fingerprint: None, + }; + }; + let fingerprint = hex::encode(Sha256::digest(auth_tag.as_bytes())) + .chars() + .take(12) + .collect::(); + let Ok(agent_pubkey) = nostr::PublicKey::from_hex(agent_pubkey) else { + return ManagedAgentOwnerAttestation { + state: "invalid".to_string(), + owner_pubkey: None, + conditions: None, + expires_at: None, + fingerprint: Some(fingerprint), + }; + }; + let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(auth_tag, &agent_pubkey) else { + return ManagedAgentOwnerAttestation { + state: "invalid".to_string(), + owner_pubkey: None, + conditions: None, + expires_at: None, + fingerprint: Some(fingerprint), + }; + }; + let parts = serde_json::from_str::>(auth_tag).unwrap_or_default(); + let conditions = parts.get(2).cloned().unwrap_or_default(); + let expires_at = conditions + .split('&') + .find_map(|clause| clause.strip_prefix("created_at<")) + .and_then(|value| value.parse::().ok()); + ManagedAgentOwnerAttestation { + state: match expires_at { + Some(expiry) if expiry <= now => "expired".to_string(), + Some(_) => "bounded".to_string(), + None => "unbounded".to_string(), + }, + owner_pubkey: Some(owner_pubkey.to_hex()), + conditions: Some(conditions), + expires_at, + fingerprint: Some(fingerprint), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_and_malformed_credentials_are_redacted() { + let missing = summarize_auth_tag(&"a".repeat(64), None, 1_000); + assert_eq!(missing.state, "missing"); + assert_eq!(missing.fingerprint, None); + + let malformed = summarize_auth_tag(&"a".repeat(64), Some("not-a-tag"), 1_000); + assert_eq!(malformed.state, "invalid"); + assert!(malformed.fingerprint.is_some()); + assert_eq!(malformed.conditions, None); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..b8f45c3e6a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -7,9 +7,9 @@ use super::agent_env::build_buzz_agent_provider_defaults; use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, - missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + missing_command_message, normalize_agent_args, open_log_file, owner_attestation_summary, + resolve_command, spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -63,8 +63,6 @@ pub(crate) use instance_reaper::reap_dead_instance_agents; #[cfg(test)] use instance_reaper::{buffer_contains_identifier, is_desktop_binary}; -// Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). - mod lifecycle; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; @@ -324,6 +322,7 @@ pub fn build_managed_agent_summary( env_vars: record.env_vars.clone(), backend: record.backend.clone(), backend_agent_id: record.backend_agent_id.clone(), + owner_attestation: owner_attestation_summary(record), status, pid, created_at: record.created_at.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index dcb8095a7c..d4d99046bb 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -521,6 +521,7 @@ pub struct ManagedAgentSummary { pub env_vars: BTreeMap, pub backend: BackendKind, pub backend_agent_id: Option, + pub owner_attestation: ManagedAgentOwnerAttestation, pub status: String, pub pid: Option, pub created_at: String, @@ -537,6 +538,20 @@ pub struct ManagedAgentSummary { pub respond_to_allowlist: Vec, } +/// Redacted lifecycle state for a managed agent's NIP-OA owner attestation. +/// +/// The raw credential never crosses IPC. `fingerprint` is a short SHA-256 +/// prefix suitable for confirming rotation without disclosing the capability. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct ManagedAgentOwnerAttestation { + pub state: String, + pub owner_pubkey: Option, + pub conditions: Option, + pub expires_at: Option, + pub fingerprint: Option, +} + #[derive(Debug, Serialize)] pub struct CreateManagedAgentResponse { pub agent: ManagedAgentSummary, diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx index 81db3405b2..fb743477bd 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -6,6 +6,7 @@ import { Download, Power, Settings, + ShieldCheck, Trash2, } from "lucide-react"; @@ -40,6 +41,8 @@ export function UserProfileAgentSettingsMenu({ onDelete, onDuplicatePersona, onExportPersona, + onAttestOwner, + onRevokeOwnerAttestation, onToggleAutoStart, personaActionKey, }: { @@ -50,11 +53,14 @@ export function UserProfileAgentSettingsMenu({ onDelete?: () => void; onDuplicatePersona?: () => void; onExportPersona?: () => void; + onAttestOwner?: (ttlSeconds: number) => Promise; + onRevokeOwnerAttestation?: () => Promise; onToggleAutoStart?: () => void; personaActionKey?: string; }) { const [archiveConfirmOpen, setArchiveConfirmOpen] = React.useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false); + const [attestationOpen, setAttestationOpen] = React.useState(false); const actionKey = managedAgent?.pubkey ?? "persona-draft"; const personaKey = personaActionKey ?? actionKey; const canToggleAutoStart = @@ -63,6 +69,10 @@ export function UserProfileAgentSettingsMenu({ onToggleAutoStart !== undefined; const autoStartSwitchId = `user-profile-agent-auto-start-${actionKey}`; const hasPrimaryActions = Boolean(onDuplicatePersona || onExportPersona); + const canManageOwnerAttestation = + managedAgent?.backend.type === "provider" && + onAttestOwner !== undefined && + onRevokeOwnerAttestation !== undefined; const hasArchiveAction = archiveActions?.canArchive === true && archiveActions.isArchived !== undefined; @@ -70,7 +80,10 @@ export function UserProfileAgentSettingsMenu({ managedAgent !== undefined && onDelete !== undefined; const hasManageActions = hasArchiveAction || Boolean(onDelete); const hasActions = - canToggleAutoStart || hasPrimaryActions || hasManageActions; + canToggleAutoStart || + hasPrimaryActions || + hasManageActions || + canManageOwnerAttestation; if (!hasActions) { return null; @@ -142,7 +155,22 @@ export function UserProfileAgentSettingsMenu({ Export ) : null} - {hasManageActions && (canToggleAutoStart || hasPrimaryActions) ? ( + {canManageOwnerAttestation ? ( + setAttestationOpen(true)} + > + + {managedAgent.ownerAttestation.state === "missing" + ? "Authorize owner actions" + : "Manage owner authorization"} + + ) : null} + {hasManageActions && + (canToggleAutoStart || + hasPrimaryActions || + canManageOwnerAttestation) ? ( ) : null} {hasArchiveAction && archiveActions ? ( @@ -210,6 +238,16 @@ export function UserProfileAgentSettingsMenu({ open={deleteConfirmOpen} /> ) : null} + {canManageOwnerAttestation ? ( + + ) : null} ); } @@ -226,6 +264,8 @@ export function UserProfileAgentSettingsMenuSlot({ onDeletePersona, onDuplicatePersona, onExportPersona, + onAttestOwner, + onRevokeOwnerAttestation, onToggleAutoStart, personaActionKey, viewerIsOwner, @@ -241,6 +281,8 @@ export function UserProfileAgentSettingsMenuSlot({ onDeletePersona: () => void; onDuplicatePersona: () => void; onExportPersona: () => void; + onAttestOwner: (ttlSeconds: number) => Promise; + onRevokeOwnerAttestation: () => Promise; onToggleAutoStart: () => void; personaActionKey?: string; viewerIsOwner: boolean; @@ -264,6 +306,8 @@ export function UserProfileAgentSettingsMenuSlot({ {...sharedProps} managedAgent={managedAgent} onDelete={onDeleteAgent} + onAttestOwner={onAttestOwner} + onRevokeOwnerAttestation={onRevokeOwnerAttestation} onToggleAutoStart={onToggleAutoStart} /> ); @@ -291,6 +335,118 @@ export function UserProfileAgentSettingsMenuSlot({ return null; } +const ATTESTATION_TTLS = [ + { label: "1 hour", seconds: 60 * 60 }, + { label: "24 hours", seconds: 24 * 60 * 60 }, + { label: "7 days", seconds: 7 * 24 * 60 * 60 }, + { label: "30 days", seconds: 30 * 24 * 60 * 60 }, +] as const; + +function OwnerAttestationDialog({ + agent, + isPending, + onAttest, + onOpenChange, + onRevoke, + open, +}: { + agent: ManagedAgent; + isPending: boolean; + onAttest: (ttlSeconds: number) => Promise; + onOpenChange: (open: boolean) => void; + onRevoke: () => Promise; + open: boolean; +}) { + const [ttlSeconds, setTtlSeconds] = React.useState(24 * 60 * 60); + const hasCredential = + agent.ownerAttestation.state === "bounded" || + agent.ownerAttestation.state === "unbounded"; + const expiry = agent.ownerAttestation.expiresAt + ? new Date(agent.ownerAttestation.expiresAt * 1000).toLocaleString() + : null; + + return ( + + + + + Owner authorization for {agent.name} + + + This lets a compatible relay authenticate the agent as your + delegated NIP-OA identity. In Buzz Desktop, that enables + owner-reviewed agent drafts. It does not grant channel roles, relay + administration, deploy, database, Stripe, or migration authority. + + +
+

+ Status:{" "} + + {agent.ownerAttestation.state} + + {expiry ? ` · expires ${expiry}` : ""} + {agent.ownerAttestation.fingerprint + ? ` · fingerprint ${agent.ownerAttestation.fingerprint}` + : ""} +

+ +

+ Rotation replaces the provider's credential and restarts only + this agent. The provider removes it and restarts the agent at the + selected expiry. Relay-side cryptographic expiry also requires a + relay that enforces NIP-AA time bounds. +

+
+ + + + + {hasCredential ? ( + + ) : null} + + +
+
+ ); +} + function AgentDeleteConfirmDialog({ agent, isPending, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index c1f713d230..6b2e8aa253 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -69,6 +69,7 @@ import { import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; +import { useOwnerAttestationActions } from "@/features/profile/ui/useOwnerAttestationActions"; import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs"; @@ -247,6 +248,11 @@ export function UserProfilePanel({ const stopAgentMutation = useStopManagedAgentMutation(); const deleteAgentMutation = useDeleteManagedAgentMutation(); const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); + const { + isPending: isOwnerAttestationPending, + onAttestOwner: handleAttestOwner, + onRevokeOwnerAttestation: handleRevokeOwnerAttestation, + } = useOwnerAttestationActions(managedAgent); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); @@ -351,6 +357,7 @@ export function UserProfilePanel({ stopAgentMutation.isPending || deleteAgentMutation.isPending || startOnLaunchMutation.isPending || + isOwnerAttestationPending || createPersonaMutation.isPending || updatePersonaMutation.isPending || deletePersonaMutation.isPending || @@ -726,10 +733,12 @@ export function UserProfilePanel({ isAgentActionPending={isAgentActionPending} isBot={isBot} managedAgent={managedAgent} + onAttestOwner={handleAttestOwner} onDeleteAgent={handleDeleteAgent} onDeletePersona={handleDeletePersona} onDuplicatePersona={handleDuplicatePersona} onExportPersona={handleExportPersona} + onRevokeOwnerAttestation={handleRevokeOwnerAttestation} onToggleAutoStart={handleToggleAgentAutoStart} personaActionKey={resolvedPersona?.id} viewerIsOwner={viewerIsOwner} diff --git a/desktop/src/features/profile/ui/useOwnerAttestationActions.ts b/desktop/src/features/profile/ui/useOwnerAttestationActions.ts new file mode 100644 index 0000000000..4b45ade958 --- /dev/null +++ b/desktop/src/features/profile/ui/useOwnerAttestationActions.ts @@ -0,0 +1,82 @@ +import * as React from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { managedAgentsQueryKey } from "@/features/agents/hooks"; +import { + attestManagedAgentOwner, + revokeManagedAgentOwnerAttestation, +} from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgent } from "@/shared/api/types"; + +export function useOwnerAttestationActions( + managedAgent: ManagedAgent | undefined, +) { + const queryClient = useQueryClient(); + const attestMutation = useMutation({ + mutationFn: ({ + pubkey, + ttlSeconds, + }: { + pubkey: string; + ttlSeconds: number; + }) => attestManagedAgentOwner(pubkey, ttlSeconds), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + }, + }); + const revokeMutation = useMutation({ + mutationFn: (pubkey: string) => revokeManagedAgentOwnerAttestation(pubkey), + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + }, + }); + + const onAttestOwner = React.useCallback( + async (ttlSeconds: number) => { + if (managedAgent?.backend.type !== "provider") return false; + try { + const updated = await attestMutation.mutateAsync({ + pubkey: managedAgent.pubkey, + ttlSeconds, + }); + const fingerprint = + updated.ownerAttestation.fingerprint ?? "unavailable"; + toast.success( + `Authorized ${updated.name}; credential fingerprint ${fingerprint}.`, + ); + return true; + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to authorize owner actions.", + ); + return false; + } + }, + [attestMutation.mutateAsync, managedAgent], + ); + + const onRevokeOwnerAttestation = React.useCallback(async () => { + if (managedAgent?.backend.type !== "provider") return false; + try { + const updated = await revokeMutation.mutateAsync(managedAgent.pubkey); + toast.success(`Revoked owner authorization for ${updated.name}.`); + return true; + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to revoke owner authorization.", + ); + return false; + } + }, [managedAgent, revokeMutation.mutateAsync]); + + return { + isPending: attestMutation.isPending || revokeMutation.isPending, + onAttestOwner, + onRevokeOwnerAttestation, + }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..6277a4ff8b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -154,6 +154,13 @@ export type RawManagedAgent = { auto_restart_on_config_change?: boolean; backend: ManagedAgentBackend; backend_agent_id: string | null; + owner_attestation?: { + state: ManagedAgent["ownerAttestation"]["state"]; + owner_pubkey: string | null; + conditions: string | null; + expires_at: number | null; + fingerprint: string | null; + }; // Optional: pre-feature mock fixtures may omit these. Mapped to // `"owner-only"` / `[]` in `fromRawManagedAgent`. respond_to?: ManagedAgent["respondTo"]; @@ -735,6 +742,13 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { autoRestartOnConfigChange: agent.auto_restart_on_config_change ?? true, backend: agent.backend, backendAgentId: agent.backend_agent_id, + ownerAttestation: { + state: agent.owner_attestation?.state ?? "missing", + ownerPubkey: agent.owner_attestation?.owner_pubkey ?? null, + conditions: agent.owner_attestation?.conditions ?? null, + expiresAt: agent.owner_attestation?.expires_at ?? null, + fingerprint: agent.owner_attestation?.fingerprint ?? null, + }, // Fallbacks for pre-feature mocks/fixtures that don't carry these fields. // Real agent records always include them (defaulted server-side). respondTo: agent.respond_to ?? "owner-only", diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index c74b099f88..78b1d8b82b 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -22,6 +22,27 @@ export async function stopManagedAgent(pubkey: string): Promise { return fromRawManagedAgent(response); } +export async function attestManagedAgentOwner( + pubkey: string, + ttlSeconds: number, +): Promise { + const response = await invokeTauri( + "attest_managed_agent_owner", + { pubkey, ttlSeconds }, + ); + return fromRawManagedAgent(response); +} + +export async function revokeManagedAgentOwnerAttestation( + pubkey: string, +): Promise { + const response = await invokeTauri( + "revoke_managed_agent_owner_attestation", + { pubkey }, + ); + return fromRawManagedAgent(response); +} + export async function setManagedAgentStartOnAppLaunch( pubkey: string, startOnAppLaunch: boolean, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d14b66eebb..7dee4a3eab 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -391,6 +391,7 @@ export type ManagedAgent = { autoRestartOnConfigChange: boolean; backend: ManagedAgentBackend; backendAgentId: string | null; + ownerAttestation: ManagedAgentOwnerAttestation; /** Who the agent should respond to. Maps to `buzz-acp --respond-to`. */ respondTo: RespondToMode; /** @@ -400,6 +401,14 @@ export type ManagedAgent = { respondToAllowlist: string[]; }; +export type ManagedAgentOwnerAttestation = { + state: "missing" | "bounded" | "expired" | "unbounded" | "invalid"; + ownerPubkey: string | null; + conditions: string | null; + expiresAt: number | null; + fingerprint: string | null; +}; + /** * Inbound author gate mode. Mirrors `buzz-acp`'s `--respond-to` CLI flag. * `"nobody"` is supported by the harness but not surfaced through this API — From bd727d9dbfb1c865f9326d8f7bde34489354c110 Mon Sep 17 00:00:00 2001 From: Daniel Schwartz Date: Tue, 28 Jul 2026 21:20:43 -0700 Subject: [PATCH 2/2] fix(desktop): surface structured provider errors --- .../src-tauri/src/managed_agents/backend.rs | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 4c73519fe0..0d26b5e9f3 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -189,11 +189,23 @@ pub fn invoke_provider( .map(|c| format!("exit code {c}")) .unwrap_or_else(|| "killed by signal".to_string()); - // Fail on non-zero exit regardless of stdout content. A provider that - // crashes mid-deploy may flush partial JSON before dying — trusting that - // output would be worse than surfacing the failure. + // Parse a structured provider error even when the provider exits non-zero. + // The response remains untrusted: a non-zero exit can never produce a + // successful result, but its redacted `error` field is more useful than + // "empty stderr" for providers that deliberately return JSON on stdout. + let stdout_str = String::from_utf8_lossy(&stdout_buf); + let parsed_response = stdout_str + .lines() + .find_map(|line| serde_json::from_str::(line).ok()) + .or_else(|| serde_json::from_str(stdout_str.trim()).ok()); let exited_ok = exit_status.success(); if !exited_ok { + if let Some(response) = parsed_response.as_ref() { + if response.get("ok").and_then(|value| value.as_bool()) == Some(false) { + let error = response["error"].as_str().unwrap_or("unknown error"); + return Err(redact_secrets_with(error, &request_secret_refs)); + } + } let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; if stderr_snippet.is_empty() { return Err(format!("provider failed ({exit_info}, empty stderr)")); @@ -207,21 +219,14 @@ pub fn invoke_provider( // Incremental JSON parse: try each line, then try the entire buffer. // Handles providers that emit JSON on a single line (common) as well as // providers that write JSON without a trailing newline. - let stdout_str = String::from_utf8_lossy(&stdout_buf); - let response: serde_json::Value = stdout_str - .lines() - .find_map(|line| serde_json::from_str(line).ok()) - .or_else(|| serde_json::from_str(stdout_str.trim()).ok()) - .ok_or_else(|| { - let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; - if stderr_snippet.is_empty() { - format!("provider produced no JSON response ({exit_info}, empty stderr)") - } else { - format!( - "provider produced no JSON response ({exit_info}). stderr: {stderr_snippet}" - ) - } - })?; + let response = parsed_response.ok_or_else(|| { + let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)]; + if stderr_snippet.is_empty() { + format!("provider produced no JSON response ({exit_info}, empty stderr)") + } else { + format!("provider produced no JSON response ({exit_info}). stderr: {stderr_snippet}") + } + })?; if response.get("ok").and_then(|v| v.as_bool()) == Some(false) { let error = response["error"].as_str().unwrap_or("unknown error");