diff --git a/Justfile b/Justfile index d6e86c8d09..35e5133b20 100644 --- a/Justfile +++ b/Justfile @@ -206,7 +206,7 @@ desktop-tauri-check: _ensure-sidecar-stubs desktop-tauri-test: _ensure-sidecar-stubs cd desktop/src-tauri && cargo test -# Verify compiled-flag behavior under both compile states (clean + internal). +# Verify compiled-flag behavior under both compile states (clean + capability set). # Runs the observer_archive focused test twice with independently supplied # expected values; build.rs rerun-if-env-changed triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs @@ -221,13 +221,25 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "=== Internal build (flags set) → expect true ===" + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test --lib + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture + echo "=== Owner-only access capability set → expect true ===" BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT=1 \ BUZZ_TEST_EXPECTED_OBSERVER_ARCHIVE_DEFAULT=true \ cargo test observer_archive_default_enabled_matches_expected -- --ignored --nocapture BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test --lib + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 0fb3747718..b9de4e68d0 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -15,9 +15,16 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_OBSERVER_ARCHIVE_DEFAULT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_METRIC_ARCHIVE_DEFAULT"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + // Explicit owner-only agent-access capability. Release packaging sets this + // presence-only marker; OSS/custom builds leave agent access configurable. + if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1"); + } + if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } diff --git a/desktop/src-tauri/src/commands/agent_access.rs b/desktop/src-tauri/src/commands/agent_access.rs new file mode 100644 index 0000000000..ef118e82b2 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -0,0 +1,18 @@ +/// Return whether this build enforces owner-only managed-agent access. +#[tauri::command] +pub fn agent_access_owner_only() -> bool { + crate::managed_agents::owner_only_access_build() +} + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "requires BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"] + fn compiled_policy_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set") + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"); + assert_eq!(super::agent_access_owner_only(), expected); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b114b0474..30ef09f99e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1360,7 +1360,7 @@ pub async fn delete_managed_agent( mod deploy; use deploy::build_deploy_payload; #[cfg(test)] -use deploy::deploy_payload_json; +use deploy::{deploy_payload_json, deploy_payload_json_for_current_build}; #[cfg(test)] use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230d..9a7d0022c4 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -138,7 +138,7 @@ pub(super) fn build_deploy_payload( &owner_pubkey, ); - Ok(deploy_payload_json( + Ok(deploy_payload_json_for_current_build( record, crate::relay::effective_agent_relay_url( &record.relay_url, @@ -152,8 +152,31 @@ pub(super) fn build_deploy_payload( )) } +/// Serialize a deploy payload using this build's managed-agent access policy. +pub(super) fn deploy_payload_json_for_current_build( + record: &ManagedAgentRecord, + relay_url: String, + effective_model: Option, + effective_provider: Option, + effective_prompt: Option, + merged_env: BTreeMap, + launch: serde_json::Value, +) -> serde_json::Value { + deploy_payload_json( + record, + relay_url, + effective_model, + effective_provider, + effective_prompt, + merged_env, + launch, + crate::managed_agents::owner_only_access_build(), + ) +} + /// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields /// remain for display/bookkeeping; providers execute the resolved `launch` block. +#[allow(clippy::too_many_arguments)] pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, @@ -162,7 +185,10 @@ pub(super) fn deploy_payload_json( effective_prompt: Option, merged_env: BTreeMap, launch: serde_json::Value, + owner_only_access: bool, ) -> serde_json::Value { + let (respond_to, respond_to_allowlist) = + crate::managed_agents::projected_access_with_policy(record, owner_only_access); serde_json::json!({ "name": &record.name, "relay_url": relay_url, @@ -177,8 +203,8 @@ pub(super) fn deploy_payload_json( "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, - "respond_to": record.respond_to, - "respond_to_allowlist": &record.respond_to_allowlist, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, "env_vars": merged_env, "launch": launch, }) diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c4..7d18e04520 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -411,6 +411,24 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── +fn deploy_payload_for_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> serde_json::Value { + deploy_payload_json( + record, + "wss://relay.example".to_string(), + Some("gpt-x".to_string()), + Some("openai".to_string()), + None, + std::collections::BTreeMap::new(), + // Access projection is the subject here; the launch block is exercised + // by the shared provider fixture test below. + serde_json::Value::Null, + owner_only_access, + ) +} + /// The shared provider fixture is the contract arbiter: it must be the exact /// richest deploy request produced by the real desktop serializers. #[test] @@ -470,6 +488,9 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { None, std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), launch, + // Fixture asserts the record's own access fields survive, so the + // owner-only projection must be off for this comparison. + false, ); assert_eq!( @@ -501,3 +522,90 @@ fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { ); } } + +#[test] +fn current_build_deploy_payload_forwards_compiled_policy() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let expected_owner_only = match std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") { + Ok(value) => value + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!( + "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set for owner-only-access-build tests" + ) + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be valid UTF-8") + } + }; + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_json_for_current_build( + &record, + "wss://relay.example".to_string(), + None, + None, + None, + std::collections::BTreeMap::new(), + // The compiled access policy is the subject here; the launch block is + // exercised by the shared provider fixture test above. + serde_json::Value::Null, + ); + let expected_mode = if expected_owner_only { + "owner-only" + } else { + "anyone" + }; + + assert_eq!( + payload["respond_to"], expected_mode, + "current-build deploy payload did not forward the compiled policy", + ); + let expected_allowlist = if expected_owner_only { + serde_json::json!([]) + } else { + serde_json::json!(["a".repeat(64)]) + }; + assert_eq!( + payload["respond_to_allowlist"], expected_allowlist, + "current-build deploy payload did not apply the compiled policy to the stale allowlist", + ); +} + +#[test] +fn owner_only_access_deploy_payload_clamps_stale_access() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_for_policy(&record, true); + + assert_eq!( + payload["respond_to"], "owner-only", + "owner-only-access deploy payload widened stale access" + ); + assert_eq!( + payload["respond_to_allowlist"], + serde_json::json!([]), + "owner-only-access deploy payload retained a stale allowlist" + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..e8afddad54 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_access; mod agent_auth; mod agent_config; mod agent_discovery; @@ -61,6 +62,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..fdd75cf8da 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -108,7 +108,6 @@ async fn clear_initial_window_backing(window: &tauri::Window< async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { const MAX_POLLS: usize = 120; const REQUIRED_STABLE_POLLS: usize = 4; - let mut previous_bounds = None; let mut stable_polls = 0; @@ -814,6 +813,7 @@ pub fn run() { get_managed_agent_log, get_agent_models, discover_agent_models, + agent_access_owner_only, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs new file mode 100644 index 0000000000..b75a5f7efb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -0,0 +1,153 @@ +//! Distribution policy at managed-agent enforcement boundaries. + +use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo}; + +pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Release packaging sets `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY`; OSS/custom +/// builds do not. +pub(crate) fn owner_only_access_build() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY").is_some() +} + +pub(crate) fn owner_only() -> bool { + owner_only_with_policy(owner_only_access_build()) +} + +pub(crate) fn owner_only_with_policy(owner_only_access: bool) -> bool { + owner_only_access +} + +/// Project effective access at a behavioral boundary without changing the +/// stored or relay-advertised access fields. +pub(crate) fn projected_access_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> (RespondTo, Vec) { + if owner_only_with_policy(owner_only_access) { + (RespondTo::OwnerOnly, Vec::new()) + } else { + (record.respond_to, record.respond_to_allowlist.clone()) + } +} + +/// Build the inbound-author access environment for a launched agent. The +/// explicit policy input keeps owner-only access enforcement testable without +/// weakening the production caller's compile-time decision. +pub(crate) fn build_respond_to_env_with_policy( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, + enforced_owner_only: bool, +) -> Result { + let (respond_to, _) = projected_access_with_policy(record, enforced_owner_only); + let normalized = validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if respond_to == RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())]; + let mut remove = Vec::new(); + if enforced_owner_only { + set.push(( + "BUZZ_ACP_ALLOWED_RESPOND_TO", + RespondTo::OwnerOnly.as_str().to_string(), + )); + } else { + remove.push("BUZZ_ACP_ALLOWED_RESPOND_TO"); + } + if respond_to == RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + Ok((set, remove)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::BackendKind; + + fn record(backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + record + } + + #[test] + fn owner_only_access_policy_rejects_malformed_stored_allowlist_before_clamping() { + let mut record = record(BackendKind::Local); + record.respond_to_allowlist = vec!["malformed stale allowlist".into()]; + + let error = build_respond_to_env_with_policy(&record, Some("owner"), true) + .expect_err("owner-only access policy accepted a malformed stored allowlist"); + + assert!( + error.contains("invalid pubkey in respond-to allowlist"), + "owner-only access policy returned the wrong malformed-allowlist error: {error}", + ); + } + + #[test] + fn owner_only_access_enforcement_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let record = record(backend); + let (gate_set, _) = + build_respond_to_env_with_policy(&record, Some("owner"), true).unwrap(); + let gate_set: std::collections::HashMap<_, _> = gate_set.into_iter().collect(); + assert_eq!( + gate_set.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only runtime env did not clamp {label} agent", + ); + assert_eq!( + gate_set + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only runtime env omitted the {label} agent guard", + ); + + let (respond_to, allowlist) = projected_access_with_policy(&record, true); + assert_eq!( + respond_to, + RespondTo::OwnerOnly, + "owner-only provider payload did not clamp {label} agent", + ); + assert!( + allowlist.is_empty(), + "owner-only provider payload retained {label} agent allowlist", + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 1653371e7f..28ee366f8a 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -71,11 +71,12 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", - // Security gates: respond-to mode + allowlist + legacy owner-only - // fallback. Overriding would make the running agent's gate diverge - // from the saved/UI-visible settings. + // Security gates: respond-to mode + allowlist + deployment allowlist + + // legacy owner-only fallback. Overriding would make the running agent's + // gate diverge from the saved/UI-visible settings. "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", "BUZZ_ACP_AGENT_OWNER", // Remote lifetime/presence policy: user env must not disable the // desktop/provider-owned bounds while the saved record still promises them. diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 534c2e0835..34cdfede2c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -150,7 +150,11 @@ fn reserved_keys_include_respond_to_gate() { // Respond-to mode + allowlist control who the agent answers. // Overriding via env_vars would let the running agent answer // anyone even when the UI/record says owner-only. - for key in ["BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST"] { + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "anyone")]); let merged = merged_user_env(&BTreeMap::new(), &agent); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f27..bab9486622 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,8 +1,10 @@ +pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; +pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy}; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3173126b90..98a04e1f2a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -16,9 +16,9 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; -pub(crate) use path::compose_path_entries; -pub(crate) use path::should_skip_claude_executable; -pub(crate) use path::should_use_inherited; +pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; + +pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv}; mod metadata; pub(crate) use metadata::{ @@ -32,8 +32,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); - mod process; #[cfg(test)] use process::{ @@ -380,44 +378,7 @@ pub(crate) fn build_respond_to_env( record: &ManagedAgentRecord, owner_hex: Option<&str>, ) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) + build_respond_to_env_with_policy(record, owner_hex, super::owner_only()) } pub(crate) fn configure_runtime_cli( @@ -1022,5 +983,8 @@ pub fn start_managed_agent_process( Ok(()) } +#[cfg(test)] +mod test_fixtures; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs new file mode 100644 index 0000000000..9836d983ed --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -0,0 +1,93 @@ +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"; + +pub(super) fn expected_owner_only() -> bool { + match std::env::var(EXPECTED_ACCESS_ENV) { + Ok(value) => value + .parse::() + .unwrap_or_else(|_| panic!("{EXPECTED_ACCESS_ENV} must be true or false")), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!("{EXPECTED_ACCESS_ENV} must be set for owner-only-access-build tests") + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("{EXPECTED_ACCESS_ENV} must be valid UTF-8") + } + } +} + +pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str { + if expected_owner_only() { + "owner-only" + } else { + oss_mode + } +} + +/// Construct a minimal record fixture for runtime tests. +pub(super) fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + 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, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to, + respond_to_allowlist: allowlist, + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..ebc34fd9ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -117,73 +117,10 @@ fn unknown_command_returns_none() { // ── build_respond_to_env tests ─────────────────────────────────────── -use super::build_respond_to_env; +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}; -/// Construct a minimal record fixture for env-building tests. Only the -/// fields read by `build_respond_to_env` matter here. -fn fixture( - respond_to: RespondTo, - allowlist: Vec, - auth_tag: Option, -) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag, - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - 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, - env_vars: std::collections::BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to, - respond_to_allowlist: allowlist, - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); @@ -195,6 +132,18 @@ fn build_env_owner_only_sets_mode_and_removes_others() { ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + if expected_owner_only() { + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only") + ); + assert!(!remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } else { + assert!(!set_map.contains_key("BUZZ_ACP_ALLOWED_RESPOND_TO")); + assert!(remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } // auth_tag is present → no AGENT_OWNER fallback fires. assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } @@ -214,14 +163,19 @@ fn build_env_allowlist_sets_both_envs_and_joins() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("allowlist") - ); - assert_eq!( - set_map - .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") - .map(String::as_str), - Some(format!("{a},{b}").as_str()), + Some(expected_mode("allowlist")), + "runtime wrapper did not apply the declared build policy", ); + if expected_owner_only() { + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + } else { + assert_eq!( + set_map + .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") + .map(String::as_str), + Some(format!("{a},{b}").as_str()), + ); + } } #[test] @@ -231,7 +185,30 @@ fn build_env_anyone_omits_allowlist_var() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("anyone") + Some(expected_mode("anyone")), + "runtime wrapper did not apply the declared build policy", + ); + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); +} + +#[test] +fn owner_only_access_policy_overrides_stale_anyone_record_at_runtime() { + let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], Some("tag".into())); + let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env widened stale access", + ); + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env omitted the owner-only guard", ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); @@ -271,8 +248,17 @@ fn build_env_rejects_corrupted_allowlist() { #[test] fn build_env_rejects_empty_allowlist_in_allowlist_mode() { let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); - let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); - assert!(err.contains("at least one pubkey")); + if expected_owner_only() { + let (set, _) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + } else { + let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); + assert!(err.contains("at least one pubkey")); + } } // ── persona fixture helpers ───────────────────────────────────────── diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index f2eb7f285c..ae45bb99f5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -152,7 +152,10 @@ with a TypeScript lookup table or an id comparison in a component. shown; when it *is* remote they picked that host from the selector themselves. Never synthesize a run location a surface doesn't have. Don't expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI - copy. + copy. **The owner-only-access build capability is backend-independent.** When + `getAgentAccessOwnerOnly()` is true, every managed agent's access control is + locked to owner-only, including provider-backed agents. A provider backend + does not prove remote execution and must never create a policy carve-out. ## The tests that enforce this diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..7be7e91dcc 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -39,6 +39,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; +import { getAgentAccessOwnerOnly } from "@/shared/api/tauriAgentAccess"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -939,6 +940,20 @@ export function useRuntimeFileConfigQuery( export const bakedBuildEnvKeysQueryKey = ["baked-build-env-keys"] as const; export const bakedBuildEnvQueryKey = ["baked-build-env"] as const; +export const agentAccessOwnerOnlyQueryKey = [ + "agent-access-owner-only", +] as const; + +export function useAgentAccessOwnerOnlyQuery(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: agentAccessOwnerOnlyQueryKey, + queryFn: () => getAgentAccessOwnerOnly(), + enabled: options?.enabled ?? true, + staleTime: Infinity, + refetchInterval: false, + retry: false, + }); +} /** * Query safely displayable baked build env entries. The backend masks secrets, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index adfb8182a8..6598828c4e 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { useAcpRuntimesQuery, + useAgentAccessOwnerOnlyQuery, useAgentConfigSurface, useBakedBuildEnvKeysQuery, usePersonasQuery, @@ -63,9 +64,9 @@ import { type RuntimeModelProviderSelection, } from "./runtimeModelProviderSelection"; import { AgentCreationPreview } from "./AgentCreationPreview"; +import { OwnerOnlyAccessField } from "./OwnerOnlyAccessField"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { useRequiredCredentialState } from "./useRequiredCredentialState"; -import { CreateAgentRespondToField } from "./RespondToField"; import { RunOnSummarySection } from "./RunOnSummarySection"; import { PersonaDropdownField } from "./PersonaDropdownField"; import { @@ -392,6 +393,9 @@ export function AgentInstanceEditDialog({ }); const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); + const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({ + enabled: open, + }); // Merge global env as the base layer so credential keys satisfied via global // config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use @@ -905,7 +909,6 @@ export function AgentInstanceEditDialog({ )}
- {/* Agent name */}
- - {/* Who can send instructions */} - - {/* Provider (runtime) */} diff --git a/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx b/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx new file mode 100644 index 0000000000..4ff7f351a0 --- /dev/null +++ b/desktop/src/features/agents/ui/OwnerOnlyAccessField.tsx @@ -0,0 +1,35 @@ +import type { RespondToMode } from "@/shared/api/types"; +import { + CreateAgentRespondToField, + OWNER_ONLY_ACCESS_DISABLED_REASON, +} from "./RespondToField"; + +export function OwnerOnlyAccessField({ + accessLocked, + allowlist, + disabled, + mode, + onAllowlistChange, + onModeChange, +}: { + accessLocked: boolean; + allowlist: string[]; + disabled: boolean; + mode: RespondToMode; + onAllowlistChange: (allowlist: string[]) => void; + onModeChange: (mode: RespondToMode) => void; +}) { + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 1ecd98b83f..e438368bf5 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -1,8 +1,12 @@ import * as React from "react"; +import { useAgentAccessOwnerOnlyQuery } from "../hooks"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; -import { CreateAgentRespondToField } from "./RespondToField"; +import { + CreateAgentRespondToField, + OWNER_ONLY_ACCESS_DISABLED_REASON, +} from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; import { isBuzzAgentRuntime, @@ -82,6 +86,11 @@ export function PersonaAdvancedFields({ */ selectedRuntime?: AcpRuntimeCatalogEntry; }) { + const { data: agentAccessOwnerOnly = false } = useAgentAccessOwnerOnlyQuery(); + const respondToMode = agentAccessOwnerOnly + ? "owner-only" + : (behaviorDraft.respondTo ?? "owner-only"); + // Numeric tuning descriptors — gate on catalog status so that loading/error // never collapses to "no controls": keys stay visible as generic rows. const numericDescriptors = React.useMemo( @@ -105,9 +114,12 @@ export function PersonaAdvancedFields({ return (
onBehaviorDraftChange({ ...behaviorDraft, diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index b32b9e0983..8f0d02a471 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -72,6 +72,9 @@ const RESPOND_TO_OPTIONS: PersonaDropdownOption[] = [ { label: "Selected people", value: "allowlist" }, ]; +export const OWNER_ONLY_ACCESS_DISABLED_REASON = + "This build disallows changing this setting."; + export function CreateAgentRespondToField({ mode, allowlist, @@ -79,6 +82,7 @@ export function CreateAgentRespondToField({ onAllowlistChange, ownerPubkey, disabled, + disabledReason, variant, runLocation, }: { @@ -93,6 +97,8 @@ export function CreateAgentRespondToField({ */ ownerPubkey?: string | null; disabled?: boolean; + /** Explanation shown when this access control is unavailable. */ + disabledReason?: string; /** When "persona", uses PersonaDropdownField styling to match the persona dialog. */ variant?: "default" | "persona"; /** @@ -219,6 +225,14 @@ export function CreateAgentRespondToField({ ))} )} + {disabledReason ? ( +

+ {disabledReason} +

+ ) : null} {mode === "anyone" ? accessWarning : null} {mode === "owner-only" ? (

diff --git a/desktop/src/features/channels/ui/EditRespondToDialog.tsx b/desktop/src/features/channels/ui/EditRespondToDialog.tsx index d9f091e902..f5bf390519 100644 --- a/desktop/src/features/channels/ui/EditRespondToDialog.tsx +++ b/desktop/src/features/channels/ui/EditRespondToDialog.tsx @@ -1,8 +1,14 @@ import * as React from "react"; -import { useUpdateManagedAgentMutation } from "@/features/agents/hooks"; +import { + useAgentAccessOwnerOnlyQuery, + useUpdateManagedAgentMutation, +} from "@/features/agents/hooks"; import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; -import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField"; +import { + CreateAgentRespondToField, + OWNER_ONLY_ACCESS_DISABLED_REASON, +} from "@/features/agents/ui/RespondToField"; import type { ManagedAgent, RespondToMode } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { @@ -25,6 +31,10 @@ export function EditRespondToDialog({ open: boolean; }) { const updateMutation = useUpdateManagedAgentMutation(); + const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({ + enabled: open, + }); + const accessLocked = agentAccessOwnerOnly === true; const [respondTo, setRespondTo] = React.useState("owner-only"); const [respondToAllowlist, setRespondToAllowlist] = React.useState( [], @@ -61,9 +71,12 @@ export function EditRespondToDialog({