diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index be873bdd0..fd91d9efe 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -8,6 +8,7 @@ mod observer; mod pool; mod queue; mod relay; +mod setup_mode; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -1091,6 +1092,19 @@ async fn tokio_main() -> Result<()> { .init(); let mut config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; + + // ── Setup-mode early branch ─────────────────────────────────────────────── + // + // When the desktop determines an agent is not ready (missing credentials, + // model, or provider), it spawns buzz-acp with BUZZ_ACP_SETUP_PAYLOAD set. + // We enter the minimal setup-listener path and never start the agent pool. + if let Some(payload) = setup_mode::SetupPayload::from_env() + .map_err(|e| anyhow::anyhow!("setup payload error: {e}"))? + { + tracing::info!("buzz-acp: setup payload present, entering setup-listener mode"); + return setup_mode::run_setup_listener(config, payload).await; + } + tracing::info!("buzz-acp starting: {}", config.summary()); let observer = config diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs new file mode 100644 index 000000000..9fe51f415 --- /dev/null +++ b/crates/buzz-acp/src/setup_mode.rs @@ -0,0 +1,769 @@ +//! Setup-mode listener for not-ready agents. +//! +//! When the desktop determines an agent is `NotReady` (missing provider, +//! model, or credentials), it spawns `buzz-acp` with a setup-mode payload +//! instead of starting the normal agent pool. This module implements that +//! early-branch path: +//! +//! ```text +//! Config::from_cli() +//! └─ SetupPayload::from_env()? +//! ├─ Some(payload) → run_setup_listener(config, payload) [this module] +//! └─ None → normal pool path (unchanged) +//! ``` +//! +//! # Contract (NON-NEGOTIABLE) +//! +//! * **Desktop is the ONLY readiness source.** `buzz-acp` trusts the payload +//! passed by the desktop and does NOT re-derive readiness. +//! * **Normal startup gains no second readiness path.** The early branch is +//! entered only when `BUZZ_ACP_SETUP_PAYLOAD` is set. +//! * `spawn_key_refusal`-class identity failures are outside this path: no +//! valid key → no safe process to post as the agent. +//! +//! # Nudge mechanics +//! +//! Connect → subscribe → on each matching event: +//! 1. Apply `ignore_self` gate. +//! 2. Apply `author_allowed` gate (same as normal mode) so the nudge goes +//! only to authors the real agent would answer. +//! 3. Require an explicit @mention via `event_mentions_agent`. +//! 4. Apply `filter::match_event` so channel/kind rules still constrain. +//! 5. Build and publish a nudge reply (surface-correct copy). +//! 6. Deduplicate by event-id (reconnect replay must not double-nudge). + +use std::collections::HashSet; + +use anyhow::Result; +use buzz_core::kind::{ + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, + KIND_WORKFLOW_APPROVAL_REQUESTED, +}; +use nostr::EventId; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + author_allowed, + config::Config, + event_mentions_agent, filter, + relay::{HarnessRelay, RelayEventPublisher}, +}; + +// ── Payload ─────────────────────────────────────────────────────────────────── + +/// Env var carrying the JSON-encoded setup payload. +pub(crate) const SETUP_PAYLOAD_ENV_VAR: &str = "BUZZ_ACP_SETUP_PAYLOAD"; + +/// A single missing requirement, surface-discriminated so the nudge copy +/// names exactly what to set and where. +/// +/// This is the Rust counterpart to the desktop's `Requirement` type +/// (`managed_agents/readiness.rs`). Desktop serializes it; `buzz-acp` +/// deserializes and renders copy from it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "surface", rename_all = "snake_case")] +pub(crate) enum RequirementPayload { + /// A normalized dropdown field (provider or model) missing. + NormalizedField { field: String }, + /// An env-backed credential that is absent. + EnvKey { key: String }, + /// A CLI authentication step that must be completed interactively. + CliLogin { + probe_args: Vec, + setup_copy: String, + }, +} + +impl RequirementPayload { + /// Human-readable instruction fragment for the nudge copy. + fn instruction(&self) -> String { + match self { + RequirementPayload::NormalizedField { field } => { + format!("set the **{}** field in Edit Agent dropdowns", field) + } + RequirementPayload::EnvKey { key } => { + format!("set `{}` in Edit Agent → Environment variables", key) + } + RequirementPayload::CliLogin { setup_copy, .. } => setup_copy.clone(), + } + } +} + +/// The full setup payload passed by the desktop when spawning in setup mode. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SetupPayload { + /// Human-readable agent display name (for the nudge message). + pub agent_name: String, + /// Hex-encoded agent pubkey. Carried so the desktop card can open + /// the Edit Agent dialog for this agent directly from the nudge. + pub agent_pubkey: String, + /// Surface-discriminated list of missing requirements. + pub requirements: Vec, +} + +impl SetupPayload { + /// Read and deserialize the setup payload from the env var, if present. + /// + /// Returns `Ok(None)` when the env var is absent (normal mode). + /// Returns `Err` if the var is present but malformed. + pub(crate) fn from_env() -> Result> { + Self::from_raw_env_value(std::env::var(SETUP_PAYLOAD_ENV_VAR).ok()) + } + + /// Parse an optional raw env-var value into a `SetupPayload`. + /// + /// `None` or empty string → `Ok(None)` (normal mode, no setup payload). + /// Non-empty, valid JSON → `Ok(Some(payload))`. + /// Non-empty, malformed JSON → `Err`. + /// + /// This is the pure core of `from_env()` and is the preferred target for + /// unit tests — it requires no global env mutation and is safe to call + /// concurrently. + pub(crate) fn from_raw_env_value(raw: Option) -> Result> { + let raw = match raw { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let payload = serde_json::from_str::(&raw) + .map_err(|e| anyhow::anyhow!("malformed {SETUP_PAYLOAD_ENV_VAR}: {e}"))?; + Ok(Some(payload)) + } + + /// Build the nudge message body from the requirements. + /// + /// The body contains two parts separated by a blank line: + /// 1. Human-readable markdown (unchanged; used by CLI and non-card clients). + /// 2. A fenced `buzz:config-nudge` sentinel block containing the structured + /// payload as JSON. The desktop client parses this block to render a + /// `ConfigNudgeCard`; clients that don't understand it see a code block. + fn nudge_body(&self) -> String { + let prose = if self.requirements.is_empty() { + format!( + "**{}** needs configuration before it can respond. Open Edit Agent to configure it.", + self.agent_name, + ) + } else { + let steps: Vec = self + .requirements + .iter() + .map(|r| format!("- {}", r.instruction())) + .collect(); + + format!( + "**{}** needs configuration before it can respond:\n{}\n\nOpen Edit Agent in the Buzz app to set these.", + self.agent_name, + steps.join("\n"), + ) + }; + + // SAFETY: `self` is fully `Serialize`; this can only fail if the types + // contain non-string map keys, which they don't — panic is acceptable. + let sentinel_json = + serde_json::to_string(self).expect("SetupPayload must be serializable to JSON"); + + format!("{}\n\n```buzz:config-nudge\n{}\n```", prose, sentinel_json) + } +} + +// ── setup listener ──────────────────────────────────────────────────────────── + +/// Run the setup-mode event loop. +/// +/// Connects to the relay, subscribes to channels, and responds to @mentions +/// of this agent with a surface-correct setup nudge. Never starts the agent +/// pool. Reconnects on relay close (mirroring normal mode) so the nudge +/// listener survives transient disconnects; `nudged_event_ids` deduplication +/// guards against replay on reconnect. +pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> Result<()> { + tracing::info!( + agent = %payload.agent_name, + requirements = payload.requirements.len(), + "buzz-acp entering setup mode" + ); + + let pubkey_hex = config.keys.public_key().to_hex(); + + // Parse BUZZ_AUTH_TAG for relay membership / NIP-OA. + let relay_auth_tag: Option = std::env::var("BUZZ_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + .and_then(|s| buzz_sdk::nip_oa::parse_auth_tag(&s).ok()); + + let startup_watermark: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut relay = + HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) + .await + .map_err(|e| anyhow::anyhow!("setup-mode relay connect error: {e}"))?; + + if let Err(e) = relay.set_startup_watermark(startup_watermark).await { + tracing::warn!("setup-mode: failed to set startup watermark: {e}"); + } + + relay + .subscribe_membership_notifications() + .await + .map_err(|e| anyhow::anyhow!("setup-mode membership subscribe error: {e}"))?; + + tracing::info!("setup-mode: connected and subscribed to membership notifications"); + + // Resolve owner for author-gate (same priority as normal mode). + let startup_owner = crate::resolve_agent_owner(&config); + let owner_cache = crate::OwnerCache::new(startup_owner); + + // Discover channels and subscribe (using a "mentions" rule so we get + // notified when someone @-mentions the agent). + let channel_info_map = relay + .discover_channels() + .await + .map_err(|e| anyhow::anyhow!("setup-mode channel discovery error: {e}"))?; + + tracing::info!( + "setup-mode: discovered {} channel(s)", + channel_info_map.len() + ); + + let channel_ids: Vec = channel_info_map.keys().copied().collect(); + + // Build subscription rules: mentions only (setup mode must not react to + // every message in a channel). + let rules = build_setup_subscription_rules(&config); + + let channel_filters = crate::config::resolve_channel_filters(&config, &channel_ids, &rules); + + if channel_filters.is_empty() { + tracing::warn!( + "setup-mode: no channel subscriptions resolved — nudge listener will sit idle" + ); + } + + for (channel_id, filter) in &channel_filters { + if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { + tracing::warn!("setup-mode: failed to subscribe to channel {channel_id}: {e}"); + } else { + tracing::info!("setup-mode: subscribed to channel {channel_id}"); + } + } + + let publisher = relay.event_publisher(); + let rest_client = relay.rest_client(); + + // Deduplicate by event-id so reconnect replay cannot double-nudge. + let mut nudged_event_ids: HashSet = HashSet::new(); + + loop { + let Some(buzz_event) = relay.next_event().await else { + tracing::warn!("setup-mode: relay event stream ended — requesting reconnect"); + if let Err(e) = relay.reconnect().await { + tracing::error!("setup-mode: relay background task is gone: {e} — exiting"); + break; + } + continue; + }; + + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + + // Handle membership notifications so we subscribe to new channels + // and drop removed ones — no session/queue drain needed (no pool). + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION + || kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION + { + handle_setup_membership(&mut relay, &buzz_event, &config, &rules, &channel_ids).await; + continue; + } + + // Ignore non-message kinds (relay housekeeping, etc.). + if kind_u32 != KIND_STREAM_MESSAGE && kind_u32 != KIND_WORKFLOW_APPROVAL_REQUESTED { + continue; + } + + // ignore_self: don't react to our own messages. + if buzz_event.event.pubkey.to_hex() == pubkey_hex { + continue; + } + + // Require an explicit @mention of this agent — setup mode must not + // nudge on every channel event even if subscribe_mode is "all". + if !event_mentions_agent(&buzz_event.event, &pubkey_hex) { + continue; + } + + // Apply the same author gate as normal mode so the nudge only goes + // to authors the real agent would have answered. + let author_hex = buzz_event.event.pubkey.to_hex(); + let allowed = author_allowed( + &config.respond_to, + &config.respond_to_allowlist, + &author_hex, + &owner_cache, + &rest_client, + ) + .await; + + // Apply channel/kind filter rules. + let filter_matched = filter::match_event( + &buzz_event.event, + buzz_event.channel_id, + &rules, + &pubkey_hex, + ) + .await + .is_some(); + + // Pure gate: author gate verdict + event-id dedup. + if !should_nudge_for_event( + buzz_event.event.id, + allowed, + filter_matched, + &mut nudged_event_ids, + ) { + continue; + } + + // Build and publish the setup nudge. + if let Err(e) = publish_setup_nudge( + &publisher, + &config.keys, + buzz_event.channel_id, + &buzz_event.event, + &payload, + ) + .await + { + tracing::warn!("setup-mode: failed to publish nudge: {e}"); + } else { + tracing::info!( + channel_id = %buzz_event.channel_id, + event_id = %buzz_event.event.id, + "setup-mode: nudge published" + ); + } + } + + Ok(()) +} + +/// Outcome of the pure per-event gate checks in setup mode. +/// +/// Callers compute the async gates (`author_allowed`, `filter::match_event`) +/// up-front, then pass the boolean results here. This helper handles +/// everything that is synchronous and stateful: the author gate verdict +/// and event-id dedup. +/// +/// Returns `true` when the event should produce a nudge. +#[must_use] +pub(crate) fn should_nudge_for_event( + event_id: EventId, + author_allowed: bool, + filter_matched: bool, + nudged_event_ids: &mut HashSet, +) -> bool { + if !author_allowed { + tracing::debug!("setup-mode: event filtered by author gate"); + return false; + } + if !filter_matched { + return false; + } + if !nudged_event_ids.insert(event_id) { + tracing::debug!(%event_id, "setup-mode: skipping already-nudged event"); + return false; + } + true +} + +/// Build the subscription rules used in setup mode. +/// +/// Always uses "mentions" mode: setup mode must not react to every event. +/// Even if the agent's normal config is `subscribe_mode = all`, setup mode +/// enforces explicit @mention filtering (see `event_mentions_agent` call in +/// the loop above). +fn build_setup_subscription_rules(config: &Config) -> Vec { + use crate::config::SubscribeMode; + + let kinds = config + .kinds_override + .clone() + .unwrap_or_else(|| vec![KIND_STREAM_MESSAGE, KIND_WORKFLOW_APPROVAL_REQUESTED]); + + match &config.subscribe_mode { + // Config mode: load the actual rules, but they will be filtered by + // the explicit event_mentions_agent check in the main loop anyway. + SubscribeMode::Config => match crate::config::load_rules(&config.config_path) { + Ok(rules) => rules, + Err(e) => { + tracing::warn!( + "setup-mode: could not load config rules ({e}); falling back to mentions" + ); + vec![mentions_rule(kinds)] + } + }, + _ => vec![mentions_rule(kinds)], + } +} + +fn mentions_rule(kinds: Vec) -> filter::SubscriptionRule { + use std::sync::{atomic::AtomicU32, Arc}; + filter::SubscriptionRule { + name: "setup-mentions".into(), + channels: filter::ChannelScope::All("all".into()), + kinds, + require_mention: true, + filter: None, + compiled_filter: None, + consecutive_timeouts: Arc::new(AtomicU32::new(0)), + prompt_tag: Some("@mention".into()), + } +} + +/// Handle membership add/remove events in setup mode. +/// +/// Subscribe new channels; unsubscribe removed ones. No queue/session +/// teardown — there is no pool. +async fn handle_setup_membership( + relay: &mut HarnessRelay, + buzz_event: &crate::relay::BuzzEvent, + config: &Config, + rules: &[filter::SubscriptionRule], + _initial_channel_ids: &[Uuid], +) { + let kind_u32 = buzz_event.event.kind.as_u16() as u32; + let channel_id = buzz_event.channel_id; + + if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION { + // Subscribe to the newly-joined channel. + let ids = vec![channel_id]; + let filters = crate::config::resolve_channel_filters(config, &ids, rules); + for (cid, filter) in filters { + if let Err(e) = relay.subscribe_channel(cid, filter).await { + tracing::warn!("setup-mode: failed to subscribe to new channel {cid}: {e}"); + } else { + tracing::info!("setup-mode: subscribed to new channel {cid}"); + } + } + } else if kind_u32 == KIND_MEMBER_REMOVED_NOTIFICATION { + if let Err(e) = relay.unsubscribe_channel(channel_id).await { + tracing::warn!("setup-mode: failed to unsubscribe from channel {channel_id}: {e}"); + } + } +} + +/// Build and publish a setup nudge reply to the triggering event. +/// +/// Threading: flat reply to the thread root if one exists; otherwise reply +/// to the triggering event itself. P-tags the asker. +async fn publish_setup_nudge( + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + channel_id: Uuid, + triggering_event: &nostr::Event, + payload: &SetupPayload, +) -> Result<()> { + use buzz_sdk::ThreadRef; + + // Parse NIP-10 thread tags to determine reply target. + let thread_tags = crate::queue::parse_thread_tags(triggering_event); + + let thread_ref = if let Some(root_str) = &thread_tags.root_event_id { + // Threaded event: reply flat to the root. + let root_id = nostr::EventId::from_hex(root_str) + .map_err(|e| anyhow::anyhow!("invalid root event id: {e}"))?; + Some(ThreadRef { + root_event_id: root_id, + parent_event_id: root_id, + }) + } else { + // Top-level event: reply to the triggering event. + Some(ThreadRef { + root_event_id: triggering_event.id, + parent_event_id: triggering_event.id, + }) + }; + + let body = payload.nudge_body(); + let author_hex = triggering_event.pubkey.to_hex(); + + let event_builder = buzz_sdk::build_message( + channel_id, + &body, + thread_ref.as_ref(), + &[&author_hex], // p-tag the asker + false, + &[], + ) + .map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?; + + let signed = event_builder + .sign_with_keys(keys) + .map_err(|e| anyhow::anyhow!("failed to sign setup nudge: {e}"))?; + + publisher + .publish_event(signed) + .await + .map_err(|e| anyhow::anyhow!("failed to publish setup nudge: {e}"))?; + + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_payload_from_raw_returns_none_when_absent() { + // None → Ok(None): normal startup, no setup payload. + let result = SetupPayload::from_raw_env_value(None).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn setup_payload_from_raw_returns_none_on_empty_string() { + // Empty string → Ok(None): treated the same as absent. + let result = SetupPayload::from_raw_env_value(Some(String::new())).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn setup_payload_from_raw_returns_err_on_malformed_json() { + // Malformed JSON → Err: no global env mutation, safe to run concurrently. + let result = SetupPayload::from_raw_env_value(Some("not-valid-json{{{".into())); + assert!(result.is_err(), "malformed JSON must return Err"); + } + + #[test] + fn setup_payload_deserializes_correctly() { + let json = r#"{ + "agent_name": "Fizz", + "agent_pubkey": "aabbccddeeff0011", + "requirements": [ + {"surface": "normalized_field", "field": "provider"}, + {"surface": "env_key", "key": "ANTHROPIC_API_KEY"} + ] + }"#; + let payload: SetupPayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.agent_name, "Fizz"); + assert_eq!(payload.requirements.len(), 2); + } + + #[test] + fn nudge_body_names_all_requirements() { + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "provider".to_string(), + }, + RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + assert!( + body.contains("provider"), + "nudge body should mention the missing provider field" + ); + assert!( + body.contains("ANTHROPIC_API_KEY"), + "nudge body should mention the missing env key" + ); + assert!(body.contains("Fizz"), "nudge body should name the agent"); + } + + #[test] + fn nudge_body_codex_copy_does_not_mention_openai_api_key() { + let payload = SetupPayload { + agent_name: "Codex".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login --with-api-key`".to_string(), + }], + }; + let body = payload.nudge_body(); + assert!( + !body.contains("OPENAI_API_KEY"), + "codex nudge must not mention OPENAI_API_KEY; got: {body:?}" + ); + assert!( + body.contains("codex login"), + "codex nudge must mention `codex login`; got: {body:?}" + ); + } + + #[test] + fn nudge_body_empty_requirements_falls_back_to_generic() { + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![], + }; + let body = payload.nudge_body(); + assert!(body.contains("Fizz")); + assert!(body.contains("needs configuration")); + } + + // ── sentinel block tests ─────────────────────────────────────────────────── + + #[test] + fn nudge_body_contains_sentinel_block() { + // The body must end with a ```buzz:config-nudge fence so the desktop + // can detect and strip it before rendering the ConfigNudgeCard. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }], + }; + let body = payload.nudge_body(); + assert!( + body.contains("```buzz:config-nudge\n"), + "body must open the sentinel fence; got: {body:?}" + ); + assert!( + body.ends_with("```"), + "body must close the sentinel fence; got: {body:?}" + ); + } + + #[test] + fn nudge_body_sentinel_round_trips_payload() { + // The JSON inside the sentinel block must deserialize back to an + // equivalent SetupPayload (same agent_name and requirements). + let payload = SetupPayload { + agent_name: "Atlas".to_string(), + agent_pubkey: "ddeeff00".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "model".to_string(), + }, + RequirementPayload::EnvKey { + key: "OPENAI_API_KEY".to_string(), + }, + RequirementPayload::CliLogin { + probe_args: vec!["codex".to_string(), "login".to_string()], + setup_copy: "run `codex login`".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + + // Extract the JSON between the fence markers. + let fence_open = "```buzz:config-nudge\n"; + let fence_close = "\n```"; + let start = body + .rfind(fence_open) + .expect("sentinel open fence not found") + + fence_open.len(); + let end = body[start..] + .rfind(fence_close) + .expect("sentinel close fence not found") + + start; + let json = &body[start..end]; + + let recovered: SetupPayload = + serde_json::from_str(json).expect("sentinel JSON must deserialize"); + assert_eq!(recovered.agent_name, payload.agent_name); + assert_eq!(recovered.agent_pubkey, payload.agent_pubkey); + assert_eq!(recovered.requirements.len(), payload.requirements.len()); + } + + #[test] + fn nudge_body_prose_still_present_with_sentinel() { + // Existing prose checks must pass — the sentinel is APPENDED, not a + // replacement, so all prior `body.contains(...)` invariants hold. + let payload = SetupPayload { + agent_name: "Fizz".to_string(), + agent_pubkey: "test".to_string(), + requirements: vec![ + RequirementPayload::NormalizedField { + field: "provider".to_string(), + }, + RequirementPayload::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }, + ], + }; + let body = payload.nudge_body(); + assert!(body.contains("provider"), "prose must name the field"); + assert!( + body.contains("ANTHROPIC_API_KEY"), + "prose must name the key" + ); + assert!(body.contains("Fizz"), "prose must name the agent"); + // Sentinel is also present. + assert!( + body.contains("```buzz:config-nudge"), + "sentinel must follow" + ); + } + + // ── should_nudge_for_event gate tests ───────────────────────────────────── + // + // These tests exercise the loop-wiring for the two safety-critical guards: + // (a) non-allowlisted author → no nudge, (b) same event-id → exactly one + // nudge. They use the extracted `should_nudge_for_event` helper, which is + // the exact code the live loop calls. + + fn fake_event_id(byte: u8) -> EventId { + EventId::from_byte_array([byte; 32]) + } + + #[test] + fn test_non_allowlisted_author_returns_no_nudge() { + // author_allowed = false → should return false regardless of other args. + let mut dedup: HashSet = HashSet::new(); + let event_id = fake_event_id(0xAA); + + let result = should_nudge_for_event( + event_id, false, // author NOT allowed + true, // filter matched — would otherwise nudge + &mut dedup, + ); + + assert!(!result, "non-allowlisted author must not produce a nudge"); + // Dedup set must remain empty — no phantom insertion for blocked author. + assert!( + dedup.is_empty(), + "dedup set must not record event for blocked author" + ); + } + + #[test] + fn test_same_event_id_twice_nudges_exactly_once() { + // The first call with a given event-id should return true; the second + // call with the identical id must return false (replay dedup). + let mut dedup: HashSet = HashSet::new(); + let event_id = fake_event_id(0xBB); + + let first = should_nudge_for_event( + event_id, true, // allowed + true, // matched + &mut dedup, + ); + assert!(first, "first occurrence must be accepted"); + + // Simulate reconnect replay: same event arrives again. + let second = should_nudge_for_event( + event_id, true, // allowed + true, // matched + &mut dedup, + ); + assert!( + !second, + "replay of the same event-id must be rejected (dedup)" + ); + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3b5f35d99..c32779bc5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/agent-readiness-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 0b809c24f..93ae68ffa 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -83,7 +83,18 @@ const overrides = new Map([ // PGID resolution helper + PID-recycling safety guard added for orphan sweep. // activity-feed threads avatar_url into build_managed_agent_summary for the // assistant-bubble pinned snapshot. - ["src-tauri/src/managed_agents/runtime.rs", 2150], + // +1 for agent_pubkey field in setup payload (config-nudge card wire). + ["src-tauri/src/managed_agents/runtime.rs", 2208], + // config-bridge setup-payload env-boundary fix adds readiness wiring in + // spawn_agent_child; load-bearing security fix, queued to split. + ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], + // config-bridge-aware requirements: goose_requirements + injection tests + // (4 new tests in goose_file_config_tests module) + test-determinism fixes + // for the 3 existing goose tests that previously read real disk config. + // New file in this PR; queued to split. + // +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix. + // +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase). + ["src-tauri/src/managed_agents/readiness.rs", 1150], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -94,7 +105,16 @@ const overrides = new Map([ // #1418 read-path fix: +3 doc-only lines correcting the getThreadReplies // contract (replies-only, root excluded — the query keys on root_event_id, // which root rows lack). Documentation accuracy, not code growth. - ["src/shared/api/tauri.ts", 1340], + // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. + // +26 lines from PRs landing on main between #1411 base and this rebase. + ["src/shared/api/tauri.ts", 1366], + // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + + // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog + // shows required markers and credential amber rows (parity with + // CreateAgentDialog). +23 lines of gate wiring. Queued to split. + // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds + // ~16 lines. Queued to split. + ["src/features/agents/ui/PersonaDialog.tsx", 1032], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); @@ -125,6 +145,8 @@ const overrides = new Map([ // +135 for AgentInfoFocusedView/DiagnosticsFocusedView/ChannelsFocusedView // props restored after 826d735fe removal (UserProfilePanel.tsx still needs them). ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], + // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). + ["src/features/profile/ui/UserProfilePanel.tsx", 1014], // PersistBackend enum + marker-on-keyring-success plumbing and its three // fail-closed regression tests (silent identity rotation on keyring outage). // A small overage from load-bearing security plumbing on a file already at @@ -143,7 +165,8 @@ const overrides = new Map([ ["src/features/channels/readState/readStateManager.ts", 1030], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. - ["src/shared/ui/markdown.tsx", 2119], + // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). + ["src/shared/ui/markdown.tsx", 2152], ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // permission-outcome (fix #1381 regression): pendingPermissions state map, diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 1667b9412..59547764d 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -1,9 +1,11 @@ +use serde::Serialize; use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ config_bridge::{ + read_goose_file_config, reader::read_config_surface, types::{ AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, @@ -16,6 +18,24 @@ use crate::{ }, }; +/// Subset of the goose file config exposed to the frontend for gate evaluation. +/// +/// Only the fields the dialog gate needs — not the full `RuntimeConfigSurface`. +/// The gate uses this to know which requirements are already satisfied in the +/// harness config file, so it can show "Set in goose config" rather than +/// surfacing a false missing-key marker. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeFileConfigSubset { + /// Provider set in the harness config file, if any. + pub provider: Option, + /// Model set in the harness config file, if any. + pub model: Option, + /// Flat credential env keys found in the harness config file's `extra` map + /// (e.g. `DATABRICKS_HOST`). Only non-empty values are included. + pub satisfied_env_keys: Vec, +} + /// Resolve the config surface with persona values applied. /// /// The pipeline: resolve the linked persona's prompt/model/provider, inject @@ -136,6 +156,34 @@ fn retag_persona_default(field: &mut Option) { } } +/// Get the file-layer config for a runtime — used by the Create/Edit/Persona +/// dialogs to know which requirements are already satisfied in the harness +/// config file (e.g. `~/.config/goose/config.yaml`), so they can show +/// "Set in goose config" instead of surfacing a false required-field marker. +/// +/// Returns `null` when the runtime has no config file or it cannot be parsed. +/// Currently only "goose" is supported; other runtimes return `null`. +#[tauri::command] +pub fn get_runtime_file_config(runtime_id: String) -> Option { + match runtime_id.as_str() { + "goose" => { + let cfg = read_goose_file_config()?; + let satisfied_env_keys = cfg + .extra + .into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, _)| k) + .collect(); + Some(RuntimeFileConfigSubset { + provider: cfg.provider, + model: cfg.model, + satisfied_env_keys, + }) + } + _ => None, + } +} + /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index df890931f..c7c21e6a8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -795,7 +795,14 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .map(str::to_string) }), - provider: snapshot_provider, + provider: snapshot_provider.or_else(|| { + input + .provider + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }), persona_source_version: snapshot_source_version, mcp_toolsets: input .mcp_toolsets diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 18c987c48..c43bcd146 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -525,6 +525,7 @@ pub fn run() { get_agent_models, discover_agent_models, get_agent_config_surface, + get_runtime_file_config, put_agent_session_config, mesh_availability, mesh_start_node, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index 4f835d177..914acf3f5 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -53,15 +53,21 @@ fn parse_goose_config(yaml_str: &str) -> Option { if let Some(ref ap) = active_provider { extra.insert("active_provider".to_string(), ap.clone()); } - if let Some(host) = yaml_string(&map, "DATABRICKS_HOST") - .or_else(|| nested.as_ref().and_then(|n| n.host.clone())) - { + // Flat DATABRICKS_HOST key — always canonicalize to the literal env-key name + // so `file_key_present("DATABRICKS_HOST")` works regardless of active_provider. + // This is Will's typical config: flat key, no explicit active_provider. + if let Some(flat_host) = yaml_string(&map, "DATABRICKS_HOST") { + extra.insert("DATABRICKS_HOST".to_string(), flat_host); + } else if let Some(nested_host) = nested.as_ref().and_then(|n| n.host.clone()) { + // Nested providers..host — canonicalize to DATABRICKS_HOST when + // the active provider is a databricks variant; otherwise store verbatim + // (future provider types may have a different canonical key). let host_key = match active_provider.as_deref() { Some("databricks_v2") | Some("databricks") => "DATABRICKS_HOST".to_string(), Some(p) => format!("{p}.host"), None => "provider.host".to_string(), }; - extra.insert(host_key, host); + extra.insert(host_key, nested_host); } Some(RuntimeFileConfig { @@ -267,6 +273,31 @@ GOOSE_TELEMETRY_ENABLED: false "#; let cfg = parse_goose_config(yaml).unwrap(); assert_eq!(cfg.provider.as_deref(), Some("databricks")); + // The flat DATABRICKS_HOST key must be canonical in extra so that + // `file_key_present("DATABRICKS_HOST")` returns true. This is Will's + // typical config — flat key, no active_provider. + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://block-lakehouse-production.cloud.databricks.com/"), + "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' in extra" + ); + } + + #[test] + fn goose_provider_databricks_flat_host_no_active_provider() { + // GOOSE_PROVIDER=databricks + flat DATABRICKS_HOST, no active_provider. + // extra["DATABRICKS_HOST"] must be canonical. + let yaml = r#" +GOOSE_PROVIDER: databricks +DATABRICKS_HOST: https://dbc.example.com +"#; + let cfg = parse_goose_config(yaml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("databricks")); + assert_eq!( + cfg.extra.get("DATABRICKS_HOST").map(|s| s.as_str()), + Some("https://dbc.example.com"), + "flat DATABRICKS_HOST must be stored as 'DATABRICKS_HOST' even with GOOSE_PROVIDER set" + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index 654ae4857..f8b045fc7 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -7,3 +7,12 @@ mod schema_walker; pub(crate) mod types; pub(crate) use types::*; + +/// Read the goose harness config file (`~/.config/goose/config.yaml`). +/// +/// Used by readiness evaluation to silence requirements that are already +/// satisfied in the file config layer — the harness reads this file at startup +/// so env vars we would otherwise require are not needed from Buzz. +pub(crate) fn read_goose_file_config() -> Option { + goose::read_config_file() +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 1c23bc6e3..778f32dab 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -360,7 +360,11 @@ fn build_provider_field( (record_provider.as_deref(), ConfigOrigin::BuzzExplicit), (file_provider.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + let (value, origin, overridden_value, overridden_origin) = match resolve_with_override(tiers) { + Some(resolved) => resolved, + None if is_required => (None, ConfigOrigin::EnvVar, None, None), + None => return None, + }; let write_via = if let Some(env_key) = provider_env_var { ConfigWriteMechanism::RespawnWithEnvVar { diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index b35623d6a..cfb7f29c1 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -659,3 +659,18 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { "thinking_effort must not appear in advanced when normalized" ); } + +#[test] +fn missing_required_provider_still_returns_dropdown_field() { + let provider = build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, true) + .expect("required provider field should be surfaced even when empty"); + + assert_eq!(provider.value, None); + assert_eq!(provider.origin, ConfigOrigin::EnvVar); + assert!(provider.is_required); +} + +#[test] +fn missing_optional_provider_stays_hidden() { + assert!(build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, false).is_none()); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 4383086c1..57ed8739b 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -94,6 +94,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", ]; pub(crate) fn is_reserved_env_key(key: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 68ab8f5a6..06d8a8bc7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod persona_events; mod personas; #[cfg(windows)] mod process_lifecycle; +pub(crate) mod readiness; #[cfg(feature = "mesh-llm")] mod relay_mesh; mod repos; @@ -32,6 +33,9 @@ pub use persona_card::*; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; +pub(crate) use readiness::{ + agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, +}; #[cfg(feature = "mesh-llm")] pub use relay_mesh::*; pub use repos::{ diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs new file mode 100644 index 000000000..5b60914b2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -0,0 +1,1149 @@ +//! Agent readiness evaluation. +//! +//! # Overview +//! +//! Before spawning a managed agent (or before deciding whether to enter +//! setup-mode nudge), the desktop must know whether the agent has every +//! piece of configuration it will need to start successfully. This module +//! provides: +//! +//! * [`EffectiveAgentEnv`] — the resolved environment a spawn would actually +//! see: baked build defaults (floor) → runtime metadata env vars → merged +//! user env_vars (last-wins) → reserved-key filtered. A separate +//! `config_file` tier tracks fields the harness reads from its config file +//! rather than the process env. +//! * [`resolve_effective_agent_env`] — assembles an `EffectiveAgentEnv` from +//! a record + personas + runtime catalog; no `AppHandle` dependency so it +//! is fully unit-testable. +//! * [`Requirement`] / [`RequirementSurface`] — structured predicates that +//! carry enough surface-discrimination for the UI to route each gap to the +//! right affordance (dropdown field vs env-var row vs CLI login step). +//! * [`AgentReadiness`] / [`agent_readiness`] — evaluates the effective env +//! against the requirements for the resolved runtime and returns `Ready` or +//! `NotReady(Vec)`. +//! +//! ## Env-assembly precedence (mirrors `spawn_agent_child`) +//! +//! 1. Baked build defaults (`baked_build_env()`) — injected first so the +//! layers above can override them. +//! 2. Runtime metadata env vars (`runtime_metadata_env_vars`) — provider / +//! model env keys derived from the record's `model`/`provider` fields and +//! the runtime's `model_env_var`/`provider_env_var`. +//! 3. Merged user env (`merged_user_env`) — the record's `env_vars` after +//! reserved-key and malformed-key filtering. Last-wins on collision. +//! +//! The config-file tier (Goose `~/.config/goose/config.yaml`) is tracked +//! separately because it is not part of the process env — the harness reads +//! it at startup. We do not evaluate it here; it is exposed for future +//! UI display only. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::managed_agents::{ + agent_env::baked_build_env, + config_bridge::read_goose_file_config, + discovery::{known_acp_runtime, KnownAcpRuntime}, + effective_agent_command, + env_vars::merged_user_env, + types::{ManagedAgentRecord, PersonaRecord}, +}; + +// ── EffectiveAgentEnv ───────────────────────────────────────────────────────── + +/// The resolved environment that a spawn of `record` would actually receive. +/// +/// Assembled from: baked build defaults (floor) → runtime metadata env vars +/// → merged user env_vars (last-wins) → reserved-key filtered. +/// +/// `config_file_path` is the harness config file path (if any) — not part of +/// the process env but relevant for display and future write-back dispatch. +/// `effective_command` is the resolved harness binary name (e.g. `"buzz-agent"`, +/// `"goose"`) after persona and override resolution. +#[derive(Debug, Clone)] +pub(crate) struct EffectiveAgentEnv { + /// The process-env map the spawned harness would receive. + pub env: BTreeMap, + /// Harness config file path, if any (e.g. `~/.config/goose/config.yaml`). + pub config_file_path: Option<&'static str>, + /// The resolved harness binary name (e.g. `"buzz-agent"`, `"goose"`). + pub effective_command: String, +} + +/// Assemble the effective agent env from a record, personas, and optional +/// known-runtime metadata — without an `AppHandle` so it is unit-testable. +/// +/// # Arguments +/// * `record` — the managed agent record (model/provider/env_vars/…) +/// * `personas` — all current persona records (for persona-backed resolution) +/// * `runtime` — the `KnownAcpRuntime` for the effective command, if any +pub(crate) fn resolve_effective_agent_env( + record: &ManagedAgentRecord, + personas: &[PersonaRecord], + runtime: Option<&KnownAcpRuntime>, +) -> EffectiveAgentEnv { + let effective_command = effective_agent_command( + record.persona_id.as_deref(), + personas, + record.agent_command_override.as_deref(), + ); + + // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). + let mut env = baked_build_env(); + + // Layer 2: runtime metadata env vars (model / provider keys derived from + // the record's structured fields). + let effective_model = record.model.as_deref(); + let effective_provider = record.provider.as_deref(); + if let Some(rt) = runtime { + for (key, value) in super::runtime::runtime_metadata_env_vars( + rt.model_env_var, + rt.provider_env_var, + rt.provider_locked, + effective_model, + effective_provider, + ) { + env.insert(key.to_string(), value.to_string()); + } + } + + // Layer 3: merged user env (agent env_vars after reserved/malformed-key + // filtering; last-wins on collision). + let user_env = merged_user_env(&BTreeMap::new(), &record.env_vars); + env.extend(user_env); + + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command, + } +} + +// ── Requirement types ───────────────────────────────────────────────────────── + +/// A single missing piece of configuration, tagged with the UI surface that +/// owns it so the UI can route each gap to the right affordance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "surface", rename_all = "snake_case")] +pub enum Requirement { + /// A normalized dropdown field (provider or model) that is missing. + /// Routes to the provider/model dropdown in the Edit Agent dialog. + NormalizedField { + /// Camel-case field name matching `NormalizedConfig` ("provider", "model"). + field: String, + }, + /// An env-backed credential that is absent from the effective env. + /// Routes to the env-var row editor in the Edit Agent dialog. + EnvKey { + /// The env var key name (e.g. `"ANTHROPIC_API_KEY"`). + key: String, + }, + /// A CLI authentication step that must be completed interactively. + /// Routes to a setup instruction panel in the Edit Agent dialog. + CliLogin { + /// Arguments for the login-status probe (e.g. `["claude", "auth", "status"]`). + probe_args: Vec, + /// Human-readable instruction for completing the login + /// (e.g. `"run \`codex login --with-api-key\`"`). + setup_copy: String, + }, +} + +impl Requirement { + /// Short label for logging/nudge copy. + pub(crate) fn label(&self) -> String { + match self { + Requirement::NormalizedField { field } => format!("missing {field}"), + Requirement::EnvKey { key } => format!("missing env {key}"), + Requirement::CliLogin { setup_copy, .. } => setup_copy.clone(), + } + } +} + +// ── AgentReadiness ──────────────────────────────────────────────────────────── + +/// Whether a managed agent has all required configuration to start. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AgentReadiness { + /// All required configuration is present — safe to spawn normally. + Ready, + /// One or more requirements are missing. + NotReady { + /// Surface-discriminated list of what is missing. + requirements: Vec, + }, +} + +impl AgentReadiness { + /// Returns `true` if the agent is ready to spawn. + pub(crate) fn is_ready(&self) -> bool { + matches!(self, AgentReadiness::Ready) + } + + /// Returns the missing requirements, or an empty slice if ready. + pub(crate) fn requirements(&self) -> &[Requirement] { + match self { + AgentReadiness::Ready => &[], + AgentReadiness::NotReady { requirements } => requirements, + } + } +} + +// ── agent_readiness ─────────────────────────────────────────────────────────── + +/// Evaluate whether a managed agent has all required configuration to start. +/// +/// Checks the `effective` env surface against the requirements for the +/// resolved runtime: +/// +/// * **buzz-agent / goose**: provider + model are required (both must be +/// present in the effective env or as structured fields). Additionally, +/// provider-specific credentials are required: +/// - `anthropic` → `ANTHROPIC_API_KEY` +/// - `openai` → `OPENAI_COMPAT_API_KEY` +/// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — +/// OAuth PKCE is the fallback) +/// * **claude**: a successful `claude auth status` probe. +/// * **codex**: a successful `codex login status` probe (checks the codex +/// credential store — NOT `OPENAI_API_KEY`). +/// * **unknown / custom command**: always `Ready` (no requirements known). +/// +/// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in +/// `buzz-agent/src/config.rs:143` — it is an escape hatch for static tokens +/// but the normal path is OAuth PKCE. We intentionally do NOT mark the +/// token as required to avoid a false NotReady for users on OAuth. +pub(crate) fn agent_readiness(effective: &EffectiveAgentEnv) -> AgentReadiness { + let runtime = known_acp_runtime(&effective.effective_command); + let missing = collect_missing_requirements(effective, runtime); + if missing.is_empty() { + AgentReadiness::Ready + } else { + AgentReadiness::NotReady { + requirements: missing, + } + } +} + +/// Collect all missing requirements for the given effective env + runtime. +fn collect_missing_requirements( + effective: &EffectiveAgentEnv, + runtime: Option<&KnownAcpRuntime>, +) -> Vec { + let Some(rt) = runtime else { + // Unknown/custom command — no requirements to check. + return vec![]; + }; + + match rt.id { + "buzz-agent" => buzz_agent_requirements(effective), + "goose" => { + // Read the file config once at the call site so the inner fn is + // pure and unit-testable by injection. + let file_cfg = read_goose_file_config(); + goose_requirements(effective, file_cfg.as_ref()) + } + "claude" => cli_login_requirements( + &["claude", "auth", "status"], + "complete Claude Code authentication by running the Claude CLI", + ), + "codex" => cli_login_requirements( + &["codex", "login", "status"], + "run `codex login --with-api-key`", + ), + _ => vec![], + } +} + +/// Requirements for buzz-agent (provider + model + provider-specific creds). +fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { + let mut missing = Vec::new(); + + // Provider is required — maps to BUZZ_AGENT_PROVIDER in the effective env. + // An empty string is treated as absent: a key set to "" is not a valid + // provider and must not pass the readiness gate. + let provider = effective + .env + .get("BUZZ_AGENT_PROVIDER") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if provider.is_none() { + missing.push(Requirement::NormalizedField { + field: "provider".to_string(), + }); + } + + // Model is required — maps to BUZZ_AGENT_MODEL in the effective env. + // Same empty-string treatment as provider. + let model = effective + .env + .get("BUZZ_AGENT_MODEL") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if model.is_none() { + missing.push(Requirement::NormalizedField { + field: "model".to_string(), + }); + } + + // Provider-specific credential requirements. + // A key present with an empty value is treated as absent — matching the + // dialog's (envVars[key] ?? "").length === 0 emptiness check. + let env_key_missing = |key: &str| effective.env.get(key).map_or(true, |v| v.is_empty()); + match provider { + Some("anthropic") => { + if env_key_missing("ANTHROPIC_API_KEY") { + missing.push(Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }); + } + } + Some("openai") => { + if env_key_missing("OPENAI_COMPAT_API_KEY") { + missing.push(Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string(), + }); + } + } + Some("databricks") | Some("databricks_v2") => { + // DATABRICKS_HOST is hard-required; DATABRICKS_TOKEN is optional + // (OAuth PKCE is the normal path — see buzz-agent/src/config.rs:143). + if env_key_missing("DATABRICKS_HOST") { + missing.push(Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string(), + }); + } + } + _ => { + // Unknown provider or no provider yet — only the NormalizedField + // requirement above captures this gap. + } + } + + missing +} + +/// Requirements for goose (provider + model + provider-specific creds). +/// +/// Mirrors buzz-agent requirements but uses GOOSE_PROVIDER / GOOSE_MODEL. +/// +/// File-config tier: goose reads `~/.config/goose/config.yaml` at startup. +/// Requirements already satisfied there are silenced — we don't need to +/// require them from Buzz's env layer. The file layer only *silences* +/// requirements; it never injects values into the spawn env. +/// +/// `file_cfg` is injected by the caller (read once at `collect_missing_requirements`) +/// so this function is pure and unit-testable without touching disk. +fn goose_requirements( + effective: &EffectiveAgentEnv, + file_cfg: Option<&crate::managed_agents::config_bridge::RuntimeFileConfig>, +) -> Vec { + let mut missing = Vec::new(); + + // Empty string treated as absent — same as buzz_agent_requirements. + let provider = effective + .env + .get("GOOSE_PROVIDER") + .filter(|v| !v.is_empty()) + .map(String::as_str); + + // Effective provider for credential checking: prefer env layer, then file. + let effective_provider = provider.or_else(|| { + file_cfg + .as_ref() + .and_then(|c| c.provider.as_deref()) + .filter(|v| !v.is_empty()) + }); + + if provider.is_none() { + // Silenced if the file config provides a provider. + let file_provides_provider = file_cfg + .as_ref() + .and_then(|c| c.provider.as_deref()) + .filter(|v| !v.is_empty()) + .is_some(); + if !file_provides_provider { + missing.push(Requirement::NormalizedField { + field: "provider".to_string(), + }); + } + } + + let model = effective + .env + .get("GOOSE_MODEL") + .filter(|v| !v.is_empty()) + .map(String::as_str); + if model.is_none() { + // Silenced if the file config provides a model. + let file_provides_model = file_cfg + .as_ref() + .and_then(|c| c.model.as_deref()) + .filter(|v| !v.is_empty()) + .is_some(); + if !file_provides_model { + missing.push(Requirement::NormalizedField { + field: "model".to_string(), + }); + } + } + + // Provider-specific credentials — same empty-string semantics as buzz-agent. + let env_key_missing = |key: &str| effective.env.get(key).map_or(true, |v| v.is_empty()); + // A credential key is also satisfied when the file config's `extra` map + // contains it (e.g. DATABRICKS_HOST set in the goose config file). + let file_key_present = |key: &str| -> bool { + file_cfg + .as_ref() + .map(|c| c.extra.get(key).map_or(false, |v| !v.is_empty())) + .unwrap_or(false) + }; + match effective_provider { + Some("anthropic") => { + if env_key_missing("ANTHROPIC_API_KEY") && !file_key_present("ANTHROPIC_API_KEY") { + missing.push(Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }); + } + } + Some("openai") => { + if env_key_missing("OPENAI_COMPAT_API_KEY") + && !file_key_present("OPENAI_COMPAT_API_KEY") + { + missing.push(Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string(), + }); + } + } + Some("databricks") | Some("databricks_v2") => { + if env_key_missing("DATABRICKS_HOST") && !file_key_present("DATABRICKS_HOST") { + missing.push(Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string(), + }); + } + } + _ => {} + } + + missing +} + +/// Requirements for CLI-login runtimes (claude, codex). +/// +/// We probe the CLI's login-status command synchronously. These probes are +/// fast (<300ms) and the results are memoized by the caller for the session +/// lifetime if desired. +fn cli_login_requirements(probe_args: &[&str], setup_copy: &str) -> Vec { + // Run the probe. A zero exit code means logged in; anything else means not. + let logged_in = std::process::Command::new(probe_args[0]) + .args(&probe_args[1..]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if logged_in { + vec![] + } else { + vec![Requirement::CliLogin { + probe_args: probe_args.iter().map(|s| s.to_string()).collect(), + setup_copy: setup_copy.to_string(), + }] + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::discovery::known_acp_runtime_exact; + + /// Build a minimal `EffectiveAgentEnv` with the given env map and command. + fn make_env(command: &str, env: BTreeMap) -> EffectiveAgentEnv { + let runtime = known_acp_runtime_exact(command); + EffectiveAgentEnv { + env, + config_file_path: runtime.and_then(|r| r.config_file_path), + effective_command: command.to_string(), + } + } + + fn env_with(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + // ── buzz-agent tests ────────────────────────────────────────────────── + + #[test] + fn buzz_agent_missing_provider_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[("BUZZ_AGENT_MODEL", "claude-opus-4-5")]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "missing BUZZ_AGENT_PROVIDER should be NotReady" + ); + let reqs = result.requirements(); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements should include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn buzz_agent_missing_model_returns_not_ready_with_normalized_field() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } + + #[test] + fn buzz_agent_missing_anthropic_key_returns_not_ready_with_env_key() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_missing_openai_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openai"), + ("BUZZ_AGENT_MODEL", "gpt-4o"), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENAI_COMPAT_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_anthropic_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + #[test] + fn buzz_agent_databricks_with_host_and_model_is_ready_without_token() { + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + // No token present, no OAuth cache present → still Ready because we + // cannot evaluate OAuth state from the env map alone. + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", "https://dbc.example.com"), + // NOTE: no DATABRICKS_TOKEN + ]), + ); + assert!( + agent_readiness(&env).is_ready(), + "Databricks with HOST+model but no TOKEN should still be Ready (OAuth path)" + ); + } + + #[test] + fn buzz_agent_databricks_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + // NOTE: no DATABRICKS_HOST + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + #[test] + fn buzz_agent_databricks_v2_missing_host_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ( + "BUZZ_AGENT_MODEL", + "databricks/meta-llama-4-maverick-17b-instruct", + ), + ]), + ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + // ── goose tests ─────────────────────────────────────────────────────── + + #[test] + fn goose_missing_provider_returns_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env("goose", env_with(&[("GOOSE_MODEL", "claude-opus-4-5")])); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "missing GOOSE_PROVIDER with no file config must produce requirements" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn goose_with_provider_and_model_and_key_is_ready() { + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + assert!(agent_readiness(&env).is_ready()); + } + + // ── empty-string semantics ──────────────────────────────────────────── + // + // A key present with an empty value ("") must be treated as MISSING, to + // match the dialog's (envVars[key] ?? "").length === 0 emptiness check. + + #[test] + fn buzz_agent_empty_string_provider_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", ""), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_PROVIDER must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "provider".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_model_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", ""), + ("ANTHROPIC_API_KEY", "sk-test"), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string BUZZ_AGENT_MODEL must be treated as missing" + ); + assert!(result + .requirements() + .contains(&Requirement::NormalizedField { + field: "model".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_anthropic_key_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "anthropic"), + ("BUZZ_AGENT_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string ANTHROPIC_API_KEY must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + })); + } + + #[test] + fn buzz_agent_empty_string_databricks_host_is_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "databricks"), + ("BUZZ_AGENT_MODEL", "dbrx-instruct"), + ("DATABRICKS_HOST", ""), + ]), + ); + let result = agent_readiness(&env); + assert!( + !result.is_ready(), + "empty-string DATABRICKS_HOST must be treated as missing" + ); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + })); + } + + #[test] + fn goose_empty_string_provider_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[("GOOSE_PROVIDER", ""), ("GOOSE_MODEL", "claude-opus-4-5")]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string GOOSE_PROVIDER must be treated as missing" + ); + assert!( + reqs.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "requirements must include NormalizedField(provider); got {reqs:?}" + ); + } + + #[test] + fn goose_empty_string_anthropic_key_is_not_ready() { + // Call goose_requirements directly with None file config so the test is + // deterministic — the `agent_readiness` path reads the real + // ~/.config/goose/config.yaml which may silence requirements on + // developer machines. + let env = make_env( + "goose", + env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ("ANTHROPIC_API_KEY", ""), + ]), + ); + let reqs = goose_requirements(&env, None); + assert!( + !reqs.is_empty(), + "empty-string ANTHROPIC_API_KEY must be treated as missing (goose)" + ); + assert!( + reqs.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "requirements must include ANTHROPIC_API_KEY; got {reqs:?}" + ); + } + + // ── codex tests ─────────────────────────────────────────────────────── + + #[test] + fn codex_not_ready_copy_does_not_mention_openai_api_key() { + // codex uses its own credential store via `codex login --with-api-key`. + // The nudge copy must NOT say "set OPENAI_API_KEY". + // We test the static not-logged-in path by constructing the requirement + // directly (the binary may or may not be installed in CI). + let reqs = cli_login_requirements( + &["codex", "login", "status"], + "run `codex login --with-api-key`", + ); + // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. + for req in &reqs { + if let Requirement::CliLogin { setup_copy, .. } = req { + assert!( + !setup_copy.contains("OPENAI_API_KEY"), + "codex nudge copy must not mention OPENAI_API_KEY; got: {setup_copy:?}" + ); + assert!( + setup_copy.contains("codex login"), + "codex nudge copy should mention `codex login`; got: {setup_copy:?}" + ); + } + } + } + + // ── custom/unknown command ───────────────────────────────────────────── + + #[test] + fn unknown_command_is_always_ready() { + let env = make_env("my-custom-harness", BTreeMap::new()); + assert!( + agent_readiness(&env).is_ready(), + "unknown/custom command should always be Ready (no requirements)" + ); + } + + // ── AgentReadiness helpers ───────────────────────────────────────────── + + #[test] + fn agent_readiness_ready_has_empty_requirements() { + assert!(AgentReadiness::Ready.requirements().is_empty()); + } + + #[test] + fn agent_readiness_not_ready_exposes_requirements() { + let r = AgentReadiness::NotReady { + requirements: vec![Requirement::EnvKey { + key: "FOO".to_string(), + }], + }; + assert!(!r.is_ready()); + assert_eq!(r.requirements().len(), 1); + } + + // ── Requirement serialization ───────────────────────────────────────── + + #[test] + fn requirement_serializes_with_surface_tag() { + let r = Requirement::NormalizedField { + field: "provider".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "normalized_field"); + assert_eq!(json["field"], "provider"); + } + + #[test] + fn env_key_requirement_serializes_correctly() { + let r = Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "env_key"); + assert_eq!(json["key"], "ANTHROPIC_API_KEY"); + } + + #[test] + fn cli_login_requirement_serializes_correctly() { + let r = Requirement::CliLogin { + probe_args: vec![ + "codex".to_string(), + "login".to_string(), + "status".to_string(), + ], + setup_copy: "run `codex login --with-api-key`".to_string(), + }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["surface"], "cli_login"); + assert!(json["probe_args"].is_array()); + assert!(json["setup_copy"].as_str().unwrap().contains("codex login")); + } + + // ── resolve_effective_agent_env ───────────────────────────────────────── + + #[test] + fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { + // A record whose env_vars explicitly set provider/model must win over + // any baked defaults. In OSS test builds the baked map is empty, so + // this test validates the user-env layer is present in the output. + let mut env_vars = BTreeMap::new(); + env_vars.insert("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()); + env_vars.insert( + "BUZZ_AGENT_MODEL".to_string(), + "claude-opus-4-5".to_string(), + ); + + // Minimal record: only the fields resolve_effective_agent_env reads. + let record = crate::managed_agents::types::ManagedAgentRecord { + pubkey: "test-pubkey".to_string(), + name: "test-agent".to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "buzz-agent".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + mcp_toolsets: None, + env_vars, + start_on_app_launch: false, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + relay_mesh: None, + }; + + let runtime = known_acp_runtime_exact("buzz-agent"); + let effective = resolve_effective_agent_env(&record, &[], runtime); + + // User env_vars must be present in the output (last-write-wins). + assert_eq!( + effective.env.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("anthropic") + ); + assert_eq!( + effective.env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("claude-opus-4-5") + ); + } +} + +// ── goose file-config–aware requirement tests ───────────────────────────── +// +// These tests call `goose_requirements` directly, injecting a synthetic +// `RuntimeFileConfig` so there is no disk I/O and tests are deterministic. + +#[cfg(test)] +mod goose_file_config_tests { + use std::collections::BTreeMap; + + use super::*; + use crate::managed_agents::config_bridge::RuntimeFileConfig; + + fn empty_env() -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } + } + + fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } + } + + fn databricks_file_config() -> RuntimeFileConfig { + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + RuntimeFileConfig { + provider: Some("databricks_v2".to_string()), + model: Some("goose-claude-4-6-opus".to_string()), + extra, + ..Default::default() + } + } + + #[test] + fn goose_file_config_silences_databricks_host_requirement() { + // File has provider, model, and DATABRICKS_HOST — all requirements silenced. + let env = empty_env(); + let cfg = databricks_file_config(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + result.is_empty(), + "all requirements should be silenced by goose file config; \ + got: {:?}", + result + ); + } + + #[test] + fn goose_env_empty_file_absent_still_not_ready() { + // No env, no file config → provider and model both required. + let env = empty_env(); + let result = goose_requirements(&env, None); + assert!( + result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider must be required when absent from both env and file" + ); + assert!( + result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model must be required when absent from both env and file" + ); + } + + #[test] + fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() { + // File has provider=anthropic and model, but ANTHROPIC_API_KEY is not + // in the file's `extra` map — it must still be required. + let cfg = RuntimeFileConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // Provider and model silenced. + assert!( + !result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider silenced by file config" + ); + assert!( + !result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model silenced by file config" + ); + // ANTHROPIC_API_KEY not in file extra → still required. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "ANTHROPIC_API_KEY must remain required when not in file extra" + ); + } + + #[test] + fn goose_env_provider_wins_over_file_provider_for_cred_check() { + // Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2). + // The env provider must win for credential checking. + let env = env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ]); + let cfg = databricks_file_config(); // has provider=databricks_v2 + let result = goose_requirements(&env, Some(&cfg)); + // anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "env provider=anthropic must require ANTHROPIC_API_KEY" + ); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "env provider=anthropic must NOT require DATABRICKS_HOST" + ); + } + + #[test] + fn goose_flat_databricks_host_in_file_config_silences_requirement() { + // Will's typical goose config: flat DATABRICKS_HOST at the top level, + // no active_provider — provider inferred as "databricks". + // The parser must store extra["DATABRICKS_HOST"] = value (canonical key), + // and goose_requirements must then silence the DATABRICKS_HOST requirement. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://block.cloud.databricks.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("goose-claude-4-5".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file). + assert!( + result.is_empty(), + "flat DATABRICKS_HOST in file config must silence all requirements; \ + got: {:?}", + result + ); + } + + #[test] + fn goose_goose_provider_databricks_flat_host_silences_databricks_host() { + // GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST. + // The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("some-model".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "DATABRICKS_HOST must be silenced when canonical key is in file extra" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index de38c3b1b..dbf3bc230 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1747,6 +1747,92 @@ pub fn spawn_agent_child( if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } + + // ── Readiness check: set setup-payload if agent is not ready ───────────── + // + // Build the effective env the agent would have at start-time, run the + // readiness predicate, and if anything is missing, serialize the payload + // into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup + // and enters the minimal setup-listener mode instead of the agent pool. + // + // SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env + // cannot set it, but we also explicitly remove it after writing user env + // to guard against the parent-process environment. We then set it only + // when desktop has computed NotReady — the desktop is the sole readiness + // source and buzz-acp only transports the payload. + // + // The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp: + // { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] } + { + use crate::managed_agents::{ + agent_readiness, resolve_effective_agent_env, AgentReadiness, Requirement, + }; + + let effective = resolve_effective_agent_env(record, &personas, runtime_meta); + // Compute the optional payload before touching the command. + let setup_payload_json = + if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) { + let reqs: Vec = requirements + .into_iter() + .map(|r| match r { + Requirement::NormalizedField { field } => serde_json::json!({ + "surface": "normalized_field", + "field": field, + }), + Requirement::EnvKey { key } => serde_json::json!({ + "surface": "env_key", + "key": key, + }), + Requirement::CliLogin { + probe_args, + setup_copy, + } => serde_json::json!({ + "surface": "cli_login", + "probe_args": probe_args, + "setup_copy": setup_copy, + }), + }) + .collect(); + let payload = serde_json::json!({ + "agent_name": record.name, + "agent_pubkey": record.pubkey, + "requirements": reqs, + }); + match serde_json::to_string(&payload) { + Ok(json) => Some(json), + Err(e) => { + eprintln!( + "buzz-desktop: failed to serialize setup payload for {}: {e}", + record.name + ); + None + } + } + } else { + None + }; + + // Strip the key from the process-spawned command on every path. + // Two independent guards protect the invariant: + // 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so + // merged_user_env() can never write it via saved/persona env. + // 2. This env_remove() clears any ambient parent-process value + // inherited by std::process::Command before we conditionally + // set the desktop-computed trusted value below. + // Note: merged_user_env() is written further below in this function; + // ordering relative to that call is NOT what makes this safe — the + // reserved-key strip (guard 1) handles user env regardless of order. + command.env_remove("BUZZ_ACP_SETUP_PAYLOAD"); + + // Set the payload only when desktop computed NotReady. + if let Some(json) = setup_payload_json { + command.env("BUZZ_ACP_SETUP_PAYLOAD", json); + eprintln!( + "buzz-desktop: agent {} not ready — spawning in setup-listener mode", + record.name + ); + } + } // Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an // override. When unset, the buzz-acp harness applies its own default // (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e17a923b2..8439daf2d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -327,6 +327,7 @@ pub struct CreateManagedAgentRequest { pub system_prompt: Option, pub avatar_url: Option, pub model: Option, + pub provider: Option, pub mcp_toolsets: Option, /// Environment variables for this agent. Layered on top of persona env. #[serde(default)] diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 567c5b576..8351d0722 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -15,6 +15,7 @@ import { discoverManagedAgentPrereqs, getAgentConfigSurface, getManagedAgentLog, + getRuntimeFileConfig, installAcpRuntime, listManagedAgents, listRelayAgents, @@ -591,6 +592,32 @@ export function useAgentConfigSurface(pubkey: string | null) { }); } +export const runtimeFileConfigQueryKey = (runtimeId: string) => + ["runtime-file-config", runtimeId] as const; + +/** + * Query the file-layer config for a runtime (e.g. `~/.config/goose/config.yaml`). + * + * Used by Create/Edit/Persona dialogs to know which requirements are already + * satisfied in the harness config file, so they can show "Set in goose config" + * rather than surfacing a false required-field marker. + * + * Enabled only when `runtimeId` is non-empty and the dialog is open. + */ +export function useRuntimeFileConfigQuery( + runtimeId: string, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: runtimeFileConfigQueryKey(runtimeId), + queryFn: () => getRuntimeFileConfig(runtimeId), + enabled: (options?.enabled ?? true) && runtimeId.trim().length > 0, + staleTime: 30_000, + // File config rarely changes mid-session; no aggressive refetch needed. + refetchInterval: false, + }); +} + export function useTeamsQuery() { return useQuery({ queryKey: teamsQueryKey, diff --git a/desktop/src/features/agents/openEditAgentEvent.test.mjs b/desktop/src/features/agents/openEditAgentEvent.test.mjs new file mode 100644 index 000000000..a0c203380 --- /dev/null +++ b/desktop/src/features/agents/openEditAgentEvent.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Provide a minimal window shim for the module's DOM event calls. +// Node 25 has CustomEvent but not window.addEventListener / dispatchEvent. +const _eventTarget = new EventTarget(); +globalThis.window = { + addEventListener: _eventTarget.addEventListener.bind(_eventTarget), + removeEventListener: _eventTarget.removeEventListener.bind(_eventTarget), + dispatchEvent: _eventTarget.dispatchEvent.bind(_eventTarget), +}; + +import { + consumePendingOpenEditAgent, + requestOpenEditAgent, + subscribeOpenEditAgent, +} from "./openEditAgentEvent.ts"; + +// ── consumePendingOpenEditAgent ─────────────────────────────────────────────── + +test("consumePendingOpenEditAgent_noPriorRequest_returnsFalse", () => { + // No request has been made for this pubkey — consume must return false. + assert.equal( + consumePendingOpenEditAgent("aabbccddeeff0011"), + false, + "consume with no pending request must return false", + ); +}); + +test("consumePendingOpenEditAgent_afterRequest_returnsTrue", () => { + const pubkey = "ddeeff00112233aa"; + requestOpenEditAgent(pubkey); + assert.equal( + consumePendingOpenEditAgent(pubkey), + true, + "consume immediately after request must return true", + ); +}); + +test("consumePendingOpenEditAgent_afterRequest_clearsState", () => { + const pubkey = "112233aabbccddee"; + requestOpenEditAgent(pubkey); + consumePendingOpenEditAgent(pubkey); + assert.equal( + consumePendingOpenEditAgent(pubkey), + false, + "second consume must return false — state cleared by first", + ); +}); + +test("consumePendingOpenEditAgent_wrongPubkey_returnsFalse", () => { + const pubkey = "aabbcc001122ddef"; + requestOpenEditAgent(pubkey); + const result = consumePendingOpenEditAgent("ffffffffffffffff"); + consumePendingOpenEditAgent(pubkey); // clean up + assert.equal( + result, + false, + "consume with non-matching pubkey must return false", + ); +}); + +// ── subscribeOpenEditAgent — live subscriber clears pending ─────────────────── + +test("subscribeOpenEditAgent_afterLiveHandle_consumeReturnsFalse", () => { + // Core Fix 2 invariant: after a live subscriber handles the event, + // consumePendingOpenEditAgent must return false (pending cleared). + const pubkey = "66778899aabbccdd"; + let handlerCalled = false; + + const unsubscribe = subscribeOpenEditAgent(pubkey, () => { + handlerCalled = true; + }); + + requestOpenEditAgent(pubkey); // fires synchronously via dispatchEvent + + unsubscribe(); + + assert.equal(handlerCalled, true, "handler must have been called"); + assert.equal( + consumePendingOpenEditAgent(pubkey), + false, + "pending must be cleared by live subscriber — not by consume", + ); +}); + +test("subscribeOpenEditAgent_differentPubkey_doesNotHandle", () => { + const subscribedPubkey = "aabbccdd11223344"; + const requestedPubkey = "ffffffffffffffff00000000"; + let handlerCalled = false; + + const unsubscribe = subscribeOpenEditAgent(subscribedPubkey, () => { + handlerCalled = true; + }); + + requestOpenEditAgent(requestedPubkey); + consumePendingOpenEditAgent(requestedPubkey); // clean up + unsubscribe(); + + assert.equal( + handlerCalled, + false, + "handler must not fire for a different pubkey", + ); +}); diff --git a/desktop/src/features/agents/openEditAgentEvent.ts b/desktop/src/features/agents/openEditAgentEvent.ts new file mode 100644 index 000000000..8220b46d9 --- /dev/null +++ b/desktop/src/features/agents/openEditAgentEvent.ts @@ -0,0 +1,51 @@ +/** + * Global event for requesting that the Edit Agent dialog open for a specific + * agent pubkey. + * + * Pattern mirrors `openCreateAgentEvent.ts`. The card (or any caller outside + * a UserProfilePanel instance) dispatches the event; UserProfilePanel + * subscribes and opens the dialog when its current pubkey matches. + * + * Callers typically also call `openProfilePanel(pubkey)` from ProfilePanel- + * Context to ensure the panel is visible before the event fires. + */ + +const OPEN_EDIT_AGENT_EVENT = "buzz:open-edit-agent"; + +let pendingEditAgentPubkey: string | null = null; + +export function requestOpenEditAgent(pubkey: string) { + pendingEditAgentPubkey = pubkey; + window.dispatchEvent( + new CustomEvent(OPEN_EDIT_AGENT_EVENT, { detail: pubkey }), + ); +} + +export function consumePendingOpenEditAgent(pubkey: string): boolean { + if ( + pendingEditAgentPubkey !== null && + pendingEditAgentPubkey.toLowerCase() === pubkey.toLowerCase() + ) { + pendingEditAgentPubkey = null; + return true; + } + return false; +} + +export function subscribeOpenEditAgent( + pubkey: string, + handler: () => void, +): () => void { + function handleEvent(event: Event) { + const detail = (event as CustomEvent).detail; + if (detail.toLowerCase() === pubkey.toLowerCase()) { + pendingEditAgentPubkey = null; + handler(); + } + } + + window.addEventListener(OPEN_EDIT_AGENT_EVENT, handleEvent); + return () => { + window.removeEventListener(OPEN_EDIT_AGENT_EVENT, handleEvent); + }; +} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index de792e35d..3f31d9715 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -7,6 +7,7 @@ import { useBackendProvidersQuery, useCreateManagedAgentMutation, useManagedAgentPrereqsQuery, + useRuntimeFileConfigQuery, } from "@/features/agents/hooks"; import { probeBackendProvider } from "@/shared/api/tauri"; import type { @@ -39,6 +40,20 @@ import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgent import { meshPrepareRelayMeshClient } from "@/shared/api/tauriMesh"; import type { MeshServeTarget } from "@/shared/api/tauriMesh"; import { useLastRuntime } from "@/features/agents/lib/useLastRuntime"; +import { + requiredCredentialEnvKeys, + runtimeSupportsLlmProviderSelection, + shouldClearKnownModelForSelectionScope, + getProviderApiKeyEnvVar, + CUSTOM_PROVIDER_DROPDOWN_VALUE, + AUTO_PROVIDER_DROPDOWN_VALUE, +} from "./personaDialogPickers"; +import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; +import { + AgentModelField, + AgentProviderField, +} from "./personaProviderModelFields"; +import { usePersonaModelDiscovery } from "./usePersonaModelDiscovery"; export function CreateAgentDialog({ open, @@ -88,6 +103,15 @@ export function CreateAgentDialog({ React.useState(null); const [probeError, setProbeError] = React.useState(null); + // Local-mode LLM provider and model — structured state for the credential + // gate and live discovery. Only rendered when the runtime supports provider + // selection (buzz-agent / goose). + const [provider, setProvider] = React.useState(""); + const [model, setModel] = React.useState(""); + const [isCustomProviderEditing, setIsCustomProviderEditing] = + React.useState(false); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + // When `useMesh` is on, the agent runs buzz-agent against a member's // shared compute. The ACP runtime + backend selectors are hidden; runtime // fields are driven by `mesh_agent_preset(meshModelId)` and the submit @@ -131,6 +155,80 @@ export function CreateAgentDialog({ prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; + // Local mode: provider/model field visibility and live discovery. + // Create has no inherit checkbox — selectedRuntimeId IS the prospective runtime. + const llmProviderFieldVisible = + !isProviderMode && + !useMesh && + runtimeSupportsLlmProviderSelection(selectedRuntimeId); + const providerForDiscovery = llmProviderFieldVisible ? provider : ""; + + const { + discoveredModelOptions, + modelDiscoveryLoading, + modelDiscoveryStatus, + } = usePersonaModelDiscovery({ + envVars, + isCustomProviderEditing, + modelFieldVisible: llmProviderFieldVisible, + open, + provider: providerForDiscovery, + selectedRuntime: selectedRuntime ?? undefined, + }); + + // Full required credential key list for EnvVarsEditor amber locked rows — + // includes already-satisfied keys, not just missing ones. + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + selectedRuntimeId, + { enabled: open }, + ); + // Credential keys satisfied by the runtime file config (e.g. goose config.yaml). + // These are shown as "Set in goose config" rows rather than amber required rows. + const fileSatisfiedEnvKeys = React.useMemo(() => { + if (!runtimeFileConfig) return [] as string[]; + const allKeys = requiredCredentialEnvKeys( + selectedRuntimeId, + runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", + ); + return allKeys.filter( + (key) => + (envVars[key] ?? "").length === 0 && + runtimeFileConfig.satisfiedEnvKeys.includes(key), + ); + }, [runtimeFileConfig, selectedRuntimeId, provider, envVars]); + + const requiredEnvKeys = React.useMemo( + () => + requiredCredentialEnvKeys( + selectedRuntimeId, + runtimeSupportsLlmProviderSelection(selectedRuntimeId) ? provider : "", + ).filter((key) => !fileSatisfiedEnvKeys.includes(key)), + [selectedRuntimeId, provider, fileSatisfiedEnvKeys], + ); + + // Clear model when provider scope changes, mirroring EditAgentDialog. + React.useEffect(() => { + if ( + !open || + isCustomModelEditing || + !shouldClearKnownModelForSelectionScope({ + model, + provider: providerForDiscovery, + runtime: selectedRuntimeId, + }) + ) { + return; + } + setModel(""); + setIsCustomModelEditing(false); + }, [ + isCustomModelEditing, + model, + open, + providerForDiscovery, + selectedRuntimeId, + ]); + React.useEffect(() => { if (hasSyncedProviderSelection || providersQuery.isLoading) { return; @@ -249,6 +347,10 @@ export function CreateAgentDialog({ setProviderConfig({}); setProbedProvider(null); setProbeError(null); + setProvider(""); + setModel(""); + setIsCustomProviderEditing(false); + setIsCustomModelEditing(false); setUseMesh(false); setMeshModelId(""); setMeshClientError(null); @@ -266,24 +368,40 @@ export function CreateAgentDialog({ } function handleProviderChange(nextProviderId: string) { + const previousRuntimeId = selectedRuntimeId; setSelectedRuntimeId(nextProviderId); if (nextProviderId === "custom") { setShowAdvanced(true); - return; + } else { + const runtime = runtimes.find( + (candidate) => candidate.id === nextProviderId, + ); + if (runtime) { + setLastRuntime(nextProviderId); + setAgentCommand(runtime.command); + setAgentArgs(runtime.defaultArgs.join(",")); + setMcpCommand(runtime.mcpCommand ?? ""); + } } - const provider = runtimes.find( - (candidate) => candidate.id === nextProviderId, - ); - if (!provider) { - return; + // Clear model when switching to a runtime with a different model scope, + // and clear provider/model when switching away from provider-selection runtimes. + if ( + shouldClearModelForRuntimeChange(previousRuntimeId, nextProviderId) || + shouldClearKnownModelForSelectionScope({ + model, + provider, + runtime: nextProviderId, + }) + ) { + setModel(""); + setIsCustomModelEditing(false); + } + if (!runtimeSupportsLlmProviderSelection(nextProviderId)) { + setProvider(""); + setIsCustomProviderEditing(false); } - - setLastRuntime(nextProviderId); - setAgentCommand(provider.command); - setAgentArgs(provider.defaultArgs.join(",")); - setMcpCommand(provider.mcpCommand ?? ""); } function handleRunOnChange(value: string) { @@ -293,6 +411,52 @@ export function CreateAgentDialog({ setProbeError(null); } + // Provider dropdown handler for local-mode provider field — mirrors Edit. + function handleProviderDropdownChange(nextValue: string) { + if (nextValue === CUSTOM_PROVIDER_DROPDOWN_VALUE) { + const previousApiKey = getProviderApiKeyEnvVar(provider); + if (previousApiKey) { + setEnvVars((current) => { + const next = { ...current }; + delete next[previousApiKey]; + return next; + }); + } + setIsCustomProviderEditing(true); + setProvider(""); + return; + } + + const nextProvider = + nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue; + + // Clear the old API key when switching providers. + const previousApiKey = getProviderApiKeyEnvVar(provider); + const nextApiKey = getProviderApiKeyEnvVar(nextProvider); + if (previousApiKey && previousApiKey !== nextApiKey) { + setEnvVars((current) => { + const next = { ...current }; + delete next[previousApiKey]; + return next; + }); + } + + setIsCustomProviderEditing(false); + setProvider(nextProvider); + + if ( + !isCustomModelEditing && + shouldClearKnownModelForSelectionScope({ + model, + provider: nextProvider, + runtime: selectedRuntimeId, + }) + ) { + setModel(""); + setIsCustomModelEditing(false); + } + } + // Check provider config required fields are filled. const providerConfigComplete = React.useMemo(() => { if (!isProviderMode || !probedProvider?.config_schema) return true; @@ -407,7 +571,10 @@ export function CreateAgentDialog({ : undefined, systemPrompt: systemPrompt.trim() || undefined, envVars, - model: useMesh ? meshModelId.trim() || undefined : undefined, + model: useMesh + ? meshModelId.trim() || undefined + : model.trim() || undefined, + provider: !useMesh ? provider.trim() || undefined : undefined, relayMesh: useMesh ? { modelRef: meshModelId.trim() } : undefined, spawnAfterCreate, // Relay-mesh agents need a freshly selected serve target to start; @@ -546,6 +713,32 @@ export function CreateAgentDialog({ /> ) : null} + {/* Local mode: structured provider + model fields for credential + gate and live discovery. Only when the runtime supports it. */} + {llmProviderFieldVisible ? ( + + ) : null} + {llmProviderFieldVisible ? ( + + ) : null} + { @@ -641,8 +834,10 @@ export function CreateAgentDialog({ diff --git a/desktop/src/features/agents/ui/EditAgentDialog.tsx b/desktop/src/features/agents/ui/EditAgentDialog.tsx index 7aa5b1073..e4155a5be 100644 --- a/desktop/src/features/agents/ui/EditAgentDialog.tsx +++ b/desktop/src/features/agents/ui/EditAgentDialog.tsx @@ -2,11 +2,12 @@ import * as React from "react"; import { useAcpRuntimesQuery, + useAgentConfigSurface, usePersonasQuery, + useRuntimeFileConfigQuery, useUpdateManagedAgentMutation, } from "@/features/agents/hooks"; import type { - AcpRuntimeCatalogEntry, ManagedAgent, RespondToMode, UpdateManagedAgentInput, @@ -19,36 +20,31 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; import { - AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, - CUSTOM_MODEL_DROPDOWN_VALUE, CUSTOM_PROVIDER_DROPDOWN_VALUE, formatRuntimeOptionLabel, - getModelSelectValue, - getPersonaProviderOptions, getProviderApiKeyEnvVar, - hasPersonaModelOption, + isMissingRequiredDropdownField, NO_RUNTIME_DROPDOWN_VALUE, runtimeSupportsLlmProviderSelection, + requiredCredentialEnvKeys, shouldClearKnownModelForSelectionScope, sortPersonaRuntimes, type PersonaDropdownOption, - type PersonaModelOption, } from "./personaDialogPickers"; import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; -import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus"; +import { + AgentModelField, + AgentProviderField, +} from "./personaProviderModelFields"; import { CreateAgentBasicsFields, CreateAgentRuntimeFields, } from "./CreateAgentDialogSections"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; -import { - MODEL_DISCOVERY_LOADING_VALUE, - usePersonaModelDiscovery, -} from "./usePersonaModelDiscovery"; +import { usePersonaModelDiscovery } from "./usePersonaModelDiscovery"; export function EditAgentDialog({ agent, @@ -63,6 +59,7 @@ export function EditAgentDialog({ }) { const updateMutation = useUpdateManagedAgentMutation(); const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); + const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; const [name, setName] = React.useState(agent.name); @@ -229,6 +226,90 @@ export function EditAgentDialog({ ); const providerForDiscovery = llmProviderFieldVisible ? provider : ""; + const normalizedConfig = configSurfaceQuery.data?.normalized; + const modelRequired = isMissingRequiredDropdownField( + normalizedConfig?.model, + model, + ); + const providerRequired = isMissingRequiredDropdownField( + normalizedConfig?.provider, + provider, + ); + + // The runtime id that will actually be active after submit. When inheriting, + // resolve from agent.agentCommand (the persona's runtime) using the same + // dual-match used at submit time — command path first, then id fallback for + // catalog entries where the adapter binary is missing (command:null). This + // single prospective id feeds BOTH the block-save gate (requiredEnvKeys) and + // the submit path so they never disagree on which runtime is being saved. + const prospectiveRuntimeId = React.useMemo(() => { + if (!inheritHarness) { + return selectedRuntime?.id ?? selectedRuntimeId; + } + return ( + runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) + ?.id ?? + runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ?? + "" + ); + }, [ + inheritHarness, + runtimes, + agent.agentCommand, + selectedRuntime?.id, + selectedRuntimeId, + ]); + + // Provider used for required-key validation — keyed off the PROSPECTIVE + // runtime, not the current dropdown. When the user transitions from a + // CLI-login pin (claude) to inherit a buzz-agent/goose persona, the current + // dropdown would suppress provider to "" (llmProviderFieldVisible=false), + // making requiredCredentialEnvKeys return [] and falsely unblocking the save. + // Using prospectiveRuntimeId here ensures the gate checks the credential + // requirements of the runtime that will actually be saved. + const providerForRequiredKeys = runtimeSupportsLlmProviderSelection( + prospectiveRuntimeId, + ) + ? provider + : ""; + + // Required credential env keys for the PROSPECTIVE post-submit runtime. + // Using the prospective id (not the current dropdown) ensures the gate + // validates what will actually be saved — in particular, on the inherit + // transition (claude→buzz-agent or buzz-agent→claude) the gate reflects + // the inherited runtime's requirements, not the old pin's. + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + prospectiveRuntimeId, + { enabled: open }, + ); + // Credential keys satisfied by the runtime file config — shown as + // "Set in goose config" rows rather than amber required rows. + const fileSatisfiedEnvKeys = React.useMemo(() => { + if (!runtimeFileConfig) return [] as string[]; + const allKeys = requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ); + return allKeys.filter( + (key) => + (envVars[key] ?? "").length === 0 && + runtimeFileConfig.satisfiedEnvKeys.includes(key), + ); + }, [ + runtimeFileConfig, + prospectiveRuntimeId, + providerForRequiredKeys, + envVars, + ]); + + const requiredEnvKeys = React.useMemo( + () => + requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ).filter((key) => !fileSatisfiedEnvKeys.includes(key)), + [prospectiveRuntimeId, providerForRequiredKeys, fileSatisfiedEnvKeys], + ); const { discoveredModelOptions, @@ -431,21 +512,10 @@ export function EditAgentDialog({ : undefined; // Derive the effective runtime at submit time — the one that will - // actually run AFTER submit. When pinned (inheritHarness=false), it's - // the live dropdown selection. When inheriting, match agent.agentCommand - // first by command (the normal path), then fall back to id-match for - // runtimes where the adapter is missing (command:null in the catalog). - // The id of a known runtime is stable even when its adapter binary is - // absent, so id-fallback lets us classify capability correctly without - // treating a "known adapter missing" as "completely unknown runtime." - const effectiveRuntimeIdForSubmit = inheritHarness - ? (runtimes.find((r) => r.command?.trim() === agent.agentCommand.trim()) - ?.id ?? - // Fallback: id-based match for command:null catalog entries (adapter - // missing but runtime is known and its capability is still static). - runtimes.find((r) => r.id === agent.agentCommand.trim())?.id ?? - "") - : (selectedRuntime?.id ?? selectedRuntimeId); + // actually run AFTER submit. This is the component-scope prospectiveRuntimeId, + // which is shared with the block-save gate (requiredEnvKeys) so both + // always agree on which runtime is being saved. + const effectiveRuntimeIdForSubmit = prospectiveRuntimeId; // Classify the effective runtime's provider capability as a tri-state so // the provider submit branch can distinguish "known-locked" (clear) from @@ -569,10 +639,11 @@ export function EditAgentDialog({ onModeChange={setRespondTo} /> - {llmProviderFieldVisible ? ( - @@ -720,183 +794,6 @@ export function EditAgentDialog({ ); } -function EditAgentModelField({ - disabled, - discoveredModelOptions, - isCustomModelEditing, - model, - modelDiscoveryLoading, - modelDiscoveryStatus, - onIsCustomModelEditingChange, - onModelChange, -}: { - disabled: boolean; - discoveredModelOptions: readonly PersonaModelOption[] | null; - isCustomModelEditing: boolean; - model: string; - modelDiscoveryLoading: boolean; - modelDiscoveryStatus: PersonaModelDiscoveryStatus | null; - onIsCustomModelEditingChange: (value: boolean) => void; - onModelChange: (value: string) => void; -}) { - const trimmedModel = model.trim(); - - // Mirror Persona: static options serve as the fallback when discovery hasn't - // returned yet. Discovered options are ADDITIVE — we never disable the picker - // or hide the custom input just because discovery returned null. - const staticModelOptions: readonly PersonaModelOption[] = [ - { id: "", label: "Default model" }, - ]; - const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions; - - // isModelCustom: true when the current model isn't in any known option set. - // We check discovered options (when available) or runtime-static options so - // a previously-saved custom model stays in custom mode even before discovery. - const isModelCustom = !hasPersonaModelOption( - effectiveModelOptions, - trimmedModel, - ); - - const modelSelectValue = getModelSelectValue({ - isCustomModelEditing, - isModelCustom, - model, - }); - - // The select is only disabled for mutation pending — never for missing discovery. - // Default/custom options remain usable regardless of discovery state. - const selectDisabled = disabled || modelDiscoveryLoading; - - // Show the custom model input whenever custom mode is active or the current - // model is already custom — not gated on discovery having returned. - const showCustomModelInput = isCustomModelEditing || isModelCustom; - - return ( -
- - - {showCustomModelInput ? ( - onModelChange(event.target.value)} - placeholder="Custom model ID" - value={model} - /> - ) : null} -

- {modelDiscoveryLoading - ? "Loading models..." - : modelDiscoveryStatus !== null - ? modelDiscoveryStatus.message - : discoveredModelOptions !== null - ? "Saved changes take effect on the next start." - : "Select a provider above to see available models."} -

-
- ); -} - -function EditAgentProviderField({ - disabled, - isCustomProviderEditing, - onProviderChange, - provider, - selectedRuntime, -}: { - disabled: boolean; - isCustomProviderEditing: boolean; - onProviderChange: (value: string) => void; - provider: string; - selectedRuntime: AcpRuntimeCatalogEntry | undefined; -}) { - const trimmedProvider = provider.trim(); - const providerOptions = getPersonaProviderOptions( - trimmedProvider, - selectedRuntime?.id ?? "", - ); - const providerSelectValue = isCustomProviderEditing - ? CUSTOM_PROVIDER_DROPDOWN_VALUE - : trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE; - - return ( -
- - - {isCustomProviderEditing ? ( - onProviderChange(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> - ) : null} -

- Changing the provider updates the available model list immediately. -

-
- ); -} - function envVarsChanged( a: Record, b: Record, diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.tsx b/desktop/src/features/agents/ui/EnvVarsEditor.tsx index fa4cc543e..68a1d42c0 100644 --- a/desktop/src/features/agents/ui/EnvVarsEditor.tsx +++ b/desktop/src/features/agents/ui/EnvVarsEditor.tsx @@ -1,4 +1,4 @@ -import { Plus, X } from "lucide-react"; +import { AlertCircle, Lock, Plus, X } from "lucide-react"; import * as React from "react"; import { Button } from "@/shared/ui/button"; @@ -27,6 +27,22 @@ type EnvVarsEditorProps = { helperText?: string; /** Disables all editing. */ disabled?: boolean; + /** + * Env var keys that are required for the agent to start with the currently + * selected runtime + provider. Each key renders as a locked first-class row + * at the top of the editor: the key is pre-filled and read-only; the value + * is editable. If the value is already set in `value`, it is shown; otherwise + * the row is empty and marked with a "Required" badge so the user knows to + * fill it in. + */ + requiredKeys?: readonly string[]; + /** + * Env var keys that are required but already satisfied by the runtime's + * config file (e.g. `~/.config/goose/config.yaml`). These are shown as + * read-only informational rows with a "Set in goose config" annotation so + * the user knows the key is covered without needing to add it here. + */ + fileSatisfiedKeys?: readonly string[]; }; type Row = { id: string; key: string; value: string }; @@ -47,6 +63,8 @@ export function EnvVarsEditor({ label = "Environment variables", helperText, disabled = false, + requiredKeys = [], + fileSatisfiedKeys = [], }: EnvVarsEditorProps) { // Local ordered row state. Synced from `value` on mount and when the // parent supplies a value we did NOT just emit (e.g., dialog reopened @@ -82,6 +100,13 @@ export function EnvVarsEditor({ emit([...rows, { id: crypto.randomUUID(), key: "", value: "" }]); } + // Required rows are rendered before the user-editable rows. They are not + // part of `rows` state — they read from / write to `value` directly via + // `onChange`, using their key as the stable identity. + function updateRequiredValue(key: string, newValue: string) { + onChange({ ...value, [key]: newValue }); + } + return (
@@ -91,7 +116,123 @@ export function EnvVarsEditor({ ) : null}
- {rows.length === 0 ? ( + {/* Required credential rows — shown first, key is read-only */} + {requiredKeys.map((key) => { + const currentValue = value[key] ?? ""; + const isMissing = currentValue.length === 0; + return ( +
+
+
+ + + {key} + + {isMissing ? ( + + + Required + + ) : null} +
+
+ + updateRequiredValue(key, event.target.value) + } + placeholder="value" + type="password" + value={currentValue} + /> +
+ {/* Spacer to align with the remove-button column */} +
+
+ {(() => { + const inheritedValue = inheritedFrom?.[key]; + return inheritedValue !== undefined ? ( +

+ Overrides {inheritedLabel} value{" "} + + {maskInherited(inheritedValue)} + +

+ ) : null; + })()} +
+ ); + })} + + {/* File-satisfied keys — required but set in the runtime config file */} + {fileSatisfiedKeys.map((key) => ( +
+
+
+ + + {key} + + + Set in goose config + +
+ {/* Spacer columns to align with required-key rows */} +
+ + •••••••• + +
+
+
+
+ ))} + + {/* User-managed rows */} + {rows.length === 0 && + requiredKeys.length === 0 && + fileSatisfiedKeys.length === 0 ? (

No variables set.

diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 3d014210b..a29193048 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -15,12 +15,16 @@ export function PersonaAdvancedFields({ namePoolText, onEnvVarsChange, onNamePoolTextChange, + requiredEnvKeys = [], + fileSatisfiedEnvKeys = [], }: { disabled: boolean; envVars: EnvVarsValue; namePoolText: string; onEnvVarsChange: (value: EnvVarsValue) => void; onNamePoolTextChange: (value: string) => void; + requiredEnvKeys?: readonly string[]; + fileSatisfiedEnvKeys?: readonly string[]; }) { return (
@@ -57,7 +61,9 @@ export function PersonaAdvancedFields({
diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index 5a813ec4a..a108d5235 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -41,6 +41,7 @@ import { AUTO_PROVIDER_DROPDOWN_VALUE, CUSTOM_MODEL_DROPDOWN_VALUE, CUSTOM_PROVIDER_DROPDOWN_VALUE, + computeLocalModeGate, formatRuntimeOptionLabel, getDefaultLlmProviderLabel, getDefaultPersonaRuntime, @@ -53,6 +54,7 @@ import { hasPersonaModelOption, NO_RUNTIME_DROPDOWN_VALUE, providerRequiresExplicitModel, + requiredCredentialEnvKeys, runtimeSupportsLlmProviderSelection, type PersonaDropdownOption, PERSONA_FIELD_CONTROL_CLASS, @@ -62,10 +64,12 @@ import { sortPersonaRuntimes, } from "./personaDialogPickers"; import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; +import { RequiredFieldLabel } from "./personaProviderModelFields"; import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; +import { useRuntimeFileConfigQuery } from "../hooks"; type PersonaDialogProps = { open: boolean; @@ -419,6 +423,35 @@ export function PersonaDialog({ : ""; const providerApiKeyFieldVisible = llmProviderFieldVisible && providerApiKeyConfig !== null; + // Required credential env keys for this runtime + provider combination. + // Used to show required markers on the LLM provider label and amber + // locked rows in the env vars editor. + // File-layer config for the selected runtime (e.g. goose config.yaml). + // Used to silence requirements already satisfied there. + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { + enabled: open, + }); + const localModeGate = computeLocalModeGate({ + envVars, + isProviderMode: false, + model, + provider: trimmedProvider, + runtimeId: runtime, + runtimeFileConfig, + useMesh: false, + }); + // Required keys for EnvVarsEditor amber rows: exclude file-satisfied keys + // so they render in the "Set in goose config" row instead. + const requiredEnvKeys = requiredCredentialEnvKeys( + runtime, + trimmedProvider, + ).filter((key) => !localModeGate.fileSatisfiedEnvKeys.includes(key)); + // Provider required-ness is a static property of the runtime — it does not + // change based on whether the field is currently filled. Using the dynamic + // missingNormalizedFields check would flip the asterisk off once a value is + // selected, which is incoherent (required means required, not "required until + // satisfied"). runtimeSupportsLlmProviderSelection is the authoritative gate. + const providerIsRequired = runtimeSupportsLlmProviderSelection(runtime); const modelFieldVisible = runtime.trim().length > 0 || blankRuntimeModelProviderEditable; const isExplicitModelRequired = @@ -863,7 +896,7 @@ export function PersonaDialog({ className="text-sm font-medium text-foreground" htmlFor="persona-runtime" > - Provider + Agent runtime - + {!providerIsRequired ? ( + + Optional + + ) : null} + ) : null} diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs new file mode 100644 index 000000000..e8e24370e --- /dev/null +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -0,0 +1,412 @@ +/** + * Unit tests for the CreateAgentDialog local-mode readiness gate. + * + * The gate computes whether required fields are present for the selected + * runtime: when missing, it surfaces field markers (isRequired) and env-key + * amber rows (EnvVarsEditor.requiredKeys), and the setup-listener nudge will + * fire after spawn. The gate NO LONGER blocks the create/save button — + * users can save with incomplete config and the nudge will guide them. + * + * On Create there is no inherit checkbox, so selectedRuntimeId IS the + * prospective runtime — no prospectiveRuntimeId hoist needed. + * + * The shared helper under test: + * computeLocalModeGate — pure function used by field isRequired and + * EnvVarsEditor.requiredKeys; canSubmit no longer + * reads gate.satisfied. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + computeLocalModeGate, + requiredCredentialEnvKeys, + runtimeSupportsLlmProviderSelection, +} from "./personaDialogPickers.tsx"; + +// ── Core predicate: provider-selection support ───────────────────────────── + +test("localMode_buzzAgent_supportsProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("buzz-agent"), + true, + "buzz-agent must support LLM provider selection", + ); +}); + +test("localMode_goose_supportsProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("goose"), + true, + "goose must support LLM provider selection", + ); +}); + +test("localMode_claude_doesNotSupportProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("claude"), + false, + "claude must NOT support LLM provider selection (CLI-login runtime)", + ); +}); + +test("localMode_custom_doesNotSupportProviderSelection", () => { + assert.equal( + runtimeSupportsLlmProviderSelection("custom"), + false, + "custom runtime must NOT support LLM provider selection", + ); +}); + +// ── IMPORTANT 1: normalized field gate (provider + model) ───────────────── + +test("localMode_buzzAgent_emptyProvider_notSatisfied", () => { + // Scenario: user selects buzz-agent but leaves provider empty. + // Rust readiness requires BUZZ_AGENT_PROVIDER — empty = NotReady. + // The gate must report not-satisfied and surface the missing field marker, + // but does NOT block the save button. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + result.missingNormalizedFields.includes("provider"), + "missing provider must be in missingNormalizedFields", + ); + assert.equal( + result.satisfied, + false, + "empty provider: gate not satisfied (marker shown); save button is still enabled", + ); +}); + +test("localMode_buzzAgent_emptyModel_notSatisfied", () => { + // Scenario: buzz-agent + anthropic + API key present, but model left empty. + // Rust readiness requires BUZZ_AGENT_MODEL — empty = NotReady. + // The gate surfaces the missing field marker; save button is still enabled. + const result = computeLocalModeGate({ + envVars: { ANTHROPIC_API_KEY: "sk-ant-test" }, + isProviderMode: false, + model: "", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + result.missingNormalizedFields.includes("model"), + "missing model must be in missingNormalizedFields", + ); + assert.equal( + result.satisfied, + false, + "empty model: gate not satisfied (marker shown); save button is still enabled", + ); +}); + +// ── Gate: buzz-agent / anthropic with missing key → markers shown ───────── + +test("localMode_buzzAgent_anthropic_missingKey_notSatisfied", () => { + // Scenario: user selects buzz-agent/anthropic + fills model, but hasn't + // supplied ANTHROPIC_API_KEY — the exact crash-loop case the nudge handles. + // Gate reports not-satisfied (required marker + env row shown); save allowed. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + result.missingEnvKeys.includes("ANTHROPIC_API_KEY"), + "ANTHROPIC_API_KEY must be in missingEnvKeys", + ); + assert.equal( + result.satisfied, + false, + "missing ANTHROPIC_API_KEY: gate not satisfied (marker + nudge shown); save still allowed", + ); +}); + +test("localMode_buzzAgent_anthropic_allRequired_present_allowed", () => { + // All three required fields present: provider, model, and credential key. + const result = computeLocalModeGate({ + envVars: { ANTHROPIC_API_KEY: "sk-ant-test" }, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.deepEqual( + result.missingNormalizedFields, + [], + "no missing normalized fields when provider and model are set", + ); + assert.deepEqual( + result.missingEnvKeys, + [], + "no missing env keys when ANTHROPIC_API_KEY is set", + ); + assert.equal( + result.satisfied, + true, + "all required fields present must allow create", + ); +}); + +// ── Gate: claude runtime (CLI-login) → NOT blocked ──────────────────────── + +test("localMode_claude_noRequiredFields_notBlocked", () => { + // Scenario: user selects claude. Claude uses CLI-login (out-of-band auth), + // runtimeSupportsLlmProviderSelection=false → no provider/model required, + // no credential keys required. The gate must not block. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "claude", + useMesh: false, + }); + + assert.deepEqual( + result.missingNormalizedFields, + [], + "claude must have no required normalized fields", + ); + assert.deepEqual( + result.missingEnvKeys, + [], + "claude must return no required credential keys", + ); + assert.equal( + result.satisfied, + true, + "claude must NOT be blocked by the local-mode gate", + ); +}); + +// ── Gate: isProviderMode / useMesh bypass ───────────────────────────────── + +test("localMode_gate_bypassed_for_providerMode", () => { + // In provider mode, gate must be satisfied regardless of local fields. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: true, + model: "", + provider: "", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.equal( + result.satisfied, + true, + "provider mode must bypass the local-mode gate", + ); +}); + +test("localMode_gate_bypassed_for_meshMode", () => { + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "buzz-agent", + useMesh: true, + }); + + assert.equal( + result.satisfied, + true, + "relay-mesh mode must bypass the local-mode gate", + ); +}); + +// ── IMPORTANT 2: requiredEnvKeys surfaces correctly ─────────────────────── + +test("localMode_requiredEnvKeys_surfaces_anthropicKey", () => { + // requiredCredentialEnvKeys returns ALL required keys for the provider + // (including already-satisfied ones) — what EnvVarsEditor receives for + // its amber locked rows. Verify the full key list, not just missing keys. + const allKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + assert.ok( + allKeys.includes("ANTHROPIC_API_KEY"), + "requiredCredentialEnvKeys must include ANTHROPIC_API_KEY for buzz-agent/anthropic", + ); +}); + +test("localMode_requiredEnvKeys_gate_and_envVarsEditor_share_same_key_set", () => { + // The key the gate blocks on must equal the key EnvVarsEditor shows. + // computeLocalModeGate.missingEnvKeys ⊆ requiredCredentialEnvKeys output. + const gateResult = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + const fullKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + + for (const key of gateResult.missingEnvKeys) { + assert.ok( + fullKeys.includes(key), + `gate-missing key ${key} must appear in requiredCredentialEnvKeys output (EnvVarsEditor source)`, + ); + } +}); + +// ── Gate: provider selection drives required credential keys ────────────── + +test("localMode_providerSelection_drives_requiredKey", () => { + // Different provider selections must produce different required keys. + const anthropicGate = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-3-5-sonnet-20241022", + provider: "anthropic", + runtimeId: "buzz-agent", + useMesh: false, + }); + const databricksGate = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "databricks-meta-llama", + provider: "databricks", + runtimeId: "buzz-agent", + useMesh: false, + }); + + assert.ok( + anthropicGate.missingEnvKeys.length > 0, + "anthropic must require at least one credential key", + ); + assert.ok( + databricksGate.missingEnvKeys.length > 0, + "databricks must require at least one credential key", + ); + assert.notDeepEqual( + anthropicGate.missingEnvKeys, + databricksGate.missingEnvKeys, + "different providers must require different keys", + ); +}); + +// ── File-config bridge tests ────────────────────────────────────────────── + +test("localMode_goose_databricksHost_satisfiedByFileConfig_notRequired", () => { + // Scenario: goose runtime, databricks_v2 provider, DATABRICKS_HOST in file. + // The gate should NOT flag DATABRICKS_HOST as missing — it's satisfied in goose config. + const fileConfig = { + provider: "databricks_v2", + model: "goose-claude-4-6-opus", + satisfiedEnvKeys: ["DATABRICKS_HOST"], + }; + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "goose-claude-4-6-opus", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: fileConfig, + useMesh: false, + }); + + assert.ok( + !result.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must NOT appear in missingEnvKeys when satisfied by file config", + ); + assert.ok( + result.fileSatisfiedEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must appear in fileSatisfiedEnvKeys when set in goose config", + ); + assert.equal( + result.satisfied, + true, + "gate must be satisfied when all requirements are covered by env or file config", + ); +}); + +test("localMode_goose_databricksHost_noFileConfig_stillRequired", () => { + // Scenario: goose + databricks_v2, no file config present. + // DATABRICKS_HOST must still be required. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "some-model", + provider: "databricks_v2", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + result.missingEnvKeys.includes("DATABRICKS_HOST"), + "DATABRICKS_HOST must be required when absent from both env and file config", + ); + assert.equal( + result.satisfied, + false, + "gate must NOT be satisfied when DATABRICKS_HOST is missing from env and file", + ); +}); + +test("localMode_goose_providerSatisfiedByFileConfig_noNormalizedFieldRequired", () => { + // Scenario: goose, no provider in Buzz env but file config has provider + model. + // Neither 'provider' nor 'model' should be required. + const fileConfig = { + provider: "anthropic", + model: "claude-opus-4-5", + satisfiedEnvKeys: [], + }; + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "goose", + runtimeFileConfig: fileConfig, + useMesh: false, + }); + + assert.deepEqual( + result.missingNormalizedFields, + [], + "normalized fields must be empty when provider + model are in file config", + ); +}); + +test("localMode_goose_envPlusFileConfig_bothEmpty_stillRequired", () => { + // Scenario: goose, empty env, file config is null (no file). + // Both provider and model must be required. + const result = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "goose", + runtimeFileConfig: null, + useMesh: false, + }); + + assert.ok( + result.missingNormalizedFields.includes("provider"), + "provider must be required when absent from both env and file", + ); + assert.ok( + result.missingNormalizedFields.includes("model"), + "model must be required when absent from both env and file", + ); + assert.equal(result.satisfied, false, "gate must not be satisfied"); +}); diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 857d07abf..75cfe00ee 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -4,6 +4,8 @@ import test from "node:test"; import { runtimeSupportsLlmProviderSelection, getPersonaProviderOptions, + requiredCredentialEnvKeys, + isMissingRequiredDropdownField, } from "./personaDialogPickers.tsx"; import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel.ts"; @@ -91,6 +93,28 @@ test("editAgent_providerOptions_includesCurrentIfCustom", () => { // for deciding whether to show options is sound: when discovery is null, we // fall back to staticModelOptions (length > 0), so we always have options. +test("editAgent_requiredDropdownField_onlyMarksMissingKnownField", async () => { + const { isMissingRequiredDropdownField } = await import( + "./personaDialogPickers.tsx" + ); + + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, ""), + true, + "missing required dropdown value must be marked required", + ); + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, "configured"), + false, + "configured required dropdown value must not show the missing-required mark", + ); + assert.equal( + isMissingRequiredDropdownField(null, ""), + false, + "unknown normalized field names are ignored because they do not map to a dropdown", + ); +}); + test("editAgent_modelFallback_staticOptionsWhenDiscoveryNull", () => { const staticModelOptions = [{ id: "", label: "Default model" }]; // Simulate: discoveredModelOptions === null → effectiveModelOptions is static fallback @@ -993,3 +1017,281 @@ test("editAgent_bugA_unknownCommandStillFallsBackToCustom", () => { "unknown command/id must still fall back to 'custom'", ); }); + +// ── requiredCredentialEnvKeys ────────────────────────────────────────────── +// +// Guards the provider-aware credential requirements surface so Phase 2 +// required env rows stay correct as providers and runtimes change. + +test("requiredCredentialEnvKeys: buzz-agent + anthropic → ANTHROPIC_API_KEY", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + assert.deepEqual(keys, ["ANTHROPIC_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: buzz-agent + openai → OPENAI_COMPAT_API_KEY", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "openai"); + assert.deepEqual(keys, ["OPENAI_COMPAT_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: buzz-agent + databricks → DATABRICKS_HOST only (no token)", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "databricks"); + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + assert.deepEqual(keys, ["DATABRICKS_HOST"]); + assert.ok( + !keys.includes("DATABRICKS_TOKEN"), + "DATABRICKS_TOKEN must not be required (OAuth PKCE is the normal auth path)", + ); +}); + +test("requiredCredentialEnvKeys: buzz-agent + databricks_v2 → DATABRICKS_HOST only", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", "databricks_v2"); + assert.deepEqual(keys, ["DATABRICKS_HOST"]); +}); + +test("requiredCredentialEnvKeys: goose + anthropic → ANTHROPIC_API_KEY", () => { + const keys = requiredCredentialEnvKeys("goose", "anthropic"); + assert.deepEqual(keys, ["ANTHROPIC_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: goose + openai → OPENAI_COMPAT_API_KEY", () => { + const keys = requiredCredentialEnvKeys("goose", "openai"); + assert.deepEqual(keys, ["OPENAI_COMPAT_API_KEY"]); +}); + +test("requiredCredentialEnvKeys: buzz-agent + no provider → empty (provider not yet selected)", () => { + const keys = requiredCredentialEnvKeys("buzz-agent", ""); + assert.deepEqual(keys, []); +}); + +test("requiredCredentialEnvKeys: claude → empty (uses CLI login, not env keys)", () => { + const keys = requiredCredentialEnvKeys("claude", ""); + assert.deepEqual(keys, []); +}); + +test("requiredCredentialEnvKeys: codex → empty (uses CLI login, not env keys)", () => { + const keys = requiredCredentialEnvKeys("codex", ""); + assert.deepEqual(keys, []); +}); + +test("requiredCredentialEnvKeys: custom/unknown runtime → empty", () => { + const keys = requiredCredentialEnvKeys("my-custom-harness", "anthropic"); + assert.deepEqual(keys, []); +}); + +// ── Block-save gate: hasRequiredEnvKeyMissing logic ──────────────────────── +// +// The EditAgentDialog computes: +// hasRequiredEnvKeyMissing = requiredEnvKeys.some(k => (envVars[k] ?? "").length === 0) +// and folds it into canSubmit. These tests exercise that predicate directly. + +function hasRequiredEnvKeyMissing(requiredKeys, envVars) { + return requiredKeys.some((key) => (envVars[key] ?? "").length === 0); +} + +test("blockSave_buzzAgentAnthropicMissingKey_blocked", () => { + // Will's exact case: buzz-agent / anthropic / opus / no ANTHROPIC_API_KEY + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + const envVars = {}; // key absent + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + true, + "save must be blocked when ANTHROPIC_API_KEY is missing", + ); +}); + +test("blockSave_buzzAgentAnthropicKeyProvided_allowed", () => { + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + const envVars = { ANTHROPIC_API_KEY: "sk-ant-test" }; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "save must be allowed when ANTHROPIC_API_KEY is present", + ); +}); + +test("blockSave_buzzAgentAnthropicEmptyStringKey_blocked", () => { + // Empty string is treated the same as absent — matches EnvVarsEditor isMissing + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "anthropic"); + const envVars = { ANTHROPIC_API_KEY: "" }; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + true, + "save must be blocked when ANTHROPIC_API_KEY is empty string", + ); +}); + +test("blockSave_claudeNoCliLogin_notBlocked", () => { + // CLI-login runtimes have no dialog-fixable requirement — must never block + const requiredKeys = requiredCredentialEnvKeys("claude", ""); + const envVars = {}; // no keys set + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "claude save must NOT be blocked — CLI login is out-of-band", + ); +}); + +test("blockSave_codexNoCliLogin_notBlocked", () => { + const requiredKeys = requiredCredentialEnvKeys("codex", ""); + const envVars = {}; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "codex save must NOT be blocked — CLI login is out-of-band", + ); +}); + +test("blockSave_buzzAgentDatabricksMissingHost_blocked", () => { + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "databricks"); + const envVars = {}; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + true, + "save must be blocked when DATABRICKS_HOST is missing", + ); +}); + +test("blockSave_buzzAgentDatabricksHostProvided_allowed", () => { + const requiredKeys = requiredCredentialEnvKeys("buzz-agent", "databricks"); + const envVars = { DATABRICKS_HOST: "https://my.databricks.instance" }; + assert.equal( + hasRequiredEnvKeyMissing(requiredKeys, envVars), + false, + "save must be allowed when DATABRICKS_HOST is present", + ); +}); + +// ── Block-save gate: isMissingRequiredDropdownField ──────────────────────── +// +// The EditAgentDialog also gates on modelRequired / providerRequired. +// These tests guard the isMissingRequiredDropdownField predicate used for both. + +test("blockSave_missingRequiredModel_blocked", () => { + // isRequired=true and value is empty → should block + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, ""), + true, + "canSubmit must be blocked when required model is unset", + ); +}); + +test("blockSave_requiredModelProvided_allowed", () => { + assert.equal( + isMissingRequiredDropdownField({ isRequired: true }, "claude-opus-4-5"), + false, + "canSubmit must be allowed when required model is set", + ); +}); + +test("blockSave_optionalModelEmpty_allowed", () => { + // isRequired=false → not a block-save condition + assert.equal( + isMissingRequiredDropdownField({ isRequired: false }, ""), + false, + "optional empty model must not block save", + ); +}); + +test("blockSave_nullField_allowed", () => { + // null/undefined field descriptor → not required, not blocked + assert.equal( + isMissingRequiredDropdownField(null, ""), + false, + "null field must not block save", + ); + assert.equal( + isMissingRequiredDropdownField(undefined, ""), + false, + "undefined field must not block save", + ); +}); + +// ── Block-save gate: inherit-runtime transition cases ────────────────────── +// +// When inheritHarness=true, prospectiveRuntimeId resolves from agent.agentCommand +// (the persona's runtime), not the current dropdown. These tests guard the two +// failure modes Thufir flagged: +// FALSE-ALLOW: claude pin → inherit buzz-agent persona → missing ANTHROPIC_API_KEY +// → must be BLOCKED (prospective runtime is buzz-agent/anthropic, key absent) +// FALSE-BLOCK: buzz-agent pin → inherit claude persona +// → must NOT be blocked (claude has no dialog-fixable credential requirement) + +test("blockSave_inheritTransition_claudePin_toBuzzAgentPersona_missingKey_blocked", () => { + // Scenario: agent is currently pinned to claude (CLI-login, llmProviderFieldVisible=false + // so providerForDiscovery="" in the component). The user checks "Inherit runtime + // from persona" where the persona uses buzz-agent/anthropic. + // prospectiveRuntimeId resolves to "buzz-agent"; providerForRequiredKeys must + // use the PROSPECTIVE runtime's provider-field visibility (buzz-agent supports + // provider selection) rather than the current locked runtime's suppression. + const prospectiveRuntimeId = "buzz-agent"; // resolved from persona's agentCommand + const provider = "anthropic"; // agent's configured provider (in envVars / state) + + // Mirror the component's providerForRequiredKeys computation: + // providerForRequiredKeys = runtimeSupportsLlmProviderSelection(prospectiveRuntimeId) + // ? provider : "" + const providerForRequiredKeys = runtimeSupportsLlmProviderSelection( + prospectiveRuntimeId, + ) + ? provider + : ""; + + const requiredKeys = requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ); + const envVars = {}; // ANTHROPIC_API_KEY absent + + // providerForRequiredKeys must be "anthropic" (buzz-agent supports selection) + // so requiredCredentialEnvKeys returns [ANTHROPIC_API_KEY] and save is blocked. + assert.equal( + providerForRequiredKeys, + "anthropic", + "providerForRequiredKeys must use the prospective runtime's visibility, not the locked current runtime", + ); + const missing = requiredKeys.some((key) => (envVars[key] ?? "").length === 0); + assert.equal( + missing, + true, + "inheriting buzz-agent/anthropic persona with no key must BLOCK save (false-allow prevented)", + ); +}); + +test("blockSave_inheritTransition_buzzAgentPin_toClaudePersona_notBlocked", () => { + // Scenario: agent is pinned to buzz-agent/anthropic. The user checks + // "Inherit runtime from persona" where the persona uses claude. + // prospectiveRuntimeId resolves to "claude"; claude doesn't support provider + // selection, so providerForRequiredKeys="" and requiredCredentialEnvKeys returns []. + const prospectiveRuntimeId = "claude"; // resolved from persona's agentCommand + const provider = "anthropic"; // agent's old provider (no longer relevant for claude) + + // Mirror the component's providerForRequiredKeys computation: + const providerForRequiredKeys = runtimeSupportsLlmProviderSelection( + prospectiveRuntimeId, + ) + ? provider + : ""; + + const requiredKeys = requiredCredentialEnvKeys( + prospectiveRuntimeId, + providerForRequiredKeys, + ); + const envVars = {}; // nothing set — claude doesn't require dialog credentials + + // providerForRequiredKeys must be "" (claude doesn't support provider selection) + assert.equal( + providerForRequiredKeys, + "", + "providerForRequiredKeys must be empty for CLI-login runtimes", + ); + const missing = requiredKeys.some((key) => (envVars[key] ?? "").length === 0); + assert.equal( + missing, + false, + "inheriting claude persona must NOT block save (false-block prevented — CLI login is out-of-band)", + ); + assert.equal( + requiredKeys.length, + 0, + "claude must return empty required keys — no dialog-fixable credential", + ); +}); diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index e3b51cfcb..574dabff7 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -1,4 +1,5 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import type { RuntimeFileConfigSubset } from "@/shared/api/tauri"; export const PERSONA_FIELD_SHELL_CLASS = "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; @@ -75,6 +76,49 @@ function isKnownLlmProvider( return (KNOWN_LLM_PROVIDER_IDS as readonly string[]).includes(providerId); } +/** + * Returns the credential env-var keys that are required for a given + * runtime + provider combination. These are the keys that must be present + * in the agent's effective env for it to start successfully. + * + * Used by EnvVarsEditor to render first-class "required" rows that make the + * gap visible before the user tries to save or start the agent. + * + * Mirrors the Rust `readiness::buzz_agent_requirements` / + * `readiness::goose_requirements` logic — keep in sync. + */ +export function requiredCredentialEnvKeys( + runtimeId: string, + provider: string, +): readonly string[] { + const normalizedRuntime = runtimeId.trim(); + const normalizedProvider = provider.trim().toLowerCase(); + + // buzz-agent and goose both use provider-specific credentials. + if (normalizedRuntime === "buzz-agent" || normalizedRuntime === "goose") { + if (normalizedProvider === "anthropic") return ["ANTHROPIC_API_KEY"]; + if (normalizedProvider === "openai") return ["OPENAI_COMPAT_API_KEY"]; + if ( + normalizedProvider === "databricks" || + normalizedProvider === "databricks_v2" + ) { + // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. + return ["DATABRICKS_HOST"]; + } + } + + // claude and codex handle auth via CLI login (not env keys) — those + // requirements are surfaced separately via the CliLogin surface. + return []; +} + +export function isMissingRequiredDropdownField( + field: { isRequired: boolean } | null | undefined, + value: string, +) { + return field?.isRequired === true && value.trim().length === 0; +} + export function runtimeSupportsLlmProviderSelection(runtimeId: string) { return runtimeId === "buzz-agent" || runtimeId === "goose"; } @@ -308,3 +352,108 @@ export function getDefaultPersonaRuntime(runtimes: AcpRuntimeCatalogEntry[]) { null ); } + +/** + * Pure local-mode readiness gate for Create (no existing agent, no config + * surface query). Returns the missing normalized fields (provider, model) and + * the missing credential env keys so the caller can derive `canSubmit`, + * field `isRequired`, and `EnvVarsEditor.requiredKeys` from the same source. + * + * Two classes of required field for provider-selection runtimes (buzz-agent, + * goose) — both required unconditionally per readiness.rs: + * 1. Normalized fields: provider + model (empty string = NotReady) + * 2. Credential env keys: provider-specific (e.g. ANTHROPIC_API_KEY) + * + * isProviderMode / useMesh modes are NOT subject to this gate — they have + * their own gates. Pass isProviderMode=true or useMesh=true to bypass. + */ +export function computeLocalModeGate({ + envVars, + isProviderMode, + model, + provider, + runtimeId, + runtimeFileConfig, + useMesh, +}: { + envVars: Record; + isProviderMode: boolean; + model: string; + provider: string; + runtimeId: string; + /** Optional file-layer config for the runtime (e.g. goose config.yaml). + * When provided, requirements already satisfied there are silenced. */ + runtimeFileConfig?: RuntimeFileConfigSubset | null; + useMesh: boolean; +}): { + /** Normalized field names that are required but empty ("provider", "model"). */ + missingNormalizedFields: string[]; + /** Credential env key names that are required but missing or empty. */ + missingEnvKeys: string[]; + /** Env keys that are not set in Buzz but are satisfied in the runtime's + * config file (e.g. "Set in goose config"). */ + fileSatisfiedEnvKeys: string[]; + /** True when the create button may be enabled (from this gate's perspective). */ + satisfied: boolean; +} { + if (isProviderMode || useMesh) { + return { + missingNormalizedFields: [], + missingEnvKeys: [], + fileSatisfiedEnvKeys: [], + satisfied: true, + }; + } + + const needsProviderSelection = runtimeSupportsLlmProviderSelection(runtimeId); + + // A normalized field is satisfied by the runtime file config when the file + // provides the value (provider or model). The file layer silences the + // requirement; the value is not injected into the Buzz env. + const fileProvider = runtimeFileConfig?.provider?.trim() ?? ""; + const fileModel = runtimeFileConfig?.model?.trim() ?? ""; + const fileSatisfiedKeys = new Set(runtimeFileConfig?.satisfiedEnvKeys ?? []); + + const missingNormalizedFields: string[] = []; + if (needsProviderSelection) { + if (provider.trim().length === 0 && fileProvider.length === 0) { + missingNormalizedFields.push("provider"); + } + if (model.trim().length === 0 && fileModel.length === 0) { + missingNormalizedFields.push("model"); + } + } + + // Credential keys depend on the selected provider (empty provider → no keys + // required beyond the normalized field gate above). + // Use the file provider as fallback when the env provider is empty, so + // credential requirements are computed correctly for file-config runtimes. + const effectiveProviderForKeys = needsProviderSelection + ? provider.trim() || fileProvider + : ""; + const providerForKeys = needsProviderSelection + ? effectiveProviderForKeys + : ""; + const requiredKeys = requiredCredentialEnvKeys(runtimeId, providerForKeys); + + const missingEnvKeys: string[] = []; + const fileSatisfiedEnvKeys: string[] = []; + for (const key of requiredKeys) { + if ((envVars[key] ?? "").length > 0) { + // Set in Buzz env — satisfied, no action. + } else if (fileSatisfiedKeys.has(key)) { + // Not in Buzz env but present in the runtime config file — silenced. + fileSatisfiedEnvKeys.push(key); + } else { + missingEnvKeys.push(key); + } + } + + return { + missingNormalizedFields, + missingEnvKeys, + fileSatisfiedEnvKeys, + satisfied: + missingNormalizedFields.length === 0 && missingEnvKeys.length === 0, + }; +} diff --git a/desktop/src/features/agents/ui/personaProviderModelFields.tsx b/desktop/src/features/agents/ui/personaProviderModelFields.tsx new file mode 100644 index 000000000..dd2fd5af5 --- /dev/null +++ b/desktop/src/features/agents/ui/personaProviderModelFields.tsx @@ -0,0 +1,226 @@ +/** + * Shared provider and model field components for agent dialogs. + * + * Both CreateAgentDialog (local mode) and EditAgentDialog import these + * instead of duplicating the picker logic. + */ +import type * as React from "react"; + +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { Input } from "@/shared/ui/input"; +import { + AUTO_MODEL_DROPDOWN_VALUE, + AUTO_PROVIDER_DROPDOWN_VALUE, + CUSTOM_MODEL_DROPDOWN_VALUE, + CUSTOM_PROVIDER_DROPDOWN_VALUE, + getModelSelectValue, + getPersonaProviderOptions, + hasPersonaModelOption, + type PersonaModelOption, +} from "./personaDialogPickers"; +import { MODEL_DISCOVERY_LOADING_VALUE } from "./usePersonaModelDiscovery"; +import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus"; + +export function RequiredFieldLabel({ + children, + htmlFor, + isRequired, +}: { + children: React.ReactNode; + htmlFor: string; + isRequired: boolean; +}) { + return ( + + ); +} + +export function AgentModelField({ + disabled, + discoveredModelOptions, + isCustomModelEditing, + isRequired, + model, + modelDiscoveryLoading, + modelDiscoveryStatus, + onIsCustomModelEditingChange, + onModelChange, +}: { + disabled: boolean; + discoveredModelOptions: readonly PersonaModelOption[] | null; + isCustomModelEditing: boolean; + isRequired: boolean; + model: string; + modelDiscoveryLoading: boolean; + modelDiscoveryStatus: PersonaModelDiscoveryStatus | null; + onIsCustomModelEditingChange: (value: boolean) => void; + onModelChange: (value: string) => void; +}) { + const trimmedModel = model.trim(); + + // Mirror Persona: static options serve as the fallback when discovery hasn't + // returned yet. Discovered options are ADDITIVE — we never disable the picker + // or hide the custom input just because discovery returned null. + const staticModelOptions: readonly PersonaModelOption[] = [ + { id: "", label: "Default model" }, + ]; + const effectiveModelOptions = discoveredModelOptions ?? staticModelOptions; + + // isModelCustom: true when the current model isn't in any known option set. + // We check discovered options (when available) or runtime-static options so + // a previously-saved custom model stays in custom mode even before discovery. + const isModelCustom = !hasPersonaModelOption( + effectiveModelOptions, + trimmedModel, + ); + + const modelSelectValue = getModelSelectValue({ + isCustomModelEditing, + isModelCustom, + model, + }); + + // The select is only disabled for mutation pending — never for missing discovery. + // Default/custom options remain usable regardless of discovery state. + const selectDisabled = disabled || modelDiscoveryLoading; + + // Show the custom model input whenever custom mode is active or the current + // model is already custom — not gated on discovery having returned. + const showCustomModelInput = isCustomModelEditing || isModelCustom; + + return ( +
+ + Model + + + {showCustomModelInput ? ( + onModelChange(event.target.value)} + placeholder="Custom model ID" + value={model} + /> + ) : null} +

+ {modelDiscoveryLoading + ? "Loading models..." + : modelDiscoveryStatus !== null + ? modelDiscoveryStatus.message + : discoveredModelOptions !== null + ? "Saved changes take effect on the next start." + : "Select a provider above to see available models."} +

+
+ ); +} + +export function AgentProviderField({ + disabled, + isCustomProviderEditing, + isRequired, + onProviderChange, + provider, + selectedRuntime, +}: { + disabled: boolean; + isCustomProviderEditing: boolean; + isRequired: boolean; + onProviderChange: (value: string) => void; + provider: string; + selectedRuntime: AcpRuntimeCatalogEntry | undefined; +}) { + const trimmedProvider = provider.trim(); + const providerOptions = getPersonaProviderOptions( + trimmedProvider, + selectedRuntime?.id ?? "", + ); + const providerSelectValue = isCustomProviderEditing + ? CUSTOM_PROVIDER_DROPDOWN_VALUE + : trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE; + + return ( +
+ + LLM provider + + + {isCustomProviderEditing ? ( + onProviderChange(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> + ) : null} +

+ Changing the provider updates the available model list immediately. +

+
+ ); +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 1a2018c92..2cfc3d220 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -36,6 +36,7 @@ import { KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { formatTime } from "@/features/messages/lib/dateFormatters"; // Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) // can exercise the exact same source the renderer uses. @@ -417,6 +418,7 @@ export function formatTimelineMessages( renderKey: event.localKey ?? event.id, createdAt: event.created_at, pubkey: authorPubkey, + signerPubkey: normalizePubkey(event.pubkey), author, avatarUrl: getAuthorAvatarUrl({ authorPubkey, diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index b98b1f249..fb07b7e31 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -17,6 +17,13 @@ export type TimelineMessage = { renderKey?: string; createdAt: number; pubkey?: string; + /** + * Raw signer pubkey (`event.pubkey`), normalized to lowercase hex. + * Distinct from `pubkey`, which is the resolved display-author and may be + * overridden by `actor` or `p` tags. Use this field — not `pubkey` — for + * security-sensitive checks such as authenticating config-nudge cards. + */ + signerPubkey?: string; author: string; avatarUrl?: string | null; role?: string; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4efec8a66..ac1cd9399 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -20,6 +20,7 @@ import { KIND_HUDDLE_STARTED, KIND_STREAM_MESSAGE_DIFF, } from "@/shared/constants/kinds"; +import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -323,6 +324,14 @@ export const MessageRow = React.memo( emojiOnly && "text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle", )} + // Only pass the author pubkey for agent-authored messages so + // config-nudge cards can authenticate the sender. Uses the + // raw event signer (signerPubkey) — not the tag-attributed + // display author — to prevent actor/p-tag spoofing. + configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey( + message, + resolvedAgentPubkeys, + )} content={message.body} customEmoji={customEmoji} imetaByUrl={imetaByUrl} diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs b/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs new file mode 100644 index 000000000..10a7bd69a --- /dev/null +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs @@ -0,0 +1,130 @@ +/** + * Integration-seam tests for getConfigNudgeAuthorPubkey. + * + * These tests exercise the exact field-selection seam that was broken: + * formatTimelineMessages retains signerPubkey = raw event.pubkey while + * resolving message.pubkey to the tag-attributed author. A test that + * passes the author pubkey by hand (as the previous regression did) would + * not catch a regression that reverts MessageRow to using message.pubkey + * (the spoofable field) instead of signerPubkey. + * + * By constructing a real TimelineMessage via formatTimelineMessages and + * passing it to getConfigNudgeAuthorPubkey — the same helper MessageRow + * calls — we lock the actual seam. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatTimelineMessages } from "../lib/formatTimelineMessages.ts"; +import { getConfigNudgeAuthorPubkey } from "./configNudgeAuthPubkey.ts"; + +const CHANNEL_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +// The raw event signer is a human (not an agent). +const HUMAN_SIGNER = + "1111111111111111111111111111111111111111111111111111111111111111"; +// The attributed agent pubkey (appears in actor/p tag). +const AGENT_PUBKEY = + "2222222222222222222222222222222222222222222222222222222222222222"; + +const AGENT_PUBKEYS = new Set([AGENT_PUBKEY]); + +function makeEvent(overrides = {}) { + return { + id: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + pubkey: HUMAN_SIGNER, + kind: 9, + created_at: 1_700_000_000, + content: "**Fizz** needs configuration.\n\n```buzz:config-nudge\n{}\n```", + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +function format(event) { + return formatTimelineMessages([event], null, undefined, null); +} + +// ── Spoof-regression test ───────────────────────────────────────────────────── +// +// A human-signed kind:9 event carries an `actor` tag attributing it to the +// agent. formatTimelineMessages resolves message.pubkey = agent (via the +// actor tag) but retains signerPubkey = human (from raw event.pubkey). +// getConfigNudgeAuthorPubkey MUST return undefined — the human signer is +// not in the known-agent set, so the card must not render. + +test("signerIsHuman_actorTagAttributedToAgent_returnsUndefined", () => { + const event = makeEvent({ + pubkey: HUMAN_SIGNER, + tags: [ + ["h", CHANNEL_ID], + ["actor", AGENT_PUBKEY], + ], + }); + + const [msg] = format(event); + + // Verify the seam: pubkey resolves to agent (tag-attributed), signerPubkey + // is human (raw signer). If these are equal the test loses its meaning. + assert.equal( + msg.pubkey?.toLowerCase(), + AGENT_PUBKEY, + "attributed pubkey must be the agent (actor tag wins in resolveEventAuthorPubkey)", + ); + assert.notEqual( + msg.signerPubkey, + AGENT_PUBKEY, + "signerPubkey must NOT be the agent (event was signed by human)", + ); + + // The guard must reject: signer is human, not in AGENT_PUBKEYS. + assert.equal( + getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + undefined, + "human signer with actor-tag attribution to agent must NOT enable the card", + ); +}); + +// ── Positive case ───────────────────────────────────────────────────────────── +// +// A genuine kind:9 signed by the agent itself: getConfigNudgeAuthorPubkey +// must return the agent pubkey so MessageRow enables the card. + +test("signerIsAgent_genuine_returnsAgentPubkey", () => { + const event = makeEvent({ pubkey: AGENT_PUBKEY }); + + const [msg] = format(event); + + assert.equal( + msg.signerPubkey, + AGENT_PUBKEY, + "signerPubkey must be the agent when the event is signed by the agent", + ); + + assert.equal( + getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + AGENT_PUBKEY, + "genuine agent-signed kind:9 must enable the card", + ); +}); + +// ── Non-kind:9 is always excluded ───────────────────────────────────────────── +// +// KIND_STREAM_MESSAGE_V2 (40002) is a valid timeline-content kind but is NOT +// kind:9 — the helper must return undefined even when the signer is a known +// agent, because the config-nudge sentinel is only emitted by the setup-listener +// on kind:9 (KIND_STREAM_MESSAGE) events. + +test("nonKind9_agentSigner_returnsUndefined", () => { + // kind 40002 = KIND_STREAM_MESSAGE_V2: a valid timeline event, not kind:9. + const event = makeEvent({ pubkey: AGENT_PUBKEY, kind: 40002 }); + + const [msg] = format(event); + + assert.equal( + getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + undefined, + "non-kind:9 events must never enable the card even if signer is known agent", + ); +}); diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts new file mode 100644 index 000000000..65582f965 --- /dev/null +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts @@ -0,0 +1,31 @@ +import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; +import type { TimelineMessage } from "@/features/messages/types"; + +/** + * Returns the pubkey to use as `configNudgeAuthorPubkey` for a given message, + * or `undefined` when the config-nudge card path should be disabled. + * + * The card is enabled ONLY when: + * 1. `message.kind === KIND_STREAM_MESSAGE` — restricts to the setup-listener + * wire format. + * 2. `message.signerPubkey` is set and is a known agent — authenticates + * against the raw event signer (NOT `message.pubkey`, which is the + * tag-attributed display author and can be spoofed via `actor`/`p` tags). + * + * Extracting this predicate as a pure helper lets tests exercise the exact + * signer-vs-attributed-author distinction with a real `TimelineMessage` from + * `formatTimelineMessages`, without a full React render harness. + */ +export function getConfigNudgeAuthorPubkey( + message: Pick, + resolvedAgentPubkeys: Set, +): string | undefined { + if ( + message.kind === KIND_STREAM_MESSAGE && + message.signerPubkey && + resolvedAgentPubkeys.has(message.signerPubkey) + ) { + return message.signerPubkey; + } + return undefined; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index ff854ef3a..80c2fd1ec 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -37,6 +37,10 @@ import { import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { describeLogFile } from "@/features/agents/ui/agentUi"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; +import { + consumePendingOpenEditAgent, + subscribeOpenEditAgent, +} from "@/features/agents/openEditAgentEvent"; import { duplicatePersonaDialogState, editPersonaDialogState, @@ -147,6 +151,21 @@ export function UserProfilePanel({ [onTabChange], ); const [editAgentOpen, setEditAgentOpen] = React.useState(false); + + // Open the Edit Agent dialog when `requestOpenEditAgent(pubkey)` fires from + // a card or other non-panel surface (e.g. `ConfigNudgeCard`). Mirrors the + // `subscribeOpenCreateAgent` pattern in AgentsView. + React.useEffect(() => { + if (!pubkey) return; + // Consume any pending request that arrived before this panel mounted. + if (consumePendingOpenEditAgent(pubkey)) { + setEditAgentOpen(true); + } + // Subscribe for events that arrive while the panel is mounted. + return subscribeOpenEditAgent(pubkey, () => { + setEditAgentOpen(true); + }); + }, [pubkey]); const [addToChannelOpen, setAddToChannelOpen] = React.useState(false); const [personaDialogState, setPersonaDialogState] = React.useState(null); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index d7128eebe..70f99f8ec 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1255,6 +1255,32 @@ export async function putAgentSessionConfig( return invokeTauri("put_agent_session_config", { pubkey, payload }); } +/** File-layer config for a runtime (e.g. `~/.config/goose/config.yaml`). */ +export type RuntimeFileConfigSubset = { + /** Provider set in the harness config file. */ + provider: string | null; + /** Model set in the harness config file. */ + model: string | null; + /** Credential env key names whose values are present in the file config. */ + satisfiedEnvKeys: string[]; +}; + +/** + * Get the file-layer config for a runtime so dialogs can show + * "Set in goose config" instead of surfacing a false required-field marker. + * Returns `null` when the runtime has no config file or it cannot be parsed. + */ +export async function getRuntimeFileConfig( + runtimeId: string, +): Promise { + return invokeTauri( + "get_runtime_file_config", + { + runtimeId, + }, + ); +} + type RawUpdateManagedAgentResponse = { agent: RawManagedAgent; profile_sync_error: string | null; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index c5e4b9e80..e2a7da326 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -385,6 +385,7 @@ export type CreateManagedAgentInput = { systemPrompt?: string; avatarUrl?: string; model?: string; + provider?: string; mcpToolsets?: string; envVars?: Record; spawnAfterCreate?: boolean; diff --git a/desktop/src/shared/lib/computeConfigNudge.ts b/desktop/src/shared/lib/computeConfigNudge.ts new file mode 100644 index 000000000..25040713d --- /dev/null +++ b/desktop/src/shared/lib/computeConfigNudge.ts @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; +import type { ConfigNudgePayload } from "@/shared/lib/configNudge"; +import { extractConfigNudge } from "@/shared/lib/configNudge"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Pure helper that computes the active `ConfigNudgePayload` for a message body. + * + * Called by `MarkdownInner` inside a `useMemo`; when the return value is + * non-null the markdown prose node is suppressed (via `selectProseOrNudge`) + * and replaced by `ConfigNudgeCard`. + * + * Extracted into its own module so it can be imported and tested without + * pulling in `markdown.tsx`'s heavy dependency chain (Tauri, emoji-mart, etc.). + */ +export function computeConfigNudge( + content: string, + interactive: boolean, + configNudgeAuthorPubkey: string | undefined | null, +): ConfigNudgePayload | null { + if (!interactive || !configNudgeAuthorPubkey) return null; + const payload = extractConfigNudge(content); + if (payload === null) return null; + if ( + normalizePubkey(payload.agent_pubkey) !== + normalizePubkey(configNudgeAuthorPubkey) + ) { + return null; + } + return payload; +} + +/** + * Returns `markdownNode` when no trusted config-nudge payload is present, + * or `null` when the card should render instead. + * + * This is the single production copy of the prose-suppression branch. + * `MarkdownInner` calls this instead of an inline ternary so the test suite + * can import and exercise the exact same branch, making a suppression revert + * observable at unit-test time. + */ +export function selectProseOrNudge( + configNudge: ConfigNudgePayload | null, + markdownNode: ReactNode, +): ReactNode { + return configNudge === null ? markdownNode : null; +} diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs new file mode 100644 index 000000000..adee297f7 --- /dev/null +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { extractConfigNudge, stripConfigNudgeSentinel } from "./configNudge.ts"; + +// Helper: build a fenced sentinel body containing the given payload. +function withSentinel(prose, payload) { + return `${prose}\n\n\`\`\`buzz:config-nudge\n${JSON.stringify(payload)}\n\`\`\``; +} + +const FIZZ_PUBKEY = "aabbccddeeff0011"; +const ATLAS_PUBKEY = "ddeeff00112233aa"; +const CODEX_PUBKEY = "112233aabbccddee"; + +// ── extractConfigNudge ──────────────────────────────────────────────────────── + +test("extractConfigNudge returns null when no sentinel present", () => { + assert.equal( + extractConfigNudge("**Fizz** needs configuration before it can respond."), + null, + ); +}); + +test("extractConfigNudge returns null for empty string", () => { + assert.equal(extractConfigNudge(""), null); +}); + +test("extractConfigNudge parses env_key requirement", () => { + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }; + const content = [ + "**Fizz** needs configuration before it can respond:", + "- set `ANTHROPIC_API_KEY` in Edit Agent → Environment variables", + "", + "Open Edit Agent in the Buzz app to set these.", + "", + "```buzz:config-nudge", + JSON.stringify(payload), + "```", + ].join("\n"); + + assert.deepEqual(extractConfigNudge(content), payload); +}); + +test("extractConfigNudge parses normalized_field requirement", () => { + const payload = { + agent_name: "Atlas", + agent_pubkey: ATLAS_PUBKEY, + requirements: [{ surface: "normalized_field", field: "provider" }], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +test("extractConfigNudge parses cli_login requirement", () => { + const payload = { + agent_name: "Codex", + agent_pubkey: CODEX_PUBKEY, + requirements: [ + { + surface: "cli_login", + probe_args: ["codex", "login", "status"], + setup_copy: "run `codex login --with-api-key`", + }, + ], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +test("extractConfigNudge parses multiple requirements of mixed types", () => { + const payload = { + agent_name: "Atlas", + agent_pubkey: ATLAS_PUBKEY, + requirements: [ + { surface: "normalized_field", field: "model" }, + { surface: "env_key", key: "OPENAI_API_KEY" }, + { + surface: "cli_login", + probe_args: ["codex", "login"], + setup_copy: "run `codex login`", + }, + ], + }; + const result = extractConfigNudge(withSentinel("prose", payload)); + assert.equal(result?.requirements.length, 3); + assert.equal(result?.agent_name, "Atlas"); + assert.equal(result?.agent_pubkey, ATLAS_PUBKEY); +}); + +test("extractConfigNudge returns null for malformed JSON", () => { + const content = "prose\n\n```buzz:config-nudge\nnot{valid}json\n```"; + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when JSON is valid but missing agent_name", () => { + const content = withSentinel("prose", { + agent_pubkey: FIZZ_PUBKEY, + requirements: [], + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when JSON is valid but missing agent_pubkey", () => { + const content = withSentinel("prose", { + agent_name: "Fizz", + requirements: [], + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when requirements contain unknown surface", () => { + const content = withSentinel("prose", { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [{ surface: "unknown_surface", data: "x" }], + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge returns null when requirements is not an array", () => { + const content = withSentinel("prose", { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: "bad", + }); + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge ignores regular code blocks with other language tags", () => { + const content = 'prose\n\n```json\n{"key":"val"}\n```'; + assert.equal(extractConfigNudge(content), null); +}); + +test("extractConfigNudge handles empty requirements array", () => { + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +// ── stripConfigNudgeSentinel ────────────────────────────────────────────────── + +test("stripConfigNudgeSentinel returns content unchanged when no sentinel", () => { + const content = "plain message body"; + assert.equal(stripConfigNudgeSentinel(content), content); +}); + +test("stripConfigNudgeSentinel strips the sentinel block", () => { + const prose = "**Fizz** needs configuration.\n\nOpen Edit Agent."; + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [], + }; + const content = withSentinel(prose, payload); + const stripped = stripConfigNudgeSentinel(content); + assert.ok(!stripped.includes("buzz:config-nudge"), "sentinel must be gone"); + assert.ok(stripped.includes("needs configuration"), "prose must survive"); +}); + +test("stripConfigNudgeSentinel removes preceding blank line", () => { + const content = "prose\n\n```buzz:config-nudge\n{}\n```"; + const stripped = stripConfigNudgeSentinel(content); + // Should not end with multiple newlines — the blank line separator was eaten. + assert.ok(!stripped.endsWith("\n\n"), "trailing blank line must be trimmed"); +}); + +// ── Auth-gate invariants (Fix 1 — authenticate before rendering) ────────────── +// +// The full auth check lives in MarkdownInner's useMemo: it calls +// normalizePubkey(payload.agent_pubkey) !== normalizePubkey(configNudgeAuthorPubkey) +// and returns null when they don't match. The tests below verify: +// (a) extractConfigNudge still returns the payload regardless of the caller +// (extraction is pure; auth is responsibility of the renderer), +// (b) when agent_pubkey doesn't match the author, the comparison yields null +// (simulated inline to keep the test self-contained without React). + +import { normalizePubkey } from "./pubkey.ts"; + +/** + * Simulate the auth guard in MarkdownInner's configNudge useMemo. + * Returns the payload if it passes auth, null otherwise. + */ +function authGuardedExtract(content, configNudgeAuthorPubkey) { + if (!configNudgeAuthorPubkey) return null; + const payload = extractConfigNudge(content); + if (payload === null) return null; + if ( + normalizePubkey(payload.agent_pubkey) !== + normalizePubkey(configNudgeAuthorPubkey) + ) { + return null; + } + return payload; +} + +const FIZZ_PUBKEY_AUTH = "aabbccddeeff0011223344556677889900aabbcc"; +const OTHER_PUBKEY = "ffffffffffffffffffffffffffffffffffffffff"; + +function makeNudgeBody(agentPubkey) { + const payload = { + agent_name: "Fizz", + agent_pubkey: agentPubkey, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }; + return `**Fizz** needs configuration.\n\n\`\`\`buzz:config-nudge\n${JSON.stringify(payload)}\n\`\`\``; +} + +test("authGuard_noAuthorPubkey_returnsNull", () => { + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + assert.equal( + authGuardedExtract(body, null), + null, + "null configNudgeAuthorPubkey must yield null (card path off)", + ); +}); + +test("authGuard_undefinedAuthorPubkey_returnsNull", () => { + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + assert.equal( + authGuardedExtract(body, undefined), + null, + "undefined configNudgeAuthorPubkey must yield null (card path off)", + ); +}); + +test("authGuard_mismatchedAuthor_returnsNull", () => { + // Fence carries FIZZ_PUBKEY_AUTH but caller says the message author is OTHER_PUBKEY. + // The card must not render and the fence must NOT be stripped by the caller. + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + const result = authGuardedExtract(body, OTHER_PUBKEY); + assert.equal( + result, + null, + "mismatched agent_pubkey vs configNudgeAuthorPubkey must yield null", + ); + // Fence text must still be in the raw body (not stripped) — stripping only + // happens when configNudge !== null. + assert.ok( + body.includes("buzz:config-nudge"), + "fence must remain in body when auth guard returns null", + ); +}); + +test("authGuard_matchingAuthor_returnsPayload", () => { + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH); + const result = authGuardedExtract(body, FIZZ_PUBKEY_AUTH); + assert.notEqual(result, null, "matching author must yield the payload"); + assert.equal(result?.agent_pubkey, FIZZ_PUBKEY_AUTH); +}); + +test("authGuard_matchingAuthor_caseInsensitive", () => { + // normalizePubkey lowercases both sides; mixed-case must still match. + const body = makeNudgeBody(FIZZ_PUBKEY_AUTH.toUpperCase()); + const result = authGuardedExtract(body, FIZZ_PUBKEY_AUTH.toLowerCase()); + assert.notEqual( + result, + null, + "case-insensitive pubkey comparison must pass auth", + ); +}); + +// ── Signer-vs-tag-attribution regression ───────────────────────────────────── +// +// Pass 2 finding: message.pubkey (display author) can be overridden by actor/p +// tags, so a human-signed event can carry the agent pubkey as its attributed +// author. The auth guard must check the RAW EVENT SIGNER (signerPubkey from +// formatTimelineMessages), not the display author. +// +// We simulate this by passing the HUMAN pubkey as configNudgeAuthorPubkey +// while the payload's agent_pubkey is the AGENT pubkey — as MessageRow now +// does (passes signerPubkey, not pubkey). +// +// If the guard were still using the tag-attributed pubkey (display author = +// agent pubkey), it would pass auth and return the payload — a forged card. +// With the signer check, the human signer != agent payload key → null. + +const HUMAN_PUBKEY = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; +const AGENT_PUBKEY_FOR_SPOOF = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; + +test("authGuard_signerIsHuman_tagAttributedToAgent_returnsNull", () => { + // The event was signed by HUMAN_PUBKEY but its `actor` or first `p` tag + // attributes it to AGENT_PUBKEY_FOR_SPOOF (display author = agent). + // The payload's agent_pubkey matches the ATTRIBUTED pubkey, not the signer. + // MessageRow now passes signerPubkey (HUMAN) as configNudgeAuthorPubkey; + // the auth guard must reject since HUMAN != AGENT. + const body = makeNudgeBody(AGENT_PUBKEY_FOR_SPOOF); + + // Simulate what MessageRow does: pass the RAW SIGNER, not the display author. + const result = authGuardedExtract(body, HUMAN_PUBKEY); + assert.equal( + result, + null, + "raw signer is human; even if actor-tag attributes to agent, must yield null", + ); + // Fence must remain — not stripped — when auth fails. + assert.ok( + body.includes("buzz:config-nudge"), + "fence must remain in body when signer auth fails", + ); +}); + +// ── Render-suppression contract ─────────────────────────────────────────────── +// +// When `extractConfigNudge` returns a non-null payload, `markdown.tsx` MUST +// suppress the prose markdown node and render only the card: +// +// {configNudge === null ? markdownNode : null} +// +// This test pins that contract at the parse level. A revert to `{markdownNode}` +// (rendering both prose and card) would violate the invariants checked here: +// the prose text is still in the raw wire body and would re-appear alongside +// the card. If you break these assertions by changing `markdown.tsx` to render +// both, these tests must be updated AND the visual duplication re-approved. + +test("nudgePresent_extractNonNull_and_stripRemovesSentinel", () => { + const prose = + "**Fizz** needs configuration before it can respond:\n- set `ANTHROPIC_API_KEY` in Edit Agent → Environment variables\n\nOpen Edit Agent in the Buzz app to set these."; + const payload = { + agent_name: "Fizz", + agent_pubkey: FIZZ_PUBKEY, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }; + const body = withSentinel(prose, payload); + + // 1. extractConfigNudge sees the sentinel → card renders, prose must be + // suppressed. If this returns null the card never renders at all. + const nudge = extractConfigNudge(body); + assert.notEqual( + nudge, + null, + "sentinel present → extractConfigNudge must return payload", + ); + + // 2. stripConfigNudgeSentinel removes the fence so whatever markdown node + // IS rendered doesn't also show the raw JSON block. The stripped string + // must NOT contain the sentinel open-fence marker. + const stripped = stripConfigNudgeSentinel(body); + assert.ok( + !stripped.includes("buzz:config-nudge"), + "stripped content must not contain the fence marker", + ); + + // 3. The prose IS still in the stripped string — that is intentional: the + // stripped string is what non-card clients display. On desktop the prose + // is suppressed by the `configNudge === null ? markdownNode : null` guard + // in markdown.tsx, NOT by stripping it from the string. If a future + // change strips the prose from the string here, that would break CLI + // fallback — this assertion guards against that regression too. + assert.ok( + stripped.includes("ANTHROPIC_API_KEY"), + "prose content must remain in stripped string for non-card client fallback", + ); +}); + +test("nudgeAbsent_extractNull_markdownNodeShown", () => { + // When there is no sentinel, extractConfigNudge returns null, meaning + // markdown.tsx renders `markdownNode` normally (no card, no suppression). + const plainBody = "Hello from an agent without any configuration sentinel."; + assert.equal( + extractConfigNudge(plainBody), + null, + "no sentinel → extractConfigNudge must return null so markdownNode is shown", + ); + // stripConfigNudgeSentinel on sentinel-free content is a no-op. + assert.equal( + stripConfigNudgeSentinel(plainBody), + plainBody, + "no sentinel → stripConfigNudgeSentinel must return content unchanged", + ); +}); diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts new file mode 100644 index 000000000..627e0f6e8 --- /dev/null +++ b/desktop/src/shared/lib/configNudge.ts @@ -0,0 +1,133 @@ +/** + * Utilities for extracting and parsing the `buzz:config-nudge` sentinel that + * `buzz-acp`'s setup-listener appends to its kind:9 nudge body. + * + * Wire format (appended by `setup_mode.rs::nudge_body()`): + * + * ``` + * ```buzz:config-nudge + * {"agent_name":"…","agent_pubkey":"…","requirements":[…]} + * ``` + * ``` + * + * The prose above the fence is left untouched and used as a plaintext + * fallback for non-card clients. The desktop detects the sentinel here, + * strips it from the displayed markdown, and renders a `ConfigNudgeCard`. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** A single missing requirement — mirrors the Rust `RequirementPayload` enum. */ +export type ConfigNudgeRequirement = + | { surface: "normalized_field"; field: string } + | { surface: "env_key"; key: string } + | { + surface: "cli_login"; + probe_args: string[]; + setup_copy: string; + }; + +/** + * The structured payload embedded in the `buzz:config-nudge` sentinel block. + * Mirrors the Rust `SetupPayload` struct. + */ +export type ConfigNudgePayload = { + agent_name: string; + /** Hex-encoded agent pubkey. Used by the desktop card action to open Edit Agent. */ + agent_pubkey: string; + requirements: ConfigNudgeRequirement[]; +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const FENCE_OPEN = "```buzz:config-nudge"; +const FENCE_CLOSE = "```"; + +// ── Extractor ───────────────────────────────────────────────────────────────── + +/** + * Extract the `ConfigNudgePayload` from a message body, if present. + * + * Returns `null` when: + * - the sentinel fence is absent + * - the JSON inside is malformed + * - the parsed value doesn't match the expected shape + * + * Never throws — all errors are swallowed so this is safe to call in the + * render path. + */ +export function extractConfigNudge(content: string): ConfigNudgePayload | null { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return null; + + // The JSON starts on the line after the opening fence. + const jsonStart = content.indexOf("\n", openIdx); + if (jsonStart === -1) return null; + + // The JSON ends at the next closing ``` that appears on its own line. + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); + if (closeIdx === -1) return null; + + const json = content.slice(jsonStart + 1, closeIdx).trim(); + if (!json) return null; + + try { + const parsed: unknown = JSON.parse(json); + return isConfigNudgePayload(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** + * Strip the `buzz:config-nudge` sentinel block (and any preceding blank line) + * from a message body. Returns the original string unchanged when no sentinel + * is present. + * + * Used so the prose fallback is rendered without the raw code block. + */ +export function stripConfigNudgeSentinel(content: string): string { + const openIdx = content.indexOf(FENCE_OPEN); + if (openIdx === -1) return content; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, openIdx); + if (closeIdx === -1) return content; + + const afterFence = closeIdx + `\n${FENCE_CLOSE}`.length; + // Trim a preceding blank line so the prose doesn't gain a trailing gap. + const prose = content.slice(0, openIdx).replace(/\n{2,}$/, "\n"); + return prose + content.slice(afterFence); +} + +// ── Type-guard ───────────────────────────────────────────────────────────────── + +function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + if (typeof r.surface !== "string") return false; + switch (r.surface) { + case "normalized_field": + return typeof r.field === "string"; + case "env_key": + return typeof r.key === "string"; + case "cli_login": + return ( + Array.isArray(r.probe_args) && + r.probe_args.every((a) => typeof a === "string") && + typeof r.setup_copy === "string" + ); + default: + return false; + } +} + +function isConfigNudgePayload(v: unknown): v is ConfigNudgePayload { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + return ( + typeof p.agent_name === "string" && + typeof p.agent_pubkey === "string" && + Array.isArray(p.requirements) && + p.requirements.every(isConfigNudgeRequirement) + ); +} diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx new file mode 100644 index 000000000..767a83417 --- /dev/null +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -0,0 +1,125 @@ +import { AlertTriangle } from "lucide-react"; + +import { requestOpenEditAgent } from "@/features/agents/openEditAgentEvent"; +import type { ConfigNudgePayload } from "@/shared/lib/configNudge"; +import { cn } from "@/shared/lib/cn"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; +import { + Attachment, + AttachmentActions, + AttachmentContent, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "@/shared/ui/attachment"; + +/** + * Stable key for a requirement row. The combination of surface + primary + * value uniquely identifies a requirement within a nudge payload. + * The fallback position index handles edge cases like two identical rows. + */ +function requirementKey( + req: ConfigNudgePayload["requirements"][number], + index: number, +): string { + switch (req.surface) { + case "env_key": + return `env_key:${req.key}:${index}`; + case "normalized_field": + return `normalized_field:${req.field}:${index}`; + case "cli_login": + return `cli_login:${req.probe_args.join(",")}:${index}`; + } +} + +/** + * Inline card rendered when the desktop detects a `buzz:config-nudge` + * sentinel in a kind:9 message body. + * + * Uses the `Attachment` primitive's built-in `state="error"` destructive-tint + * variant so it is visually distinct and consistent with other error states in + * the system. + * + * The card's trigger opens the Edit Agent dialog for the agent by: + * 1. Calling `openProfilePanel(pubkey)` (from ProfilePanelContext) to ensure + * the profile panel is visible. + * 2. Dispatching `requestOpenEditAgent(pubkey)` so that `UserProfilePanel` + * auto-opens the edit dialog once it mounts with that pubkey. + */ +export function ConfigNudgeCard({ + className, + nudge, +}: { + className?: string; + nudge: ConfigNudgePayload; +}) { + const { openProfilePanel } = useProfilePanel(); + + const handleOpen = () => { + openProfilePanel?.(nudge.agent_pubkey); + requestOpenEditAgent(nudge.agent_pubkey); + }; + + return ( + + + + + + {nudge.agent_name} needs configuration + +
+ {nudge.requirements.map((req, i) => ( + + ))} +
+
+ + + Edit Agent + + + +
+ ); +} + +function RequirementRow({ + requirement, +}: { + requirement: ConfigNudgePayload["requirements"][number]; +}) { + switch (requirement.surface) { + case "env_key": + return ( +
+ Set{" "} + + {requirement.key} + {" "} + in Edit Agent → Environment variables +
+ ); + case "normalized_field": + return ( +
+ Set the {requirement.field} field in Edit Agent + dropdowns +
+ ); + case "cli_login": + return ( +
+ {requirement.setup_copy} +
+ ); + } +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index ede8e86fe..9cae0bcbf 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -656,3 +656,130 @@ test("remarkMessageLinks: text inside inlineCode is left alone", () => { assert.equal(kids[0].type, "inlineCode"); assert.equal(kids[0].value, "buzz://message?channel=c&id=m"); }); + +// ── selectProseOrNudge render-level guard ───────────────────────────────────── +// +// `MarkdownInner` calls `selectProseOrNudge(configNudge, markdownNode)` — +// the single production copy of the prose-suppression branch, exported from +// `computeConfigNudge.ts`. These tests import and call that exact function +// through a minimal stub so a revert that changes its behavior is caught +// at unit-test time. +// +// (The full `Markdown` component cannot be rendered in this environment: +// emoji-mart JSON imports crash the module loader before React runs.) + +import { + computeConfigNudge, + selectProseOrNudge, +} from "../lib/computeConfigNudge.ts"; +import { stripConfigNudgeSentinel } from "../lib/configNudge.ts"; + +const AGENT_PUBKEY = + "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"; +const HUMAN_PUBKEY = + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + +function nudgeBody(agentPubkey) { + return [ + "**Fizz** needs configuration before it can respond:", + "- set `ANTHROPIC_API_KEY` in Edit Agent → Environment variables", + "", + "Open Edit Agent in the Buzz app to set these.", + "", + "```buzz:config-nudge", + JSON.stringify({ + agent_name: "Fizz", + agent_pubkey: agentPubkey, + requirements: [{ surface: "env_key", key: "ANTHROPIC_API_KEY" }], + }), + "```", + ].join("\n"); +} + +// Minimal wrapper that calls the real production functions from +// `computeConfigNudge.ts` — `computeConfigNudge` to detect the payload and +// `selectProseOrNudge` for the prose-suppression branch — without importing +// any Tauri or context dependencies. +function GuardStub({ content, configNudgeAuthorPubkey }) { + const configNudge = computeConfigNudge( + content, + true, + configNudgeAuthorPubkey, + ); + const stripped = + configNudge !== null ? stripConfigNudgeSentinel(content) : content; + const markdownNode = React.createElement( + "div", + { "data-markdown-prose": "" }, + stripped, + ); + return React.createElement( + "div", + null, + selectProseOrNudge(configNudge, markdownNode), + configNudge !== null + ? React.createElement("div", { "data-config-nudge": "" }) + : null, + ); +} + +test("nudgeGuard_sentinelPresentMatchingAuthor_cardRenderedProseAbsent", () => { + const body = nudgeBody(AGENT_PUBKEY); + const html = renderToStaticMarkup( + React.createElement(GuardStub, { + content: body, + configNudgeAuthorPubkey: AGENT_PUBKEY, + }), + ); + // Card placeholder rendered. + assert.ok( + html.includes("data-config-nudge"), + "data-config-nudge must be present when sentinel+author match", + ); + // Prose suppressed — the raw fallback text must NOT appear outside the card. + assert.ok( + !html.includes("needs configuration before it can respond"), + "prose must be absent when card renders", + ); + assert.ok( + !html.includes("data-markdown-prose"), + "markdownNode must be null (not rendered) when configNudge is non-null", + ); +}); + +test("nudgeGuard_sentinelPresentWrongAuthor_proseRenderedCardAbsent", () => { + // Sentinel present, but author pubkey is human — auth guard rejects, prose shown. + const body = nudgeBody(AGENT_PUBKEY); + const html = renderToStaticMarkup( + React.createElement(GuardStub, { + content: body, + configNudgeAuthorPubkey: HUMAN_PUBKEY, + }), + ); + assert.ok( + !html.includes("data-config-nudge"), + "card must be absent when author pubkey does not match sentinel agent_pubkey", + ); + assert.ok( + html.includes("data-markdown-prose"), + "markdownNode must render when configNudge is null (auth mismatch)", + ); +}); + +test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => { + const plain = "Hello, this is a normal message with no sentinel."; + const html = renderToStaticMarkup( + React.createElement(GuardStub, { + content: plain, + configNudgeAuthorPubkey: AGENT_PUBKEY, + }), + ); + assert.ok( + !html.includes("data-config-nudge"), + "card must be absent when no sentinel is present", + ); + assert.ok( + html.includes("data-markdown-prose"), + "markdownNode must render when no sentinel is present", + ); +}); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 365edd7aa..bf15f5190 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -37,8 +37,14 @@ import remarkMentions from "@/shared/lib/remarkMentions"; import remarkSpoilers from "@/shared/lib/remarkSpoilers"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import { AttachmentGroup } from "@/shared/ui/attachment"; +import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; +import { stripConfigNudgeSentinel } from "@/shared/lib/configNudge"; +import { + computeConfigNudge, + selectProseOrNudge, +} from "@/shared/lib/computeConfigNudge"; import { INLINE_CODE_CHIP_CLASS, MENTION_CHIP_BASE_CLASSES, @@ -1941,6 +1947,7 @@ function MarkdownInner({ channelNames, className, compact = false, + configNudgeAuthorPubkey, content, customEmoji, imetaByUrl, @@ -1982,6 +1989,10 @@ function MarkdownInner({ () => (interactive ? extractSupportedLinkPreviews(content) : []), [content, interactive], ); + const configNudge = React.useMemo( + () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), + [content, interactive, configNudgeAuthorPubkey], + ); const runtimeRef = useLatestRef({ agentMentionPubkeysByName, channels, @@ -2029,11 +2040,15 @@ function MarkdownInner({ let processedContent = content; - if (/^(?:\s{2}\n)+/.test(content)) { + if (configNudge !== null) { + processedContent = stripConfigNudgeSentinel(processedContent); + } + + if (/^(?:\s{2}\n)+/.test(processedContent)) { processedContent = `\u200B${processedContent}`; } - if (/(?:\s{2}\n)+$/.test(content)) { + if (/(?:\s{2}\n)+$/.test(processedContent)) { processedContent = `${processedContent}\u200B`; } @@ -2079,7 +2094,15 @@ function MarkdownInner({ )} > - {markdownNode} + {selectProseOrNudge(configNudge, markdownNode)} + {configNudge !== null ? ( + + + + ) : null} {resolvedLinkPreviews.length > 0 ? ( [0], diff --git a/desktop/tests/e2e/agent-readiness-screenshots.spec.ts b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts new file mode 100644 index 000000000..00d3dba11 --- /dev/null +++ b/desktop/tests/e2e/agent-readiness-screenshots.spec.ts @@ -0,0 +1,298 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/agent-readiness"; + +// An existing buzz-agent managed agent for the Edit-dialog shot. +// Tyler's pubkey maps to gooseSurface in the mock bridge (runtimeId: "goose"), +// which supports LLM provider selection — the shared AgentProviderField / +// AgentModelField components render for it just as they do for buzz-agent. +const EDIT_AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; + +/** + * Navigate to the agents view and open the Create Agent dialog via the + * "Custom agent" option, then fill a placeholder name. + */ +async function openCreateDialog(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByText("Custom agent").click(); + await page.getByTestId("agent-name-input").fill("Test Agent"); +} + +/** + * Wait for the provider field to become visible (buzz-agent auto-selected) + * then select the given provider value. + */ +async function selectProvider( + page: import("@playwright/test").Page, + provider: string, +) { + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-provider").selectOption(provider); +} + +/** + * Choose "Custom model..." from the model dropdown and fill a custom model id. + */ +async function setCustomModel( + page: import("@playwright/test").Page, + modelId: string, +) { + await page.locator("#agent-model").selectOption("__custom_model__"); + await page.getByLabel("Custom model ID").fill(modelId); +} + +/** + * Open the Edit Agent dialog for a seeded managed agent. + * Opens the agents view, clicks the agent card to open the profile panel, + * then clicks the Edit quick-action button. + */ +async function openEditDialog( + page: import("@playwright/test").Page, + agentName: string, +) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + + const agentButton = page.getByRole("button", { + name: `${agentName} agent profile`, + }); + await expect(agentButton).toBeVisible({ timeout: 10_000 }); + await agentButton.click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("user-profile-edit-agent").click(); + + // Wait for the Edit dialog's provider field (goose runtime supports it). + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); +} + +// Settle any in-flight CSS / Web Animations before capture. +async function settleAnimations(page: import("@playwright/test").Page) { + await page.evaluate(() => + Promise.all(document.getAnimations().map((a) => a.finished)), + ); +} + +test.describe("agent readiness gate screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + page.on("pageerror", (err) => { + console.error( + "PAGE ERROR:", + err.message, + err.stack?.split("\n").slice(0, 5).join("\n"), + ); + }); + }); + + // Shot 01: buzz-agent selected, provider empty → required marker shown, save allowed. + test("01-create-buzzagent-empty-provider-marker", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + + // Wait for buzz-agent to auto-select and the provider field to render. + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + + // Provider empty → required marker shown; submit is now ENABLED. + // Wait up to 10 s for prereqsQuery to resolve (async even in mock env). + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/01-create-buzzagent-empty-provider-marker.png`, + }); + }); + + // Shot 02: buzz-agent + anthropic selected, model empty → required marker shown, save allowed. + test("02-create-buzzagent-empty-model-marker", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + await selectProvider(page, "anthropic"); + + // Model still empty → required marker shown; submit is now ENABLED. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/02-create-buzzagent-empty-model-marker.png`, + }); + }); + + // Shot 03: buzz-agent + anthropic + model set, ANTHROPIC_API_KEY missing → + // amber required row names the key, submit ENABLED (no longer blocked). + test("03-create-missing-credential-row", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + await selectProvider(page, "anthropic"); + await setCustomModel(page, "claude-opus-4-5"); + + // Required row should name ANTHROPIC_API_KEY; submit is now ENABLED. + await expect(page.getByTestId("env-vars-required-key")).toHaveText( + "ANTHROPIC_API_KEY", + ); + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + + // Scroll the required row into view so it is visible in the screenshot. + await page.getByTestId("env-vars-required-key").scrollIntoViewIfNeeded(); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/03-create-missing-credential-row.png`, + }); + }); + + // Shot 04: all required fields satisfied → Create button enabled. + test("04-create-all-required-satisfied-enabled", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + await selectProvider(page, "anthropic"); + await setCustomModel(page, "claude-opus-4-5"); + await page + .getByTestId("env-vars-required-value") + .fill("sk-test-api-key-for-e2e"); + + // All required fields satisfied → submit enabled. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 5_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/04-create-all-required-satisfied-enabled.png`, + }); + }); + + // Shot 05: claude runtime (CLI-login) — provider/model not required, submit enabled. + // Override the catalog to make claude fully available so it appears in the dropdown. + test("05-create-cli-login-runtime-no-provider-required", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/usr/local/bin/buzz-agent", + default_args: [], + mcp_command: "buzz-dev-mcp", + install_hint: "Ships with the Buzz desktop app.", + install_instructions_url: "https://github.com/block/buzz", + can_auto_install: false, + underlying_cli_path: null, + }, + { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "available", + command: "/usr/local/bin/claude-agent", + binary_path: "/usr/local/bin/claude-agent", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install the Claude Code ACP adapter via npm.", + install_instructions_url: + "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp", + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", + }, + ], + }); + + await openCreateDialog(page); + + // Wait for buzz-agent to auto-select (provider field visible), then + // switch to claude. + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-runtime").selectOption("claude"); + + // Provider/model fields hidden for CLI-login runtimes. + await expect(page.locator("#agent-provider")).not.toBeVisible(); + // Submit enabled without provider/model. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 5_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/05-create-cli-login-runtime-no-provider-required.png`, + }); + }); + + // Shot 07: Edit dialog for an existing managed agent (goose runtime) showing + // the shared AgentProviderField / AgentModelField extraction. + test("07-edit-dialog-extracted-fields", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: EDIT_AGENT_PUBKEY, + name: "Tyler Agent", + status: "stopped" as const, + channelNames: ["agents"], + }, + ], + }); + + await openEditDialog(page, "Tyler Agent"); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/07-edit-dialog-extracted-fields.png`, + }); + }); + + // Shot 08: goose runtime, provider empty → required marker shown, save allowed (same as buzz-agent). + test("08-create-goose-empty-provider-marker", async ({ page }) => { + await installMockBridge(page); + await openCreateDialog(page); + + // Buzz-agent auto-selects first; wait for its provider field, then + // switch to goose to confirm its required-marker behavior is identical. + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-runtime").selectOption("goose"); + + // Provider field still visible for goose (also a provider-selection runtime). + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 5_000, + }); + // Required marker shown; submit is now ENABLED. + await expect(page.getByTestId("create-agent-submit")).toBeEnabled({ + timeout: 10_000, + }); + await settleAnimations(page); + + const dialog = page.getByRole("dialog"); + await dialog.screenshot({ + path: `${SHOTS}/08-create-goose-empty-provider-marker.png`, + }); + }); +}); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d504dd17f..2c1f9ae1d 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -122,6 +122,22 @@ test("create agent supports parallelism and system prompt overrides", async ({ await page.getByText("Custom agent").click(); await page.getByTestId("agent-name-input").fill(agentName); + + // The buzz-agent runtime requires provider + model selection. Wait for the + // provider field to appear (it renders once the ACP runtime catalog loads + // and the auto-select effect resolves to buzz-agent). + await expect(page.locator("#agent-provider")).toBeVisible({ + timeout: 10_000, + }); + await page.locator("#agent-provider").selectOption("anthropic"); + await page.locator("#agent-model").selectOption("__custom_model__"); + await page.getByLabel("Custom model ID").fill("claude-opus-4-5"); + // Supply a credential so the agent can actually run (create is no longer + // blocked by a missing key, but we include it for a realistic test). + await page + .getByTestId("env-vars-required-value") + .fill("sk-test-api-key-for-e2e"); + await page.getByRole("button", { name: "Advanced setup" }).click(); await page.getByTestId("agent-parallelism-input").fill("3"); await page