diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md index 4b187f355a5..e84760d1cfb 100644 --- a/VISION_REMOTE_AGENTS.md +++ b/VISION_REMOTE_AGENTS.md @@ -20,7 +20,7 @@ So a remote agent's return is a resurrection, not a rebirth: fresh compute, same Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate. -Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance. +Buzz's answer is an axiom: **after creation, the desktop retains no substrate control channel.** A deploy-only provider receives one launch payload. A registry provider instead advertises `register` and `attest` together: it creates the agent identity in its own custody, returns only the public key, then accepts the owner's narrowly scoped NIP-OA authorization. Buzz never receives that agent's private key. In both cases the desktop resolves the provider through one narrow path, stages exact artifacts, and refuses a protocol version it does not understand. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, and the controller supplies disposable bodies for the identity it holds. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote. @@ -28,7 +28,7 @@ This is not asceticism. It is what makes the body replaceable. A management plan ## Bodies Are Replaceable -Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately. +Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. Capabilities make custody explicit. A deploy-only provider is trusted with a Desktop-created key; a `register`+`attest` provider creates and retains the key and proves ownership authorization without turning Buzz into a general-purpose signing service. Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)). @@ -48,7 +48,7 @@ Remote agents solve it from the inside. Because the desktop retains no substrate **You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work. -**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide. +**Key custody is a decision.** A deploy-only provider receives a Desktop-created key. A registry provider creates the key beyond the Desktop boundary and returns only its public half; the owner signs a NIP-OA authorization for that exact identity. Either mode still trusts the provider's substrate with the agent identity. The capability negotiation makes that trust visible and prevents a missing local key from being mistaken for deliberate provider custody. **No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did. diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 093e925f18a..b353881ffe4 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -74,6 +74,7 @@ fn agent_record() -> ManagedAgentRecord { name: "Agent".to_string(), persona_id: Some("persona-1".to_string()), private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -94,6 +95,7 @@ fn agent_record() -> ManagedAgentRecord { backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index 178ec0bb6d6..e26ca4b4442 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,4 +1,6 @@ -use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; +use crate::managed_agents::{ + discover_provider_candidates, probe_provider_info, BackendProviderInfo, +}; #[tauri::command] pub async fn discover_backend_providers() -> Result, String> { @@ -32,15 +34,7 @@ pub async fn probe_backend_provider(binary_path: String) -> Result, ) -> Result { + // Snapshot relay and owner under the workspace transaction fence before + // provider negotiation or registration can await. A workspace switch + // writes those values separately; reading without the fence could pair one + // community with another community's owner authorization. + let (attestation_relay, owner_keys) = { + let _workspace_guard = state.workspace_apply_lock.lock().await; + ( + crate::relay::bind_expected_relay_scope(None, relay_ws_url_with_override(&state))?, + state.signing_keys()?, + ) + }; let name = input.name.trim().to_string(); let requested_persona_id = input .persona_id @@ -376,6 +387,28 @@ pub async fn create_managed_agent( ); } + // Negotiate identity custody before minting any key material. Existing + // deploy-only providers retain the legacy Desktop-custodied path. + let provider_custody = if let BackendKind::Provider { ref config, ref id } = input.backend { + validate_provider_config(config)?; + resolve_provider_binary(id)?; + let uses_registration = provider_registration::uses_registration(id).await?; + Some(provider_registration::require_expected_custody( + input.expected_key_custody, + uses_registration, + )?) + } else { + if input.expected_key_custody.is_some() { + return Err("expectedKeyCustody is only valid for provider backends".to_string()); + } + None + }; + // The opaque token above is the compile-time binding between custody + // validation and the command's key-minting / registration side effects. + let provider_uses_registration = provider_custody + .as_ref() + .is_some_and(|custody| custody.uses_registration()); + // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { let _store_guard = state @@ -400,15 +433,20 @@ pub async fn create_managed_agent( let personas = load_personas(&app)?; ensure_persona_is_active(&personas, persona_id)?; } - let keys = Keys::generate(); - let pubkey = keys.public_key().to_hex(); - if records.iter().any(|record| record.pubkey == pubkey) { - return Err(format!("agent {pubkey} already exists")); - } - let private_key_nsec = keys - .secret_key() - .to_bech32() - .map_err(|error| format!("failed to encode private key: {error}"))?; + let (keys, private_key_nsec, pubkey) = if provider_uses_registration { + (None, String::new(), None) + } else { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + if records.iter().any(|record| record.pubkey == pubkey) { + return Err(format!("agent {pubkey} already exists")); + } + let private_key_nsec = keys + .secret_key() + .to_bech32() + .map_err(|error| format!("failed to encode private key: {error}"))?; + (Some(keys), private_key_nsec, Some(pubkey)) + }; // Store the relay override exactly as supplied (trimmed). An explicit // value pins the agent; empty stays empty and resolves to the active @@ -423,24 +461,78 @@ pub async fn create_managed_agent( (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; - // ── Pre-Phase 2: validate provider config BEFORE any side effects ──────── - if let BackendKind::Provider { ref config, ref id } = input.backend { - validate_provider_config(config)?; - // Validate via discovered candidates — not raw resolve_command. - resolve_provider_binary(id)?; + let relay_mesh = normalize_relay_mesh(input.relay_mesh.as_ref(), &input.backend)?; + + // Complete every validation that does not depend on the provider-returned + // pubkey before registration. Registration is an external side effect; a + // bad owner key, missing team, unreadable persona store, or invalid + // definition default must not leave an identity that Desktop cannot save. + let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app)?; + if let Some(persona_id) = requested_persona_id.as_deref() { + ensure_persona_is_active(&personas, persona_id)?; + } + if let Some(team_id) = input + .team_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !load_teams(&app)?.iter().any(|team| team.id == team_id) { + return Err(format!("team {team_id} not found")); + } + } + let linked_persona = requested_persona_id + .as_deref() + .and_then(|id| personas.iter().find(|persona| persona.id == id)); + crate::managed_agents::resolve_mint_behavioral_defaults( + input.respond_to, + respond_to_allowlist.clone(), + input.parallelism, + linked_persona, + )?; } - let relay_mesh = normalize_relay_mesh(input.relay_mesh.as_ref(), &input.backend)?; + // Provider-custodied identity: registration returns only the public key + // and registry id. Desktop never receives the agent secret. + let registration = if provider_uses_registration { + let BackendKind::Provider { ref id, ref config } = input.backend else { + return Err("registration selected without a provider backend".to_string()); + }; + Some(provider_registration::register(id, config, &name).await?) + } else { + None + }; + let pubkey = registration + .as_ref() + .map(|value| value.pubkey.clone()) + .or(pubkey) + .ok_or_else(|| "agent creation produced no pubkey".to_string())?; + + if provider_uses_registration { + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + if load_managed_agents(&app)? + .iter() + .any(|record| record.pubkey == pubkey) + { + return Err(format!("agent {pubkey} already exists")); + } + } // ── Phase 2: compute NIP-OA auth tag (sync) ────────────────────────────── // Agents authenticate via the auth tag in their kind:0 profile event. // No tokens are minted. Fail closed: bad auth tag → don't create agent. let auth_tag = { - let owner_keys = state.signing_keys()?; - // Bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip. - let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex()) - .map_err(|e| format!("failed to bridge owner keys: {e}"))?; - let compat_agent = nostr::PublicKey::from_hex(&agent_keys.public_key().to_hex()) + let compat_agent = nostr::PublicKey::from_hex(&pubkey) .map_err(|e| format!("failed to bridge agent pubkey: {e}"))?; let tag = buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?; @@ -484,7 +576,7 @@ pub async fn create_managed_agent( }; // Load personas once for harness/pack/avatar resolution below. - let personas = load_personas(&app).unwrap_or_default(); + let personas = load_personas(&app)?; // Harness resolution: the persona's runtime is authoritative. A // persona-backed create stores an `agent_command_override` ONLY when the @@ -564,12 +656,10 @@ pub async fn create_managed_agent( // (input.env_vars), and the live persona env is merged underneath at // read time (spawn / readiness / deploy) so persona credential edits // refresh on the next spawn like prompt/model/provider already do. - let linked_persona = requested_persona_id.as_deref().and_then(|pid| { - load_personas(&app) - .ok()? - .into_iter() - .find(|persona| persona.id == pid) - }); + let linked_persona = requested_persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|persona| persona.id == pid)) + .cloned(); let persona_snapshot = linked_persona .as_ref() .map(crate::managed_agents::persona_events::persona_snapshot); @@ -606,6 +696,11 @@ pub async fn create_managed_agent( persona_id: requested_persona_id.clone(), team_id, private_key_nsec: private_key_nsec.clone(), + key_custody: if provider_uses_registration { + crate::managed_agents::AgentKeyCustody::Provider + } else { + crate::managed_agents::AgentKeyCustody::Local + }, auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), avatar_url: resolved_avatar_url.clone(), @@ -648,8 +743,12 @@ pub async fn create_managed_agent( auto_restart_on_config_change: true, runtime_pid: None, backend: input.backend.clone(), - backend_agent_id: None, + backend_agent_id: registration.as_ref().map(|value| value.agent_id.clone()), provider_policy_pending: false, + // Persist the incomplete handshake before the network call. The + // provider clears this only after attest succeeds, so a crash or + // error leaves the existing Start action available for retry. + provider_attestation_pending: provider_uses_registration, provider_binary_path, persona_team_dir: None, persona_name_in_team: None, @@ -747,20 +846,32 @@ pub async fn create_managed_agent( // ── Phase 4: sync agent profile on relay (async, outside lock) ─────────── // Use the avatar persisted on the record so the published profile and any // later reconciliation agree on the same value. - let mut profile_sync_error = profile::publish_agent_profile_with_about( - &state, - &resolved_relay_url, - &agent_keys, - &name, - resolved_avatar_url.as_deref(), - profile_about.as_deref(), - auth_tag.as_deref(), - ) - .await; + let mut profile_sync_error = if let Some(agent_keys) = &agent_keys { + profile::publish_agent_profile_with_about( + &state, + &resolved_relay_url, + agent_keys, + &name, + resolved_avatar_url.as_deref(), + profile_about.as_deref(), + auth_tag.as_deref(), + ) + .await + } else { + None + }; profile_sync_error = super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await; - let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { + let spawn_error = if provider_uses_registration { + let BackendKind::Provider { ref id, ref config } = input.backend else { + return Err("registration selected without a provider backend".to_string()); + }; + provider_registration::attest(&app, &state, &pubkey, id, config, &attestation_relay) + .await + .err() + .or(spawn_error) + } else if input.spawn_after_create && input.backend != BackendKind::Local { if let BackendKind::Provider { ref id, ref config } = input.backend { let agent_json = { let _g = state @@ -857,6 +968,9 @@ pub async fn start_managed_agent( )?; enum StartTarget { Local, + ControllerManaged { + backend: BackendKind, + }, Provider { backend: BackendKind, cached_binary_path: Option, @@ -866,7 +980,7 @@ pub async fn start_managed_agent( // Collect backend info under lock; async preflight/spawn happens below. // Also snapshot profile reconciliation data for the background task. - let (target, reconcile_data) = { + let (target, reconcile_data, reconcile_locally) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -903,6 +1017,10 @@ pub async fn start_managed_agent( let target = if record.backend == BackendKind::Local { StartTarget::Local + } else if record.key_custody == crate::managed_agents::AgentKeyCustody::Provider { + StartTarget::ControllerManaged { + backend: record.backend.clone(), + } } else { StartTarget::Provider { backend: record.backend.clone(), @@ -911,7 +1029,8 @@ pub async fn start_managed_agent( } }; - (target, reconcile) + let reconcile_locally = record.key_custody == crate::managed_agents::AgentKeyCustody::Local; + (target, reconcile, reconcile_locally) }; let result = match target { @@ -927,6 +1046,34 @@ pub async fn start_managed_agent( ) .await } + StartTarget::ControllerManaged { + backend: BackendKind::Provider { id, config }, + } => { + // Re-attest on every explicit start. The record is persisted before + // the create-time network call, so a process exit in that window + // leaves no reliable local success marker. Registration providers + // must therefore make attest idempotent. + provider_registration::attest(&app, &state, &pubkey, &id, &config, &reconcile_relay) + .await?; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + summarize_from_disk(&app, record, &runtimes) + } + StartTarget::ControllerManaged { backend } => Err(format!( + "agent {pubkey} has unsupported controller-managed backend kind: {backend:?}" + )), StartTarget::Provider { backend: BackendKind::Provider { id, config }, cached_binary_path, @@ -980,6 +1127,7 @@ pub async fn start_managed_agent( // profile sync at creation time failed silently. For legacy records (pre-PR-921) // with no persisted avatar, this also backfills the avatar from the relay. if result.is_ok() + && reconcile_locally && state .managed_agent_profile_reconcile_enabled() .load(std::sync::atomic::Ordering::Acquire) @@ -1159,6 +1307,7 @@ pub async fn delete_managed_agent( mod deploy; pub(super) mod provider_access; mod provider_deploy; +mod provider_registration; pub(super) use deploy::build_deploy_payload; #[cfg(test)] use deploy::{deploy_payload_json, DeployProjections}; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs index 69c2d2f7f83..f4a18b47b76 100644 --- a/desktop/src-tauri/src/commands/agents/provider_access.rs +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -17,6 +17,7 @@ pub(super) fn needs_reconciliation_with_policy( ) -> bool { (owner_only_access || record.provider_policy_pending) && record.backend != BackendKind::Local + && record.key_custody == crate::managed_agents::AgentKeyCustody::Local && record.backend_agent_id.is_some() } diff --git a/desktop/src-tauri/src/commands/agents/provider_registration.rs b/desktop/src-tauri/src/commands/agents/provider_registration.rs new file mode 100644 index 00000000000..612d26d2cbd --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/provider_registration.rs @@ -0,0 +1,212 @@ +//! Capability-gated provider registration and NIP-OA activation. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + find_managed_agent_mut, load_managed_agents, provider_attest, provider_capabilities, + provider_register, resolve_provider_binary, save_managed_agents, AgentKeyCustody, + ProviderRegistration, + }, + relay::ScopedWorkspaceRelay, + util::now_iso, +}; + +/// A provider owns identity creation only when it explicitly advertises both +/// halves of the handshake. Providers with neither capability retain the v1 +/// deploy flow; advertising only one half fails closed as a protocol error. +pub(super) async fn uses_registration(provider_id: &str) -> Result { + let binary = resolve_provider_binary(provider_id)?; + let capabilities = tokio::task::spawn_blocking(move || provider_capabilities(&binary)) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))??; + let register = capabilities.iter().any(|value| value == "register"); + let attest = capabilities.iter().any(|value| value == "attest"); + match (register, attest) { + (true, true) => Ok(true), + (false, false) => Ok(false), + _ => Err( + "provider must advertise register and attest together to manage agent identity" + .to_string(), + ), + } +} + +/// Proof that the create command validated a freshly negotiated custody mode. +/// +/// The field is private so callers cannot construct this token without passing +/// through [`require_expected_custody`]. The create command must consume the +/// token to choose its key-minting or provider-registration path, which binds +/// the guard to that production side-effect boundary at compile time. +#[must_use = "validated custody must select the agent creation path"] +pub(super) struct ValidatedCustodyMode { + uses_registration: bool, +} + +impl ValidatedCustodyMode { + pub(super) fn uses_registration(&self) -> bool { + self.uses_registration + } +} + +/// Bind creation to the custody mode the user saw after the provider probe. +/// A fresh negotiation may confirm that mode, but it must never silently +/// switch paths after the UI has described which process receives the key. +pub(super) fn require_expected_custody( + expected: Option, + uses_registration: bool, +) -> Result { + let expected = expected.ok_or_else(|| { + "provider creation requires the key custody observed during provider selection".to_string() + })?; + let negotiated = if uses_registration { + AgentKeyCustody::Provider + } else { + AgentKeyCustody::Local + }; + if negotiated == expected { + Ok(ValidatedCustodyMode { uses_registration }) + } else { + Err(format!( + "provider key custody changed after selection (expected {expected:?}, negotiated {negotiated:?}); select the provider again" + )) + } +} + +pub(super) async fn register( + provider_id: &str, + provider_config: &serde_json::Value, + name: &str, +) -> Result { + let binary = resolve_provider_binary(provider_id)?; + let config = provider_config.clone(); + let agent = serde_json::json!({ "name": name }); + tokio::task::spawn_blocking(move || provider_register(&binary, &agent, &config)) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? + .map_err(|error| format!("provider register failed: {error}")) +} + +fn attestation_agent( + pubkey: &str, + auth_tag: &str, + community_relay: &ScopedWorkspaceRelay, +) -> serde_json::Value { + serde_json::json!({ + "pubkey": pubkey, + "auth_tag": auth_tag, + "community_url": community_relay.as_str(), + }) +} + +fn attestation_pending_after(was_pending: bool, succeeded: bool) -> bool { + was_pending && !succeeded +} + +/// Attest the saved record in one community and persist a visible error if +/// activation or enrollment fails. +/// +/// The provider operation is deliberately idempotent: creation uses it for the +/// first community, and every later community-scoped start repeats it so the +/// same provider-custodied identity can enroll wherever its owner is a member. +pub(super) async fn attest( + app: &AppHandle, + state: &AppState, + pubkey: &str, + provider_id: &str, + provider_config: &serde_json::Value, + community_relay: &ScopedWorkspaceRelay, +) -> Result<(), String> { + let (auth_tag, was_pending) = { + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + ( + record + .auth_tag + .clone() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("agent {pubkey} has no auth tag"))?, + record.provider_attestation_pending, + ) + }; + + let binary = resolve_provider_binary(provider_id)?; + let config = provider_config.clone(); + let agent = attestation_agent(pubkey, &auth_tag, community_relay); + let result = tokio::task::spawn_blocking(move || provider_attest(&binary, &agent, &config)) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? + .map_err(|error| format!("provider attest failed: {error}")); + + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.updated_at = now_iso(); + record.last_error = result.as_ref().err().cloned(); + // A failed first attest leaves activation pending. A later community's + // enrollment failure must not make an already activated agent globally + // undeployed; the scoped Start call still returns the failure to its caller. + record.provider_attestation_pending = attestation_pending_after(was_pending, result.is_ok()); + save_managed_agents(app, &records)?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay::bind_expected_relay_scope; + + #[test] + fn attestation_payload_binds_the_target_community() { + let community = bind_expected_relay_scope( + Some("wss://community.example"), + "wss://community.example".to_string(), + ) + .unwrap(); + + assert_eq!( + attestation_agent("agent-pubkey", "owner-auth", &community), + serde_json::json!({ + "pubkey": "agent-pubkey", + "auth_tag": "owner-auth", + "community_url": "wss://community.example", + }) + ); + } + + #[test] + fn later_community_failure_does_not_reopen_global_attestation() { + assert!(attestation_pending_after(true, false)); + assert!(!attestation_pending_after(true, true)); + assert!(!attestation_pending_after(false, false)); + assert!(!attestation_pending_after(false, true)); + } + + #[test] + fn custody_validation_returns_the_command_path_token_only_for_an_exact_match() { + assert!( + require_expected_custody(Some(AgentKeyCustody::Provider), true) + .unwrap() + .uses_registration() + ); + assert!( + !require_expected_custody(Some(AgentKeyCustody::Local), false) + .unwrap() + .uses_registration() + ); + assert!(require_expected_custody(Some(AgentKeyCustody::Provider), false).is_err()); + assert!(require_expected_custody(Some(AgentKeyCustody::Local), true).is_err()); + assert!(require_expected_custody(None, true).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 59e04b09ff0..a8f95ab5bf2 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -14,6 +14,7 @@ fn bare_agent_record( name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -36,6 +37,7 @@ fn bare_agent_record( backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -754,6 +756,12 @@ fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_b &record, false )); + record.key_custody = crate::managed_agents::AgentKeyCustody::Provider; + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); + record.key_custody = crate::managed_agents::AgentKeyCustody::Local; + record.backend_agent_id = None; assert!(!provider_access::needs_reconciliation_with_policy( &record, true diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 6a10a1f9ee2..daf58327ac4 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -22,6 +22,7 @@ fn make_agent( name: "Test Agent".to_string(), persona_id: persona_id.map(str::to_string), private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -44,6 +45,7 @@ fn make_agent( backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index e90df637314..daf1dbfb144 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -168,6 +168,7 @@ fn local_agent() -> ManagedAgentRecord { name: "Local Agent".to_string(), persona_id: Some("persona-local".to_string()), private_key_nsec: "nsec1localsecret".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: Some("localauthtag".to_string()), relay_url: "wss://relay.local".to_string(), avatar_url: None, @@ -194,6 +195,7 @@ fn local_agent() -> ManagedAgentRecord { }, backend_agent_id: Some("local-remote-id".to_string()), provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: Some("/local/bin".to_string()), team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 55a64db59bc..db271bb4b4c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -18,6 +18,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { display_name: None, persona_id: None, private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -41,6 +42,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 041a0b91dc9..1b8dffd447d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -607,6 +607,7 @@ pub async fn confirm_agent_snapshot_import( slug: None, persona_id: Some(persona_id.clone()), private_key_nsec: private_key_nsec.clone(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: auth_tag.clone(), relay_url: String::new(), // resolves to workspace relay at runtime avatar_url: effective_avatar.clone(), @@ -633,6 +634,7 @@ pub async fn confirm_agent_snapshot_import( backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index abf4bef443d..e788d9efd7f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -27,6 +27,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { display_name: None, persona_id: None, private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -50,6 +51,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index 7aedcb25ef5..892041d56bf 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -10,6 +10,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge name: name.to_string(), persona_id: Some(persona_id.to_string()), private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -33,6 +34,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge backend: Default::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 9c57ce12b53..e53c3fced3a 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -569,6 +569,7 @@ pub async fn confirm_team_snapshot_import( slug: None, persona_id: Some(definition.id.clone()), private_key_nsec: private_key_nsec.clone(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: auth_tag.clone(), relay_url: String::new(), avatar_url: effective_avatar_url.clone(), @@ -593,6 +594,7 @@ pub async fn confirm_team_snapshot_import( backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: Some(imported_team.id.clone()), persona_team_dir: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 13c7f6ae810..bd2438c103a 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -200,6 +200,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { slug: None, persona_id: Some("alice".to_string()), private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -223,6 +224,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: Some("t1".to_string()), persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 85f34260ce7..f1e55f3075e 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -169,6 +169,7 @@ mod tests { name: "Test Agent".to_string(), persona_id: Some("persona-1".to_string()), private_key_nsec: "nsec1secretdonotpublish".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: Some("authtagsecret".to_string()), relay_url: "wss://relay.example".to_string(), avatar_url: Some("https://example.com/a.png".to_string()), @@ -195,6 +196,7 @@ mod tests { }, backend_agent_id: Some("remote-id".to_string()), provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: Some("/path/to/binary".to_string()), team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 131966409b0..f04ec359b1a 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -371,6 +371,7 @@ mod tests { name: "Locked Test".to_string(), persona_id: None, private_key_nsec, + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -391,6 +392,7 @@ mod tests { backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index da881f64f5a..4c199bdb6ca 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -18,8 +18,9 @@ fn minimal_record() -> ManagedAgentRecord { persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot - auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot - relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot + key_custody: crate::managed_agents::AgentKeyCustody::Local, + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot avatar_url: Some("https://example.com/avatar.png".to_string()), acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot agent_command: "goose".to_string(), // MUST NOT appear in snapshot @@ -49,6 +50,7 @@ fn minimal_record() -> ManagedAgentRecord { }, backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 84dd7e99da4..1f27f038bef 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -10,7 +10,7 @@ const STDERR_CAP: usize = 65536; const STDOUT_CAP: usize = 1_048_576; // 1 MB const PROVIDER_PROTOCOL_VERSION: u64 = 1; -fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { +fn validate_provider_info(info: &serde_json::Value) -> Result, String> { let object = info .as_object() .ok_or_else(|| "provider info response must be a JSON object".to_string())?; @@ -45,13 +45,33 @@ fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { return Err("provider info response missing object config_schema".to_string()); } + let capabilities = match object.get("capabilities") { + None => Vec::new(), + Some(value) => value + .as_array() + .ok_or_else(|| "provider info capabilities must be an array".to_string())? + .iter() + .map(|capability| { + capability + .as_str() + .filter(|capability| !capability.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + "provider info capabilities must contain non-empty strings".to_string() + }) + }) + .collect::, _>>()?, + }; + const FIELDS: &[&str] = &[ "ok", + "request_id", "name", "version", "protocol_version", "description", "config_schema", + "capabilities", ]; if let Some(field) = object .keys() @@ -61,7 +81,7 @@ fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { "provider info response contains unknown field {field}" )); } - Ok(()) + Ok(capabilities) } /// Invoke a provider binary: write JSON to stdin, read JSON from stdout. @@ -532,6 +552,111 @@ pub fn provider_deploy( .ok_or_else(|| "deploy response missing agent_id".to_string()) } +fn staged_provider_capabilities(binary: &Path) -> Result, String> { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + provider_capabilities_for_executable(&staged) +} + +/// Probe a provider through an immutable staged copy and return only a +/// protocol-v1 response that has passed the same validation creation uses. +/// The UI must never make custody claims from an unvalidated provider blob. +pub fn probe_provider_info(binary: &Path) -> Result { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + let request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + let info = invoke_provider(&staged, &request, Duration::from_secs(10))?; + validate_provider_info(&info)?; + Ok(info) +} + +fn provider_capabilities_for_executable(binary: &Path) -> Result, String> { + let request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + let info = invoke_provider(binary, &request, Duration::from_secs(10))?; + validate_provider_info(&info) +} + +/// Return the capabilities advertised by a validated provider protocol-v1 +/// implementation. Unknown capabilities are preserved for forward-compatible +/// negotiation; callers opt into only the operations they understand. +pub fn provider_capabilities(binary: &Path) -> Result, String> { + staged_provider_capabilities(binary) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderRegistration { + pub agent_id: String, + pub pubkey: String, +} + +/// Register a provider-custodied agent. The provider mints the key and returns +/// only its public identity plus the registry id. +pub fn provider_register( + binary: &Path, + agent: &serde_json::Value, + provider_config: &serde_json::Value, +) -> Result { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + let capabilities = provider_capabilities_for_executable(&staged)?; + if !capabilities.iter().any(|value| value == "register") { + return Err("provider does not advertise the register capability".to_string()); + } + let request = serde_json::json!({ + "op": "register", + "request_id": uuid::Uuid::new_v4().to_string(), + "agent": agent, + "provider_config": provider_config, + }); + let response = invoke_provider(&staged, &request, Duration::from_secs(120))?; + let agent_id = response["agent_id"] + .as_str() + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "register response missing agent_id".to_string())?; + let pubkey = response["pubkey"] + .as_str() + .ok_or_else(|| "register response missing pubkey".to_string())?; + if pubkey.len() != 64 + || !pubkey + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("register response pubkey is not 64 hex characters".to_string()); + } + Ok(ProviderRegistration { + agent_id, + pubkey: pubkey.to_ascii_lowercase(), + }) +} + +/// Complete ownership activation for a registered provider-custodied agent. +pub fn provider_attest( + binary: &Path, + agent: &serde_json::Value, + provider_config: &serde_json::Value, +) -> Result<(), String> { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + let capabilities = provider_capabilities_for_executable(&staged)?; + if !capabilities.iter().any(|value| value == "attest") { + return Err("provider does not advertise the attest capability".to_string()); + } + let request = serde_json::json!({ + "op": "attest", + "request_id": uuid::Uuid::new_v4().to_string(), + "agent": agent, + "provider_config": provider_config, + }); + let response = invoke_provider(&staged, &request, Duration::from_secs(120))?; + if response.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + return Err("attest response missing ok=true".to_string()); + } + Ok(()) +} + /// Validate provider_config: flat object, scalar values, no secret-like keys. pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> { let obj = config diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs index ce1f81466fc..7739a927727 100644 --- a/desktop/src-tauri/src/managed_agents/backend_tests.rs +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -342,6 +342,84 @@ fn provider_info_requires_the_complete_flat_wire_shape() { assert!(validate_provider_info(&nested) .unwrap_err() .contains("unknown field provider")); + + let mut capable = serde_json::json!({ + "ok": true, + "name": "registry", + "version": "1.0.0", + "protocol_version": 1, + "description": "Registry provider", + "config_schema": {}, + "capabilities": ["register", "attest"] + }); + assert_eq!( + validate_provider_info(&capable).unwrap(), + vec!["register", "attest"] + ); + capable["capabilities"] = serde_json::json!(["register", 1]); + assert!(validate_provider_info(&capable).is_err()); +} + +#[cfg(unix)] +#[test] +fn provider_register_and_attest_negotiate_capabilities() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"registry","version":"1.0.0","protocol_version":1,"description":"registry provider","capabilities":["register","attest"],"config_schema":{}}' ;; + *\"op\":\"register\"*) printf '%s\n' '{"ok":true,"agent_id":"agent-1","pubkey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' ;; + *\"op\":\"attest\"*) printf '%s\n' '{"ok":true}' ;; +esac"#, + ); + + let registered = provider_register( + &provider, + &serde_json::json!({"name": "Pip"}), + &serde_json::json!({}), + ) + .unwrap(); + assert_eq!(registered.agent_id, "agent-1"); + assert_eq!( + registered.pubkey, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + provider_attest( + &provider, + &serde_json::json!({ + "pubkey": registered.pubkey, + "auth_tag": "tag", + "community_url": "wss://community.example", + }), + &serde_json::json!({}), + ) + .unwrap(); +} + +#[cfg(unix)] +#[test] +fn provider_probe_returns_only_validated_protocol_info() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +printf '%s\n' '{"ok":true,"name":"registry","version":"1.0.0","protocol_version":1,"description":"registry provider","capabilities":["register","attest"],"config_schema":{}}'"#, + ); + + let info = probe_provider_info(&provider).unwrap(); + assert_eq!(info["name"], "registry"); + + write_test_provider( + &provider, + r#"read request +printf '%s\n' '{"ok":true,"capabilities":["register","attest"]}'"#, + ); + assert!(probe_provider_info(&provider) + .unwrap_err() + .contains("protocol_version")); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs index 9c4568fceb4..fbcc3f935b3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/effort_tests.rs @@ -32,6 +32,7 @@ pub(super) fn record() -> ManagedAgentRecord { name: "Test Agent".to_string(), persona_id: None, private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::types::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -53,6 +54,7 @@ pub(super) fn record() -> ManagedAgentRecord { backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, 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 34b4f1496f5..b0f31259e66 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 @@ -73,6 +73,7 @@ fn test_record() -> ManagedAgentRecord { name: "Test Agent".to_string(), persona_id: None, private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -93,6 +94,7 @@ fn test_record() -> ManagedAgentRecord { backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index dc155d82f5b..133dbe19595 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -15,7 +15,6 @@ use crate::managed_agents::AcpAvailabilityStatus; #[test] fn resolves_known_avatar_for_bare_command() { let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); - assert_eq!(avatar_url, GOOSE_AVATAR_URL); } @@ -205,7 +204,6 @@ fn effective_agent_command_explicit_override_wins() { ); } -/// Minimal record for `record_agent_command` tests; only resolution inputs vary. fn record_with( runtime: Option<&str>, persona_id: Option<&str>, @@ -217,6 +215,7 @@ fn record_with( name: "r".to_string(), persona_id: persona_id.map(str::to_string), private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -239,6 +238,7 @@ fn record_with( backend: Default::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 1ed44ace946..84f0e2e1a7e 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -46,6 +46,7 @@ fn record( name: "Agent".to_string(), persona_id: persona_id.map(str::to_string), private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -68,6 +69,7 @@ fn record( backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 5f39b7b75f2..be8df12880e 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -304,6 +304,7 @@ fn bare_record() -> ManagedAgentRecord { name: "Agent".to_string(), persona_id: None, private_key_nsec: "".to_string(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -326,6 +327,7 @@ fn bare_record() -> ManagedAgentRecord { backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs index c712b2525d4..76bd675d491 100644 --- a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -43,6 +43,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { name: name.to_string(), persona_id: persona_id.map(|s| s.to_string()), private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: TEST_RELAY.to_string(), avatar_url: None, @@ -65,6 +66,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { backend: BackendKind::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index f0806c8bc04..1ff1c00587f 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -69,6 +69,7 @@ mod tests { name: "r".to_string(), persona_id: None, private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -91,6 +92,7 @@ mod tests { backend: Default::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 9367ad463e2..e40d161bffd 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -10,6 +10,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { name: "agent".into(), persona_id: Some("test-persona".into()), private_key_nsec: "nsec1fake".into(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".into(), avatar_url: None, @@ -33,6 +34,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 88cc7884c41..3ef63601862 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1483,21 +1483,20 @@ mod tests { #[test] fn resolve_effective_agent_env_user_env_wins_over_structured_fields() { - // User env_vars must win over baked defaults; in OSS builds baked map is empty, - // so this validates the user-env layer is present in the output. + // User env_vars must win over baked defaults; this verifies the user-env output layer. 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 { description: None, pubkey: "test-pubkey".to_string(), name: "test-agent".to_string(), persona_id: None, private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: None, @@ -1521,6 +1520,7 @@ mod tests { backend: Default::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b8d586b32af..1fca79a176c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -166,11 +166,7 @@ pub fn build_managed_agent_summary( // (infrastructure still exists). This is intentional — the provider may // have allocated a VM/container that persists across process restarts. // A future provider `undeploy` operation (v2) will handle teardown. - let status = if record.backend_agent_id.is_some() { - "deployed".to_string() - } else { - "not_deployed".to_string() - }; + let status = remote_deployment_status(record).to_string(); (status, None, String::new()) } else { let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); @@ -337,6 +333,14 @@ pub fn build_managed_agent_summary( }) } +fn remote_deployment_status(record: &ManagedAgentRecord) -> &'static str { + if record.backend_agent_id.is_some() && !record.provider_attestation_pending { + "deployed" + } else { + "not_deployed" + } +} + pub fn find_managed_agent_mut<'a>( records: &'a mut [ManagedAgentRecord], pubkey: &str, diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 05e11fc4cdf..44ade8ddb4f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -41,6 +41,7 @@ pub(super) fn fixture( name: "n".into(), persona_id: None, private_key_nsec: "nsec1fake".into(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag, relay_url: "ws://localhost:3000".into(), avatar_url: None, @@ -64,6 +65,7 @@ pub(super) fn fixture( backend: Default::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 57521c04fff..37f2090c6b0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -124,6 +124,18 @@ use super::test_fixtures::{expected_mode, expected_owner_only, fixture}; use super::{build_respond_to_env, build_respond_to_env_with_policy}; use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; +#[test] +fn provider_attestation_must_complete_before_remote_agent_is_deployed() { + let mut record = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); + record.backend_agent_id = Some("provider-agent-id".into()); + record.provider_attestation_pending = true; + + assert_eq!(super::remote_deployment_status(&record), "not_deployed"); + + record.provider_attestation_pending = false; + assert_eq!(super::remote_deployment_status(&record), "deployed"); +} + #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 388256e01c6..e62438b8241 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -48,6 +48,7 @@ fn record() -> ManagedAgentRecord { name: "agent".into(), persona_id: None, private_key_nsec: "nsec1fake".into(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".into(), avatar_url: None, @@ -71,6 +72,7 @@ fn record() -> ManagedAgentRecord { backend: Default::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f8a2c1039a8..278871ffca5 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -220,20 +220,22 @@ fn migrate_inline_key(store: &impl KeyStore, record: &ManagedAgentRecord) -> Key } } -/// Refuse to spawn an agent whose private key is unavailable. Returns -/// `Some(error)` when `private_key_nsec` is empty — after [`hydrate_keys`] an -/// empty key means a keyring outage or a genuinely absent secret, NOT a -/// deliberately keyless agent. Spawning anyway would inject an empty +/// Refuse to spawn a locally-custodied agent whose private key is unavailable. +/// Provider-custodied records deliberately carry no local key and are launched +/// by their controller; explicit `key_custody` distinguishes them from a local +/// keyring outage or genuinely absent secret. Spawning a local record anyway +/// would inject an empty /// `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, launching with no identity. Callers /// (the spawn path) must fail closed (Wes storage.rs:158). pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { - record.private_key_nsec.is_empty().then(|| { - format!( - "agent {} has no private key available — the OS keyring may be unreachable. \ + (record.key_custody == super::AgentKeyCustody::Local && record.private_key_nsec.is_empty()) + .then(|| { + format!( + "agent {} has no private key available — the OS keyring may be unreachable. \ Refusing to start without an identity; retry once the keyring is reachable.", - record.pubkey - ) - }) + record.pubkey + ) + }) } /// Read the raw unified store — keyed instances AND key-less definitions — @@ -303,7 +305,8 @@ pub(crate) fn backup_invalid_store(path: &Path) { /// Fill in each record's in-memory `private_key_nsec` from the keyring, and /// opportunistically re-migrate any key that is still inline. /// -/// - Empty key → fetch it from the keyring (the normal keyring-backed case). +/// - Provider custody → skip the keyring entirely. +/// - Empty local key → fetch it from the keyring (the normal keyring-backed case). /// - Non-empty key → the JSON carried it inline because the keyring was /// unreachable at its last save. Re-migrate it now ([`migrate_inline_key`]): /// if the keyring is reachable this boot, write-verify-strip so the next save @@ -323,13 +326,13 @@ fn hydrate_keys(records: &mut [ManagedAgentRecord]) { /// (genuinely absent). On an outage the key is left empty and the record is /// surfaced as unavailable rather than silently swallowed: callers must refuse /// to spawn an agent whose key could not be read (see the empty-key bail in -/// `spawn_agent_child`). Empty here never means "fine" — it means "no usable -/// key this boot." +/// `spawn_agent_child`). Empty local custody never means "fine"; provider +/// custody is skipped before any keyring access. fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) { for record in records.iter_mut() { // A key-less definition (no pubkey yet — unified agent model) has no // keyring entry by construction; keys are minted on first start. - if record.pubkey.is_empty() { + if record.pubkey.is_empty() || record.key_custody == super::AgentKeyCustody::Provider { continue; } if record.private_key_nsec.is_empty() { @@ -443,6 +446,9 @@ fn persist_agent_keys(records: &mut [ManagedAgentRecord]) { /// Testable core of [`persist_agent_keys`], generic over the [`KeyStore`] seam. fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) { for record in records.iter_mut() { + if record.key_custody == super::AgentKeyCustody::Provider { + continue; + } // Only a verified keyring entry lets us drop the inline copy. Both // other outcomes keep the key inline: `KeptInline` (keyring // unreachable) so it is not lost, and `Nothing` (empty key) because diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index d39fcf41009..b715a840d22 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -251,6 +251,22 @@ fn spawn_allowed_when_private_key_present() { assert!(super::spawn_key_refusal(&record).is_none()); } +#[test] +fn provider_custodied_agent_skips_keyring_and_local_key_refusal() { + let store = FakeKeyStore::reachable(); + let mut record = record_with_key(""); + record.key_custody = crate::managed_agents::AgentKeyCustody::Provider; + let mut records = vec![record]; + + hydrate_keys_with(&store, &mut records); + persist_agent_keys_with(&store, &mut records); + + assert!(records[0].private_key_nsec.is_empty()); + assert!(super::spawn_key_refusal(&records[0]).is_none()); + assert_eq!(*store.read_count.borrow(), 0); + assert_eq!(*store.write_count.borrow(), 0); +} + #[test] fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() { // This is the dominant prompt-storm scenario: after the first successful diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index fdeb54c4f27..69c7dc26f02 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -260,8 +260,9 @@ mod tests { display_name: Some(format!("{name} Display")), persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear - auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear - relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear + key_custody: crate::managed_agents::AgentKeyCustody::Local, + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear avatar_url: Some(format!("https://example.com/{name}.png")), acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear agent_command: "goose".to_string(), // MUST NOT appear @@ -287,6 +288,7 @@ mod tests { backend: BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index fc6f0f1a97b..6021cdda5a2 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -173,6 +173,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { persona_id: None, team_id: None, private_key_nsec: String::new(), + key_custody: crate::managed_agents::AgentKeyCustody::Local, auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, @@ -196,6 +197,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, persona_team_dir: None, persona_name_in_team: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 2620f0337fc..e537313b76a 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -12,6 +12,17 @@ pub enum BackendKind { }, } +/// Where the agent's Nostr signing key is held. This must be explicit: an +/// empty in-memory key for a locally-custodied agent means the keyring is +/// unavailable, while an empty key for a provider-custodied agent is expected. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentKeyCustody { + #[default] + Local, + Provider, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentDefinition { pub id: String, @@ -114,6 +125,7 @@ impl AgentDefinition { name: self.display_name.clone(), persona_id: None, private_key_nsec: String::new(), + key_custody: AgentKeyCustody::Local, auth_tag: None, relay_url: String::new(), avatar_url: self.avatar_url, @@ -137,6 +149,7 @@ impl AgentDefinition { backend: BackendKind::default(), backend_agent_id: None, provider_policy_pending: false, + provider_attestation_pending: false, provider_binary_path: None, team_id: None, persona_team_dir: None, @@ -247,6 +260,8 @@ pub struct ManagedAgentRecord { /// store whose inline key was already migrated out and blanked. #[serde(default, skip_serializing_if = "String::is_empty")] pub private_key_nsec: String, + #[serde(default, skip_serializing_if = "is_local_key_custody")] + pub key_custody: AgentKeyCustody, /// NIP-OA auth tag JSON. Computed at agent creation time. /// /// Pre-existing agents created before NIP-OA will have `None` here. @@ -337,6 +352,12 @@ pub struct ManagedAgentRecord { pub backend_agent_id: Option, #[serde(default)] pub provider_policy_pending: bool, + /// A provider-custodied identity has been registered but its owner + /// attestation has not yet been durably acknowledged by the provider. + /// While set, the agent remains startable so Desktop can retry the + /// idempotent attestation after an error or interrupted create flow. + #[serde(default)] + pub provider_attestation_pending: bool, #[serde(default)] pub provider_binary_path: Option, /// Installed team directory path (absolute). Set when agent was created from a team persona. @@ -478,6 +499,10 @@ pub struct ManagedAgentRecord { pub effort_level: Option, } +fn is_local_key_custody(custody: &AgentKeyCustody) -> bool { + *custody == AgentKeyCustody::Local +} + #[derive(Debug)] pub struct ManagedAgentProcess { pub child: Child, diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 824ca4ccf3a..d8b63d8a56b 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -6,8 +6,8 @@ use std::collections::BTreeMap; use serde::Deserialize; use super::{ - default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, BackendKind, - CatalogSource, RelayMeshConfig, RespondTo, + default_start_on_app_launch, validate_respond_to_allowlist, AgentDefinition, AgentKeyCustody, + BackendKind, CatalogSource, RelayMeshConfig, RespondTo, }; /// The NIP-AP behavioral group as one grouped request field. @@ -183,6 +183,10 @@ pub struct CreateManagedAgentRequest { pub start_on_app_launch: bool, #[serde(default)] pub backend: BackendKind, + /// Custody mode shown to the user after probing the selected provider. + /// Provider creates fail closed when fresh negotiation no longer matches. + #[serde(default)] + pub expected_key_custody: Option, /// `None` = caller expressed no preference: the definition's /// `respond_to` default applies when linked, `RespondTo::default()` /// otherwise. `Some` is an explicit instance-level choice and always diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 0918ab2c65c..845602def90 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -446,6 +446,10 @@ fn managed_agent_record_without_key_deserializes_empty() { !record.provider_policy_pending, "pre-pending stores must deserialize as acknowledged" ); + assert!( + !record.provider_attestation_pending, + "pre-attestation-state stores must deserialize as acknowledged" + ); } #[test] @@ -459,6 +463,18 @@ fn pending_provider_policy_round_trips() { assert!(reloaded.provider_policy_pending); } +#[test] +fn pending_provider_attestation_round_trips() { + let mut record = sample_agent_record(); + record.provider_attestation_pending = true; + + let json = serde_json::to_string(&record).expect("serialize pending attestation"); + let reloaded: ManagedAgentRecord = + serde_json::from_str(&json).expect("reload pending attestation"); + + assert!(reloaded.provider_attestation_pending); +} + fn sample_agent_record() -> ManagedAgentRecord { serde_json::from_str( r#"{ diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dfb9c0ed494..9dc2c414fb1 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -308,6 +308,20 @@ with a TypeScript lookup table or an id comparison in a component. 17. **Databricks model discovery has one shared catalog authority.** Desktop and ACP call the shared `buzz-agent` discovery library; Desktop passes the effective merged `DATABRICKS_MODEL_FILTER` explicitly, and the library applies it to raw workspace endpoint IDs and Unity Catalog model-service FQNs after the additive union. A successful filtered-empty catalog is authoritative: it stays empty, disables switching, and never falls through to configured or known-model fallback. UC FQNs are catalog data and always use the MLflow Chat Completions route, regardless of family-looking text in their components. Global Defaults preserves the discovered model ID as the selected value while its closed trigger renders the provider-scoped display label; do not force the raw persisted ID over that label. +18. **Remote key custody is explicit and capability-gated.** A provider must + advertise `register` and `attest` together before Desktop lets it create an + agent identity. Desktop signs NIP-OA over the returned pubkey, persists + `key_custody: provider`, and never receives or probes a local secret for that + agent. A provider advertising neither capability keeps the legacy + Desktop-custodied `deploy` path; advertising only one fails closed. Never + infer provider custody from an empty `private_key_nsec`: for local custody, + empty still means an unavailable or missing key and local spawn must refuse. + Provider-custodied records must never enter legacy `deploy` or policy + redeploy paths. Persist their attestation as pending before the network call + and clear it only after provider acknowledgement; pending records summarize + as not deployed so Start remains available. Start must re-attest them + idempotently so an interrupted create-time handshake remains recoverable. + ## Channel-only runtime controls Desktop observer controls identify a channel, not a thread session. The harness diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 5919309224f..07057203ca8 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -86,6 +86,7 @@ export type BackendIntent = { type: "provider"; id: string; config: Record; + expectedKeyCustody: "local" | "provider"; }; /** @@ -136,6 +137,7 @@ export async function buildInstanceInputForDefinition( id: backendIntent.id, config: backendIntent.config, }, + expectedKeyCustody: backendIntent.expectedKeyCustody, }; } diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index eb2fc8f0f12..479467fc383 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -9,6 +9,7 @@ import { PersonaDropdownField } from "./PersonaDropdownField"; import { applyProbeResult, emptyWhereToRunDraft, + providerManagesIdentity, type WhereToRunDraft, } from "./whereToRunIntent"; @@ -40,6 +41,7 @@ export function WhereToRunSection({ backendProviders.find((provider) => provider.id === draft.runOn) ?? null, [backendProviders, draft.runOn], ); + const providerKeepsAgentKey = providerManagesIdentity(draft.probedProvider); // Latest-state seam for probe resolution: an Effect Event always sees the // draft as it is *now*. Without this, the probe promise closes over the @@ -115,8 +117,9 @@ export function WhereToRunSection({ {selectedBackendProvider.binaryPath} {" "} - will receive your agent's private key. Only use providers - from trusted sources. + {providerKeepsAgentKey + ? "will create and keep your agent’s private key. Buzz receives only the public key." + : "will receive your agent’s private key. Only use providers from trusted sources."}

{probeError ? ( diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 262d55d998d..2716f5d814b 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -6,11 +6,13 @@ import { canSubmitWhereToRun, emptyWhereToRunDraft, providerConfigComplete, + providerManagesIdentity, resolveBackendIntent, } from "./whereToRunIntent.ts"; const probed = { ok: true, + capabilities: ["register", "attest"], config_schema: { properties: { region: { type: "string" }, size: { type: "integer" } }, required: ["region"], @@ -48,6 +50,21 @@ test("local never gates submit", () => { assert.equal(canSubmitWhereToRun(emptyWhereToRunDraft), true); }); +test("provider identity custody requires register and attest together", () => { + assert.equal( + providerManagesIdentity({ + ok: true, + capabilities: ["register", "attest"], + }), + true, + ); + assert.equal( + providerManagesIdentity({ ok: true, capabilities: ["register"] }), + false, + ); + assert.equal(providerManagesIdentity(null), false); +}); + test("local draft resolves to null intent", () => { assert.equal(resolveBackendIntent(emptyWhereToRunDraft), null); }); @@ -58,9 +75,26 @@ test("provider draft resolves with coerced config values", () => { type: "provider", id: "blox", config: { region: "us", size: 3 }, + expectedKeyCustody: "provider", }); }); +test("provider draft binds the observed legacy custody mode", () => { + assert.equal( + resolveBackendIntent( + providerDraft({ probedProvider: { ...probed, capabilities: [] } }), + ).expectedKeyCustody, + "local", + ); +}); + +test("unprobed provider intent fails closed", () => { + assert.throws( + () => resolveBackendIntent(providerDraft({ probedProvider: null })), + /must be probed/, + ); +}); + // ── applyProbeResult: probe resolution must merge, not overwrite ───────────── // // Pins the seam that fixed the "Typewriter Eraser" (agent-create dialog's diff --git a/desktop/src/features/agents/ui/whereToRunIntent.ts b/desktop/src/features/agents/ui/whereToRunIntent.ts index 9aa9248e75d..8cd23a73611 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.ts +++ b/desktop/src/features/agents/ui/whereToRunIntent.ts @@ -60,10 +60,22 @@ export function canSubmitWhereToRun(draft: WhereToRunDraft): boolean { return providerConfigComplete(draft); } +export function providerManagesIdentity( + provider: BackendProviderProbeResult | null, +): boolean { + return ( + provider?.capabilities?.includes("register") === true && + provider.capabilities.includes("attest") + ); +} + export function resolveBackendIntent( draft: WhereToRunDraft, ): BackendIntent | null { if (draft.runOn === "local") return null; + if (!draft.probedProvider) { + throw new Error("Provider must be probed before it can be selected"); + } return { type: "provider", id: draft.runOn, @@ -71,5 +83,8 @@ export function resolveBackendIntent( draft.providerConfig, draft.probedProvider?.config_schema, ), + expectedKeyCustody: providerManagesIdentity(draft.probedProvider) + ? "provider" + : "local", }; } diff --git a/desktop/src/shared/api/tauri.test.mjs b/desktop/src/shared/api/tauri.test.mjs index 2273b55fca3..d013f098161 100644 --- a/desktop/src/shared/api/tauri.test.mjs +++ b/desktop/src/shared/api/tauri.test.mjs @@ -211,6 +211,34 @@ test("fromRawAcpRuntimeCatalogEntry env round-trips through edit payload shape", ); }); +// ── createManagedAgent: security-sensitive input forwarding ────────────────── + +test("createManagedAgent forwards the provider custody observed by the UI", async (t) => { + let capturedCall; + globalThis.window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + capturedCall = { command, args }; + throw new Error("stop after capture"); + }, + }; + t.after(() => { + delete globalThis.window.__TAURI_INTERNALS__; + }); + + const { createManagedAgent } = await import("./tauri.ts"); + await assert.rejects( + createManagedAgent({ + name: "Provider agent", + backend: { type: "provider", id: "blox", config: {} }, + expectedKeyCustody: "provider", + }), + /stop after capture/, + ); + + assert.equal(capturedCall.command, "create_managed_agent"); + assert.equal(capturedCall.args.input.expectedKeyCustody, "provider"); +}); + // ── max_parallelism → maxParallelism mapping ────────────────────────────────── test("fromRawAcpRuntimeCatalogEntry maps max_parallelism to maxParallelism when present", () => { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 984b9d176df..64dd2ed61ec 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -806,6 +806,7 @@ export async function createManagedAgent(input: CreateManagedAgentInput) { spawnAfterCreate: input.spawnAfterCreate, startOnAppLaunch: input.startOnAppLaunch, backend: input.backend, + expectedKeyCustody: input.expectedKeyCustody, respondTo: input.respondTo, respondToAllowlist: input.respondToAllowlist, relayMesh: input.relayMesh, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index b988843d60b..6f30a4737fc 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -389,6 +389,7 @@ export type BackendProviderProbeResult = { name?: string; version?: string; description?: string; + capabilities?: string[]; config_schema?: Record; }; @@ -425,6 +426,8 @@ export type CreateManagedAgentInput = { spawnAfterCreate?: boolean; startOnAppLaunch?: boolean; backend?: ManagedAgentBackend; + /** Custody mode shown after probing; provider creation fails if it changed. */ + expectedKeyCustody?: "local" | "provider"; /** Omitted uses the linked persona default, then `"owner-only"`. */ respondTo?: RespondToMode; /**