diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index afe94cdfe6..9c7251b9ef 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -691,17 +691,16 @@ fn legacy_managed_agent_auth_tag( .map_err(|error| format!("failed to compute managed agent auth tag: {error}")) } -fn managed_agent_submission_auth_tag( +pub(crate) fn managed_agent_submission_auth_tag( record: &ManagedAgentRecord, - state: &AppState, + owner_keys: &Keys, agent_pubkey: &PublicKey, ) -> Result, String> { if let Some(auth_tag) = stored_managed_agent_auth_tag(record.auth_tag.as_deref()) { return Ok(Some(auth_tag)); } - let owner_keys = state.keys.lock().map_err(|error| error.to_string())?; - legacy_managed_agent_auth_tag(&owner_keys, agent_pubkey) + legacy_managed_agent_auth_tag(owner_keys, agent_pubkey) } fn build_managed_agent_channel_message( @@ -769,8 +768,10 @@ pub async fn send_managed_agent_channel_message( record.pubkey )); } - let submission_auth_tag = - managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?; + let submission_auth_tag = { + let owner_keys = state.keys.lock().map_err(|error| error.to_string())?; + managed_agent_submission_auth_tag(&record, &owner_keys, &keys.public_key())? + }; let thread_ref = match parent_event_id.as_deref() { Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?), None => None, diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index 3c08f0f113..ce642d603d 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -191,3 +191,58 @@ fn legacy_managed_agent_auth_tag_skips_self_attestation() { assert_eq!(tag, None); } + +fn record_with_auth_tag(auth_tag: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "agent", + "name": "agent", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("record fixture should parse"); + record.auth_tag = auth_tag.map(str::to_string); + record +} + +#[test] +fn managed_agent_submission_auth_tag_prefers_stored_tag() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let record = record_with_auth_tag(Some(r#"["auth","owner","","sig"]"#)); + + let tag = managed_agent_submission_auth_tag(&record, &owner_keys, &agent_keys.public_key()) + .expect("stored tag should resolve"); + + assert_eq!(tag.as_deref(), Some(r#"["auth","owner","","sig"]"#)); +} + +#[test] +fn managed_agent_submission_auth_tag_computes_fallback_for_legacy_records() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + + for stored in [None, Some(" ")] { + let record = record_with_auth_tag(stored); + + let tag = managed_agent_submission_auth_tag(&record, &owner_keys, &agent_keys.public_key()) + .expect("fallback should compute") + .expect("fallback tag should be present"); + + let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key()) + .expect("fallback tag should verify"); + assert_eq!(owner, owner_keys.public_key()); + } +} diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index e4a8ad7b41..c2ae97e3dd 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -48,6 +48,11 @@ pub(crate) struct GitAuthConfig { git_path: std::path::PathBuf, credential_helper: Option, nsec: String, + /// NIP-OA owner attestation for managed agents, which normally rely on + /// their owner's relay membership rather than their own (NIP-AA). Exported + /// as `BUZZ_AUTH_TAG` so the credential helper embeds it in the signed + /// NIP-98 event for the relay's membership check. + auth_tag: Option, allow_file_transport: bool, } @@ -139,6 +144,12 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig, needs_credent "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_SSH_COMMAND", "GIT_EXTERNAL_DIFF", + // Clear the helper's auth inputs and the desktop's own identity + // override up front; NOSTR_PRIVATE_KEY and BUZZ_AUTH_TAG are set + // explicitly below for helper-backed credentialed operations. + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_PRIVATE_KEY", ] { command.env_remove(key); } @@ -173,6 +184,9 @@ fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig, needs_credent return apply_git_config(command, &entries); }; command.env("NOSTR_PRIVATE_KEY", &auth.nsec); + if let Some(auth_tag) = &auth.auth_tag { + command.env("BUZZ_AUTH_TAG", auth_tag); + } entries.push(( "credential.helper", credential_helper_config_value(cred_helper), @@ -198,12 +212,30 @@ fn apply_git_config(command: &mut Command, entries: &[(&str, String)]) { } } +/// The repository-owner identity project operations run as: the owner's keys +/// plus, for managed agents, the NIP-OA owner attestation presented to relays +/// that admit agents through their owner (NIP-AA). +pub(crate) struct ProjectOwnerIdentity { + pub(crate) keys: Keys, + pub(crate) auth_tag: Option, +} + +pub(crate) fn build_git_auth_config_for_identity( + identity: &ProjectOwnerIdentity, +) -> Result { + build_git_auth_config_for_keys(&identity.keys, identity.auth_tag.as_deref()) +} + pub(crate) fn build_git_auth_config(state: &AppState) -> Result { let keys = state.signing_keys()?; - build_git_auth_config_for_keys(&keys) + // Viewer-owned operations do not use an owner attestation. + build_git_auth_config_for_keys(&keys, None) } -pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result { +fn build_git_auth_config_for_keys( + keys: &Keys, + auth_tag: Option<&str>, +) -> Result { let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; let credential_helper = resolve_command("git-credential-nostr"); let nsec = keys @@ -214,13 +246,14 @@ pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result Result { - let mut auth = build_git_auth_config_for_keys(&Keys::generate())?; + let mut auth = build_git_auth_config_for_keys(&Keys::generate(), None)?; auth.allow_file_transport = true; Ok(auth) } @@ -327,9 +360,91 @@ fn validate_clone_url_against_relay(clone_url: &str, relay_base: &str) -> Result #[cfg(test)] mod tests { use super::{ - clean_branch, clean_target_ref, credential_helper_config_value, git_needs_credentials, - git_subcommand, validate_clone_url, validate_clone_url_against_relay, + clean_branch, clean_target_ref, configure_git_auth, credential_helper_config_value, + git_needs_credentials, git_subcommand, validate_clone_url, + validate_clone_url_against_relay, GitAuthConfig, }; + use std::process::Command; + + fn credentialed_auth_config(auth_tag: Option<&str>) -> GitAuthConfig { + GitAuthConfig { + git_path: "git".into(), + credential_helper: Some("git-credential-nostr".into()), + nsec: "nsec1testonly".into(), + auth_tag: auth_tag.map(str::to_owned), + allow_file_transport: false, + } + } + + fn env_value(command: &Command, key: &str) -> Option> { + command + .get_envs() + .find(|(name, _)| name.to_str() == Some(key)) + .map(|(_, value)| value.map(|value| value.to_string_lossy().into_owned())) + } + + #[test] + fn credentialed_git_exports_owner_attestation() { + let tag = r#"["auth","owner","conditions","sig"]"#; + let mut command = Command::new("git"); + configure_git_auth(&mut command, &credentialed_auth_config(Some(tag)), true); + assert_eq!( + env_value(&command, "BUZZ_AUTH_TAG"), + Some(Some(tag.to_string())) + ); + assert_eq!( + env_value(&command, "NOSTR_PRIVATE_KEY"), + Some(Some("nsec1testonly".to_string())) + ); + assert_eq!(env_value(&command, "BUZZ_PRIVATE_KEY"), Some(None)); + } + + #[test] + fn credentialed_git_without_attestation_scrubs_inherited_tag() { + let mut command = Command::new("git"); + configure_git_auth(&mut command, &credentialed_auth_config(None), true); + assert_eq!(env_value(&command, "BUZZ_AUTH_TAG"), Some(None)); + } + + #[test] + fn local_git_scrubs_ambient_identity_env() { + let tag = r#"["auth","owner","conditions","sig"]"#; + let mut command = Command::new("git"); + configure_git_auth(&mut command, &credentialed_auth_config(Some(tag)), false); + assert_eq!(env_value(&command, "BUZZ_AUTH_TAG"), Some(None)); + assert_eq!(env_value(&command, "NOSTR_PRIVATE_KEY"), Some(None)); + assert_eq!(env_value(&command, "BUZZ_PRIVATE_KEY"), Some(None)); + } + + #[test] + fn missing_credential_helper_scrubs_identity_env() { + let tag = r#"["auth","owner","conditions","sig"]"#; + let mut auth = credentialed_auth_config(Some(tag)); + auth.credential_helper = None; + let mut command = Command::new("git"); + configure_git_auth(&mut command, &auth, true); + assert_eq!(env_value(&command, "BUZZ_AUTH_TAG"), Some(None)); + assert_eq!(env_value(&command, "NOSTR_PRIVATE_KEY"), Some(None)); + assert_eq!(env_value(&command, "BUZZ_PRIVATE_KEY"), Some(None)); + } + + #[test] + fn owner_identity_attestation_reaches_git_env() { + let tag = r#"["auth","owner","conditions","sig"]"#; + let identity = super::ProjectOwnerIdentity { + keys: nostr::Keys::generate(), + auth_tag: Some(tag.to_string()), + }; + let mut auth = + super::build_git_auth_config_for_identity(&identity).expect("build auth config"); + auth.credential_helper = Some("git-credential-nostr".into()); + let mut command = Command::new("git"); + configure_git_auth(&mut command, &auth, true); + assert_eq!( + env_value(&command, "BUZZ_AUTH_TAG"), + Some(Some(tag.to_string())) + ); + } #[test] fn credential_helper_config_value_uses_forward_slashes() { diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 39832feb10..f5a7d3350f 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -1,10 +1,11 @@ //! Local clone and pull-request merge commands for the Projects workflow. +use super::messages::managed_agent_submission_auth_tag; use super::project_git::{first_output_line, normalize_branch_option}; use super::project_git_diff::clean_commit; use super::project_git_exec::{ - build_git_auth_config, build_git_auth_config_for_keys, clone_url_owner, run_git, - validate_clone_url, validate_workspace_clone_url, GitAuthConfig, + build_git_auth_config, build_git_auth_config_for_identity, clone_url_owner, run_git, + validate_clone_url, validate_workspace_clone_url, GitAuthConfig, ProjectOwnerIdentity, }; use super::project_repo_paths::{ canonical_repos_roots, canonicalize_repos_root, default_repos_root_candidates, @@ -155,11 +156,6 @@ fn normalize_event_id(value: &str) -> Option { (value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value) } -struct ProjectOwnerIdentity { - keys: Keys, - auth_tag: Option, -} - fn project_owner_identity( app: &AppHandle, state: &AppState, @@ -173,19 +169,22 @@ fn project_owner_identity( }); } - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let records = load_managed_agents(app)?; - let record = records - .iter() - .find(|record| record.pubkey.eq_ignore_ascii_case(target_owner)) - .ok_or_else(|| { - "Only the repository owner or the owner of its managed agent can merge pull requests." - .to_string() - })?; - if let Some(error) = spawn_key_refusal(record) { + let record = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(app)?; + records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(target_owner)) + .cloned() + .ok_or_else(|| { + "Only the repository owner or the owner of its managed agent can merge pull requests." + .to_string() + })? + }; + if let Some(error) = spawn_key_refusal(&record) { return Err(error); } let keys = Keys::parse(&record.private_key_nsec) @@ -193,10 +192,11 @@ fn project_owner_identity( if keys.public_key().to_hex() != target_owner { return Err("Managed agent key does not match the repository owner.".to_string()); } - Ok(ProjectOwnerIdentity { - keys, - auth_tag: record.auth_tag.clone(), - }) + // Stored attestation, or a freshly computed one for pre-NIP-OA records; + // the same fallback the managed-agent message path uses. Computed from the + // viewer keys captured above so the identity is one coherent snapshot. + let auth_tag = managed_agent_submission_auth_tag(&record, &viewer_keys, &keys.public_key())?; + Ok(ProjectOwnerIdentity { keys, auth_tag }) } fn validate_repo_address(repo_address: &str, owner: &str) -> Result<(), String> { @@ -604,7 +604,7 @@ pub async fn merge_project_pull_request( &pull_request_id, &pull_request_author, )?; - let auth = build_git_auth_config_for_keys(&owner_identity.keys)?; + let auth = build_git_auth_config_for_identity(&owner_identity)?; let git_result = tauri::async_runtime::spawn_blocking( move || -> Result { diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index dcb8095a7c..dd2d68a9d7 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -211,8 +211,9 @@ pub struct ManagedAgentRecord { /// NIP-OA auth tag JSON. Computed at agent creation time. /// /// Pre-existing agents created before NIP-OA will have `None` here. - /// This is intentional — they continue to work without attestation. - /// Re-attestation requires agent recreation (v2 migration scope). + /// This is intentional; message and project submissions compute a + /// non-persisted fallback from the owner's keys on demand, so agent + /// recreation is only needed to persist a tag (v2 migration scope). #[serde(default)] pub auth_tag: Option, pub relay_url: String,