diff --git a/Cargo.lock b/Cargo.lock index ef632433429..d5651413f51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15434,6 +15434,7 @@ dependencies = [ "warp_managed_secrets", "warp_multi_agent_api", "warp_multi_agent_client", + "warp_request_context", "warp_ripgrep", "warp_search_core", "warp_server_auth", @@ -15854,6 +15855,7 @@ dependencies = [ "warp_core", "warp_graphql", "warp_isolation_platform", + "warp_request_context", "warpui_core", "zeroize", ] @@ -15888,6 +15890,16 @@ dependencies = [ "warp_server_client", ] +[[package]] +name = "warp_request_context" +version = "0.1.0" +dependencies = [ + "schemars 1.0.4", + "serde", + "serde_json", + "settings_value", +] + [[package]] name = "warp_ripgrep" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0dbb0ffba74..7075128d226 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ warp_js = { path = "crates/warp_js" } warp_logging = { path = "crates/warp_logging" } warp_managed_secrets = { path = "crates/managed_secrets" } warp_multi_agent_client = { path = "crates/warp_multi_agent_client" } +warp_request_context = { path = "crates/request_context" } warp_ripgrep = { path = "crates/warp_ripgrep" } warp_search_core = { path = "crates/warp_search_core" } warp_server_auth = { path = "crates/warp_server_auth" } diff --git a/app/Cargo.toml b/app/Cargo.toml index 1f90fcf3142..e521f29f55d 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -268,6 +268,7 @@ warp_isolation_platform.workspace = true warp_ripgrep.workspace = true warp_managed_secrets.workspace = true warp_multi_agent_client.workspace = true +warp_request_context.workspace = true local_control.workspace = true hashbrown.workspace = true rustc-hash.workspace = true diff --git a/app/src/ai/agent/api/convert_from.rs b/app/src/ai/agent/api/convert_from.rs index 1d42d378f31..f899ed467d1 100644 --- a/app/src/ai/agent/api/convert_from.rs +++ b/app/src/ai/agent/api/convert_from.rs @@ -143,9 +143,8 @@ fn convert_run_agents( }) .collect(), plan_id, - // Auth secret is a client-side dispatch concern populated by the - // confirmation card from `CloudAgentSettings.last_selected_auth_secret` - // before Accept. The proto does not carry it. + // Auth secret is a client-side dispatch concern populated from scoped settings before + // Accept. The proto does not carry it. harness_auth_secret_name: None, }) } diff --git a/app/src/ai/agent/api/impl.rs b/app/src/ai/agent/api/impl.rs index f963ec6bfba..626cf9c68b6 100644 --- a/app/src/ai/agent/api/impl.rs +++ b/app/src/ai/agent/api/impl.rs @@ -145,7 +145,7 @@ pub async fn generate_multi_agent_output( let response_stream = warp_multi_agent_client::generate_multi_agent_output( server_api.as_ref(), &request, - team_scope.team_uid().map(|uid| uid.uid()), + team_scope.team_uid().map(str::to_string), ) .await; match response_stream { diff --git a/app/src/ai/agent/api_tests.rs b/app/src/ai/agent/api_tests.rs index 731f781a8b2..83168e0e1dd 100644 --- a/app/src/ai/agent/api_tests.rs +++ b/app/src/ai/agent/api_tests.rs @@ -3,7 +3,6 @@ use warp_core::channel::{Channel, ChannelState}; use super::{RequestParams, ServerConversationToken}; use crate::ai::agent::ServerOutputId; use crate::server::ids::ServerId; -use crate::server::team_scope::RequestTeamScope; use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; #[test] @@ -69,11 +68,11 @@ fn request_team_scope_sends_the_team_its_scope_named() { let scope = TeamContextForOperation::new_for_test(team_uid); assert_eq!( - RequestTeamScope::from_scope(&scope).team_uid(), - Some(team_uid) + crate::server::team_scope::request_team_scope(&scope).team_uid(), + Some(team_uid.uid().as_str()) ); assert_eq!( - RequestTeamScope::from_scope(&TeamlessScopeForTest).team_uid(), + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest).team_uid(), None ); } diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 549dd2c9976..4ef7ef67ed4 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -46,7 +46,6 @@ use crate::server::server_api::ai::{ ListAgentMessagesRequest, ReadAgentMessageResponse, RunSortBy, RunSortOrder, SendAgentMessageRequest, SendAgentMessageResponse, SpawnAgentRequest, TaskListFilter, }; -use crate::server::team_scope::RequestTeamScope; use crate::terminal::shared_session; use crate::util::time_format::format_approx_duration_from_now_utc; use crate::workspaces::user_workspaces::{TeamScopeForCli, UserWorkspaces}; @@ -555,7 +554,7 @@ impl AmbientAgentRunner { let should_open = args.open; let oz_root_url = ChannelState::oz_root_url(); let ai_client_clone = ai_client.clone(); - let request_team_scope = RequestTeamScope::from_scope(&team_scope); + let request_team_scope = crate::server::team_scope::request_team_scope(&team_scope); let spawn_future = async move { let mut stream = Box::pin(spawn_task( request, diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index ed7fe3eff3c..8a77d0e45af 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -73,7 +73,6 @@ use crate::send_telemetry_sync_from_app_ctx; use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::ServerApiProvider; use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot, GitCredential}; -use crate::server::team_scope::RequestTeamScope; use crate::terminal::view::ConversationRestorationInNewPaneType; use crate::workflows::workflow::Workflow; use crate::workspaces::user_workspaces::TeamScopeForCli; @@ -1211,7 +1210,7 @@ impl AgentDriverRunner { team_scope: TeamScopeForCli, driver_options: &mut AgentDriverOptions, ) -> Result<(), AgentDriverError> { - let request_team_scope = RequestTeamScope::from_scope(&team_scope); + let request_team_scope = crate::server::team_scope::request_team_scope(&team_scope); driver_options.team_scope = Some(team_scope); let environment = merged_config.environment_id.clone(); let task_config = if merged_config.is_empty() { diff --git a/app/src/ai/agent_sdk/mod_tests.rs b/app/src/ai/agent_sdk/mod_tests.rs index e89c7ad7bc9..f16327bbfe3 100644 --- a/app/src/ai/agent_sdk/mod_tests.rs +++ b/app/src/ai/agent_sdk/mod_tests.rs @@ -130,12 +130,15 @@ fn multi_team_run_passes_selected_team_to_task_creation_and_headless_window() { .unwrap() .expect("new local run should resolve a scope"); assert_eq!(team_scope.team_uid(), Some(selected_team_uid)); + let selected_team_uid_string = selected_team_uid.uid(); let mut ai_client = MockAIClient::new(); ai_client .expect_create_agent_task() .times(1) - .withf(move |_, _, _, _, scope| scope.team_uid() == Some(selected_team_uid)) + .withf(move |_, _, _, _, scope| { + scope.team_uid() == Some(selected_team_uid_string.as_str()) + }) .returning(|_, _, _, _, _| Ok(TASK_ID.parse().unwrap())); let ai_client: Arc = Arc::new(ai_client); let mut driver_options = agent_driver_options(); diff --git a/app/src/ai/agent_sdk/secret.rs b/app/src/ai/agent_sdk/secret.rs index 908dbd82457..686288d0ce3 100644 --- a/app/src/ai/agent_sdk/secret.rs +++ b/app/src/ai/agent_sdk/secret.rs @@ -219,61 +219,73 @@ fn create_secret_with_input( description: Option, scope: ObjectScope, ) -> Result<()> { - ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| { + ManagedSecretManager::handle(ctx).update(ctx, move |_managed_secrets, ctx| { // Perform as much validation as possible up-front, before prompting the user for a secret. // It's a bad UX if we make them type in a secret and then fail on something we could have // checked beforehand. let refresh_future = super::common::refresh_workspace_metadata(ctx); - ctx.spawn(refresh_future, move |manager, refresh_result, ctx| { - if let Err(err) = refresh_result { - super::report_fatal_error(err, ctx); - return; - } - - let owner = match super::common::resolve_owner(&scope, ctx) { - Ok(owner) => owner, - Err(err) => { + ctx.spawn( + refresh_future, + move |managed_secrets, refresh_result, ctx| { + if let Err(err) = refresh_result { super::report_fatal_error(err, ctx); return; } - }; - let managed_value = match input.read() { - Ok(Some(value)) => value, - Ok(None) => { - // Treat this as a cancellation. - ctx.terminate_app(TerminationMode::ForceTerminate, None); - return; - } - Err(err) => { - super::report_fatal_error(err, ctx); - return; - } - }; + let owner = match super::common::resolve_owner(&scope, ctx) { + Ok(owner) => owner, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; + let team_scope = match super::common::resolve_team_scope(&scope.team_selection, ctx) + { + Ok(team_scope) => crate::server::team_scope::request_team_scope(&team_scope), + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; - let secret_owner = match owner { - Owner::User { .. } => SecretOwner::CurrentUser, - Owner::Team { team_uid } => SecretOwner::Team { - team_uid: team_uid.uid(), - }, - }; + let managed_value = match input.read() { + Ok(Some(value)) => value, + Ok(None) => { + // Treat this as a cancellation. + ctx.terminate_app(TerminationMode::ForceTerminate, None); + return; + } + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; - let create_future = manager.create_secret( - secret_owner, - name.clone(), - managed_value, - description.clone(), - ); - ctx.spawn(create_future, move |_, result, ctx| match result { - Ok(secret) => { - println!("Secret '{}' created", secret.name); - ctx.terminate_app(TerminationMode::ForceTerminate, None); - } - Err(err) => { - super::report_fatal_error(err, ctx); - } - }); - }); + let secret_owner = match owner { + Owner::User { .. } => SecretOwner::CurrentUser, + Owner::Team { team_uid } => SecretOwner::Team { + team_uid: team_uid.uid(), + }, + }; + + let create_future = managed_secrets.create_secret( + team_scope, + secret_owner, + name.clone(), + managed_value, + description.clone(), + ); + ctx.spawn(create_future, move |_, result, ctx| match result { + Ok(secret) => { + println!("Secret '{}' created", secret.name); + ctx.terminate_app(TerminationMode::ForceTerminate, None); + } + Err(err) => { + super::report_fatal_error(err, ctx); + } + }); + }, + ); }); Ok(()) @@ -285,10 +297,10 @@ fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> { let force = args.force; let scope = args.scope; - ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| { + ManagedSecretManager::handle(ctx).update(ctx, move |_managed_secrets, ctx| { let refresh_future = super::common::refresh_workspace_metadata(ctx); let name = name.clone(); - ctx.spawn(refresh_future, move |manager, refresh_result, ctx| { + ctx.spawn(refresh_future, move |managed_secrets, refresh_result, ctx| { if let Err(err) = refresh_result { super::report_fatal_error(err, ctx); return; @@ -301,6 +313,13 @@ fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> { return; } }; + let team_scope = match super::common::resolve_team_scope(&scope.team_selection, ctx) { + Ok(team_scope) => crate::server::team_scope::request_team_scope(&team_scope), + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; let secret_owner = match owner { Owner::User { .. } => SecretOwner::CurrentUser, @@ -350,7 +369,8 @@ fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> { } } - let delete_future = manager.delete_secret(secret_owner, name.clone()); + let delete_future = + managed_secrets.delete_secret(team_scope, secret_owner, name.clone()); ctx.spawn(delete_future, move |_, result, ctx| match result { Ok(()) => { println!("Secret '{name}' deleted"); @@ -369,84 +389,117 @@ fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> { /// Update a secret. fn update_secret(ctx: &mut AppContext, args: UpdateSecretArgs) -> Result<()> { - ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| { + ManagedSecretManager::handle(ctx).update(ctx, move |_managed_secrets, ctx| { // Perform as much validation as possible up-front, before prompting the user for a secret. let refresh_future = super::common::refresh_workspace_metadata(ctx); - ctx.spawn(refresh_future, move |manager, refresh_result, ctx| { - if let Err(err) = refresh_result { - super::report_fatal_error(err, ctx); - return; - } - - let owner = match super::common::resolve_owner(&args.scope, ctx) { - Ok(owner) => owner, - Err(err) => { + ctx.spawn( + refresh_future, + move |managed_secrets, refresh_result, ctx| { + if let Err(err) = refresh_result { super::report_fatal_error(err, ctx); return; } - }; - // Read the secret value if either --value or --value-file is provided. - let secret_value = if args.value || args.value_args.value_file.is_some() { - // Create ValueArgs to handle reading from file or prompting - match read_simple_secret_value(&args.value_args) { - Ok(Some(value)) => Some(value), - Ok(None) => { - // Treat this as a cancellation. - ctx.terminate_app(TerminationMode::ForceTerminate, None); - return; - } + let owner = match super::common::resolve_owner(&args.scope, ctx) { + Ok(owner) => owner, Err(err) => { super::report_fatal_error(err, ctx); return; } - } - } else { - None - }; - - let secret_owner = match owner { - Owner::User { .. } => SecretOwner::CurrentUser, - Owner::Team { team_uid } => SecretOwner::Team { - team_uid: team_uid.uid(), - }, - }; - - if let Some(secret_value) = secret_value { - // Look up the existing secret's type so we use the correct ManagedSecretValue variant. - let list_future = manager.list_secrets(); - ctx.spawn(list_future, move |manager, list_result, ctx| { - let secrets = match list_result { - Ok(secrets) => secrets, + }; + let team_scope = + match super::common::resolve_team_scope(&args.scope.team_selection, ctx) { + Ok(team_scope) => { + crate::server::team_scope::request_team_scope(&team_scope) + } Err(err) => { super::report_fatal_error(err, ctx); return; } }; - let secret_type = match find_secret_type(&secrets, &args.name, &secret_owner) { - Some(t) => t, - None => { - super::report_fatal_error( - anyhow::anyhow!("Secret '{}' not found", args.name), - ctx, - ); + // Read the secret value if either --value or --value-file is provided. + let secret_value = if args.value || args.value_args.value_file.is_some() { + // Create ValueArgs to handle reading from file or prompting + match read_simple_secret_value(&args.value_args) { + Ok(Some(value)) => Some(value), + Ok(None) => { + // Treat this as a cancellation. + ctx.terminate_app(TerminationMode::ForceTerminate, None); return; } - }; + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + } + } else { + None + }; + + let secret_owner = match owner { + Owner::User { .. } => SecretOwner::CurrentUser, + Owner::Team { team_uid } => SecretOwner::Team { + team_uid: team_uid.uid(), + }, + }; - let managed_secret_value = - match make_secret_value_from_gql_type(secret_type, &secret_value) { - Ok(v) => v, + if let Some(secret_value) = secret_value { + // Look up the existing secret's type so we use the correct ManagedSecretValue variant. + let list_future = managed_secrets.list_secrets(team_scope.clone()); + ctx.spawn(list_future, move |managed_secrets, list_result, ctx| { + let secrets = match list_result { + Ok(secrets) => secrets, Err(err) => { super::report_fatal_error(err, ctx); return; } }; - let update_future = manager.update_secret( + + let secret_type = + match find_secret_type(&secrets, &args.name, &secret_owner) { + Some(t) => t, + None => { + super::report_fatal_error( + anyhow::anyhow!("Secret '{}' not found", args.name), + ctx, + ); + return; + } + }; + + let managed_secret_value = + match make_secret_value_from_gql_type(secret_type, &secret_value) { + Ok(v) => v, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; + let update_future = managed_secrets.update_secret( + team_scope, + secret_owner, + args.name.clone(), + Some(managed_secret_value), + args.description.clone(), + ); + ctx.spawn(update_future, move |_, result, ctx| match result { + Ok(secret) => { + println!("Secret '{}' updated", secret.name); + ctx.terminate_app(TerminationMode::ForceTerminate, None); + } + Err(err) => { + super::report_fatal_error(err, ctx); + } + }); + }); + } else { + // Description-only update; no encryption needed. + let update_future = managed_secrets.update_secret( + team_scope, secret_owner, args.name.clone(), - Some(managed_secret_value), + None, args.description.clone(), ); ctx.spawn(update_future, move |_, result, ctx| match result { @@ -458,26 +511,9 @@ fn update_secret(ctx: &mut AppContext, args: UpdateSecretArgs) -> Result<()> { super::report_fatal_error(err, ctx); } }); - }); - } else { - // Description-only update; no encryption needed. - let update_future = manager.update_secret( - secret_owner, - args.name.clone(), - None, - args.description.clone(), - ); - ctx.spawn(update_future, move |_, result, ctx| match result { - Ok(secret) => { - println!("Secret '{}' updated", secret.name); - ctx.terminate_app(TerminationMode::ForceTerminate, None); - } - Err(err) => { - super::report_fatal_error(err, ctx); - } - }); - } - }); + } + }, + ); }); Ok(()) @@ -487,38 +523,61 @@ fn update_secret(ctx: &mut AppContext, args: UpdateSecretArgs) -> Result<()> { fn list_secrets( ctx: &mut AppContext, output_format: OutputFormat, - _args: ListSecretsArgs, + args: ListSecretsArgs, ) -> Result<()> { - ManagedSecretManager::handle(ctx).update(ctx, |manager, ctx| { - ctx.spawn(manager.list_secrets(), move |_, result, ctx| match result { - Ok(secrets) => { - let secret_infos = secrets.into_iter().map(|secret| { - let owner = match secret.owner.type_ { - SpaceType::User => Owner::User { - user_uid: UserUid::new(secret.owner.uid.inner()), - }, - SpaceType::Team => Owner::Team { - team_uid: ServerId::from_string_lossy(secret.owner.uid.inner()), - }, - }; - - SecretInfo { - name: secret.name, - scope: super::common::format_owner(&owner).to_string(), - secret_type: secret.type_, - created_at: secret.created_at.utc(), - updated_at: secret.updated_at.utc(), + ManagedSecretManager::handle(ctx).update(ctx, move |_managed_secrets, ctx| { + let refresh_future = super::common::refresh_workspace_metadata(ctx); + ctx.spawn( + refresh_future, + move |managed_secrets, refresh_result, ctx| { + if let Err(err) = refresh_result { + super::report_fatal_error(err, ctx); + return; + } + let team_scope = match super::common::resolve_team_scope(&args.team_selection, ctx) + { + Ok(team_scope) => crate::server::team_scope::request_team_scope(&team_scope), + Err(err) => { + super::report_fatal_error(err, ctx); + return; } - }); - - output::print_list(secret_infos, output_format); + }; + ctx.spawn( + managed_secrets.list_secrets(team_scope), + move |_, result, ctx| match result { + Ok(secrets) => { + let secret_infos = secrets.into_iter().map(|secret| { + let owner = match secret.owner.type_ { + SpaceType::User => Owner::User { + user_uid: UserUid::new(secret.owner.uid.inner()), + }, + SpaceType::Team => Owner::Team { + team_uid: ServerId::from_string_lossy( + secret.owner.uid.inner(), + ), + }, + }; + + SecretInfo { + name: secret.name, + scope: super::common::format_owner(&owner).to_string(), + secret_type: secret.type_, + created_at: secret.created_at.utc(), + updated_at: secret.updated_at.utc(), + } + }); + + output::print_list(secret_infos, output_format); - ctx.terminate_app(TerminationMode::ForceTerminate, None); - } - Err(err) => { - super::report_fatal_error(err, ctx); - } - }); + ctx.terminate_app(TerminationMode::ForceTerminate, None); + } + Err(err) => { + super::report_fatal_error(err, ctx); + } + }, + ); + }, + ); }); Ok(()) } diff --git a/app/src/ai/ambient_agents/spawn_tests.rs b/app/src/ai/ambient_agents/spawn_tests.rs index a2ef76d948f..7a316609a86 100644 --- a/app/src/ai/ambient_agents/spawn_tests.rs +++ b/app/src/ai/ambient_agents/spawn_tests.rs @@ -693,19 +693,20 @@ fn run_id() -> crate::ai::ambient_agents::AmbientAgentTaskId { "550e8400-e29b-41d4-a716-446655440000".parse().unwrap() } fn request_team_scope() -> RequestTeamScope { - RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(7.into())) + crate::server::team_scope::request_team_scope(&TeamContextForOperation::new_for_test(7.into())) } #[tokio::test] async fn spawn_uses_resolved_team_scope() { use futures::StreamExt; - let team_uid = 7.into(); + let team_uid = crate::server::ids::ServerId::from(7); + let expected_team_uid = team_uid.uid(); let mut mock = MockAIClient::new(); mock.expect_spawn_agent() .times(1) .withf(move |request, team_scope| { - request.team == Some(true) && team_scope.team_uid() == Some(team_uid) + request.team == Some(true) && team_scope.team_uid() == Some(expected_team_uid.as_str()) }) .returning(|_, _| { Ok(SpawnAgentResponse { @@ -736,7 +737,9 @@ async fn spawn_uses_resolved_team_scope() { snapshot_disabled: None, orchestration_handoff: None, }; - let team_scope = RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)); + let team_scope = crate::server::team_scope::request_team_scope( + &TeamContextForOperation::new_for_test(team_uid), + ); let mut stream = Box::pin(spawn_task(request, team_scope, Arc::new(mock), None)); assert!(matches!( diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index 64c2c47e598..cfca5d5a6d0 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -98,7 +98,6 @@ use crate::ai::blocklist::telemetry::send_run_agents_completed_telemetry; use crate::ai::get_relevant_files::controller::GetRelevantFilesController; #[cfg(feature = "local_fs")] use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path}; -use crate::server::team_scope::RequestTeamScope; use crate::terminal::model::session::active_session::ActiveSession; use crate::terminal::model::session::command_executor::shell_quote_arg; use crate::terminal::model::session::{ExecuteCommandOptions, Session}; @@ -345,8 +344,14 @@ impl BlocklistAIActionExecutor { let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone())); let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new()); let start_agent_executor = ctx.add_model(StartAgentExecutor::new); - let run_agents_executor = ctx - .add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id)); + let team_context_resolver_for_run_agents = team_context_resolver.clone(); + let run_agents_executor = ctx.add_model(|_| { + RunAgentsExecutor::new( + start_agent_executor.clone(), + terminal_view_id, + team_context_resolver_for_run_agents, + ) + }); let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new()); let ask_user_question_executor = ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id)); @@ -696,7 +701,9 @@ impl BlocklistAIActionExecutor { let team_context_resolver = self.team_context_resolver.clone(); self.search_codebase_executor .update(ctx, |executor, ctx| { - let team_scope = RequestTeamScope::from_scope(&team_context_resolver(ctx)); + let team_scope = crate::server::team_scope::request_team_scope( + &team_context_resolver(ctx), + ); executor.execute(input, team_scope, ctx) }) .into() diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index d7aa191e30a..d673f127bb4 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -40,6 +40,8 @@ use crate::ai::orchestration::{ populate_default_auth_secret_for_execution, }; use crate::features::FeatureFlag; +use crate::server::team_scope::RequestTeamScope; +use crate::workspaces::user_workspaces::TeamContextResolver; /// Per-child spawn timeout. If a child agent doesn't report back within /// this window (e.g. binary not found, server error), the slot is failed @@ -70,6 +72,7 @@ pub struct RunAgentsExecutor { launched_agents: HashMap>, start_agent_executor: ModelHandle, terminal_view_id: EntityId, + team_context_resolver: TeamContextResolver, } /// Lifecycle events for in-flight dispatches. @@ -91,15 +94,21 @@ impl RunAgentsExecutor { pub fn new( start_agent_executor: ModelHandle, terminal_view_id: EntityId, + team_context_resolver: TeamContextResolver, ) -> Self { Self { pending: HashMap::new(), launched_agents: HashMap::new(), start_agent_executor, terminal_view_id, + team_context_resolver, } } + fn request_team_scope(&self, ctx: &ModelContext) -> RequestTeamScope { + crate::server::team_scope::request_team_scope(&(self.team_context_resolver)(ctx)) + } + pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool { self.pending.contains_key(action_id) } @@ -166,6 +175,7 @@ impl RunAgentsExecutor { action_id: AIAgentActionId, request: RunAgentsRequest, parent_conversation_id: AIConversationId, + team_scope: RequestTeamScope, ctx: &mut ModelContext, ) -> async_channel::Receiver { let (sender, receiver) = async_channel::bounded(1); @@ -210,6 +220,7 @@ impl RunAgentsExecutor { action_id_for_wait.clone(), request, parent_conversation_id, + team_scope, sender, ctx, ) @@ -224,6 +235,7 @@ impl RunAgentsExecutor { action_id: AIAgentActionId, request: RunAgentsRequest, parent_conversation_id: AIConversationId, + team_scope: RequestTeamScope, sender: async_channel::Sender, ctx: &mut ModelContext, ) { @@ -277,6 +289,7 @@ impl RunAgentsExecutor { None, /* lifecycle_subscription */ parent_conversation_id, parent_run_id.clone(), + team_scope.clone(), exec_ctx, ) }); @@ -389,11 +402,13 @@ impl RunAgentsExecutor { let mut request = request.clone(); let action_id = id.clone(); let parent_conversation_id = input.conversation_id; + let team_scope = self.request_team_scope(ctx); if let Some(reason) = prepare_request_for_execution( &mut request, parent_conversation_id, self.terminal_view_id, &self.launched_agents, + team_scope.clone(), ctx, ) { let result = RunAgentsResult::Denied { reason }; @@ -407,8 +422,13 @@ impl RunAgentsExecutor { } let telemetry_request = request.clone(); - let receiver = - self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx); + let receiver = self.dispatch_prepared_run_agents( + action_id, + request, + parent_conversation_id, + team_scope, + ctx, + ); ActionExecution::new_async(async move { receiver.recv().await }, move |result, ctx| { let result = match result { @@ -450,18 +470,23 @@ impl RunAgentsExecutor { return true; } let mut resolved_request = request.clone(); + let team_scope = self.request_team_scope(ctx); resolve_request_from_approved_config(&mut resolved_request, input.conversation_id, ctx); - populate_default_auth_secret_for_execution(&mut resolved_request, ctx); + populate_default_auth_secret_for_execution(&mut resolved_request, team_scope.clone(), ctx); if self .duplicate_launched_agents_reason(&resolved_request, input.conversation_id, ctx) .is_some() { return true; } - approved_orchestration_config_can_autoexecute(request, input.conversation_id, ctx) - || BlocklistAIPermissions::as_ref(ctx) - .get_run_agents_setting(ctx, Some(self.terminal_view_id)) - .is_always_allow() + approved_orchestration_config_can_autoexecute( + request, + input.conversation_id, + team_scope, + ctx, + ) || BlocklistAIPermissions::as_ref(ctx) + .get_run_agents_setting(ctx, Some(self.terminal_view_id)) + .is_always_allow() } pub(super) fn preprocess_action( @@ -485,12 +510,13 @@ enum ChildSlot { fn approved_orchestration_config_can_autoexecute( request: &RunAgentsRequest, parent_conversation_id: AIConversationId, + team_scope: RequestTeamScope, ctx: &ModelContext, ) -> bool { let mut resolved_request = request.clone(); resolve_request_from_approved_config(&mut resolved_request, parent_conversation_id, ctx) .is_some_and(|status| status.is_approved()) - && can_execute_with_auth_secret(&resolved_request, ctx) + && can_execute_with_auth_secret(&resolved_request, team_scope, ctx) } fn resolve_request_from_approved_config( @@ -517,10 +543,11 @@ fn prepare_request_for_execution( parent_conversation_id: AIConversationId, terminal_view_id: EntityId, launched_agents: &HashMap>, + team_scope: RequestTeamScope, ctx: &ModelContext, ) -> Option { let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx); - populate_default_auth_secret_for_execution(request, ctx); + populate_default_auth_secret_for_execution(request, team_scope.clone(), ctx); if let Some(reason) = duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx) { @@ -556,7 +583,7 @@ fn prepare_request_for_execution( ); } - if !can_execute_with_auth_secret(request, ctx) { + if !can_execute_with_auth_secret(request, team_scope, ctx) { return Some( "Cloud child agents using this harness require an API key before they can run." .to_string(), diff --git a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs index 0ab8604b31c..8fa13037002 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs @@ -26,13 +26,16 @@ use crate::auth::AuthStateProvider; use crate::cloud_object::model::persistence::CloudModel; use crate::network::NetworkStatus; use crate::server::cloud_objects::update_manager::UpdateManager; -use crate::server::ids::SyncId; +use crate::server::ids::{ServerId, SyncId}; use crate::server::sync_queue::SyncQueue; +use crate::server::team_scope::RequestTeamScope; use crate::settings::PrivacySettings; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::test_util::settings::initialize_settings_for_tests_with_mode; use crate::workspaces::team_tester::TeamTesterStatus; -use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::user_workspaces::{ + TeamContextForOperation, TeamlessScopeForTest, UserWorkspaces, +}; use crate::{ AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, LaunchMode, }; @@ -93,6 +96,75 @@ fn persist_plan_config_with_harness( }); } +#[test] +fn scoped_auth_secret_defaults_do_not_cross_team_scope() { + App::test((), |mut app| async move { + let state = initialize_run_agents_test(&mut app, ExecutionMode::App); + let team_a_scope = request_scope_for_team(7); + let team_b_scope = request_scope_for_team(8); + persist_default_auth_secret(&mut app, "claude", "legacy-personal-key"); + persist_scoped_default_auth_secret( + &mut app, + team_a_scope.clone(), + Harness::Claude, + "team-a-anthropic-key", + ); + let AIAgentActionType::RunAgents(mut team_b_request) = + remote_run_agents_action("claude").action + else { + panic!("expected run_agents action"); + }; + let mut team_a_request = team_b_request.clone(); + let mut personal_request = team_b_request.clone(); + + state.executor.update(&mut app, |_, ctx| { + populate_default_auth_secret_for_execution(&mut team_b_request, team_b_scope, ctx); + populate_default_auth_secret_for_execution(&mut team_a_request, team_a_scope, ctx); + populate_default_auth_secret_for_execution( + &mut personal_request, + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), + ctx, + ); + }); + + assert_eq!(team_b_request.harness_auth_secret_name, None); + assert_eq!( + team_a_request.harness_auth_secret_name.as_deref(), + Some("team-a-anthropic-key") + ); + assert_eq!( + personal_request.harness_auth_secret_name.as_deref(), + Some("legacy-personal-key") + ); + }); +} + +fn request_scope_for_team(team_uid: i64) -> RequestTeamScope { + crate::server::team_scope::request_team_scope(&TeamContextForOperation::new_for_test( + ServerId::from(team_uid), + )) +} + +fn persist_scoped_default_auth_secret( + app: &mut App, + team_scope: RequestTeamScope, + harness: Harness, + secret_name: &str, +) { + CloudAgentSettings::handle(app).update(app, |settings, ctx| { + settings.persist_auth_secret_preference( + team_scope, + harness, + Some( + crate::ai::cloud_agent_settings::AuthSecretPreference::Named( + secret_name.to_string(), + ), + ), + ctx, + ); + }); +} + #[test] fn should_autoexecute_duplicate_launched_agent_denial() { App::test((), |mut app| async move { @@ -195,8 +267,13 @@ fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTe history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) }); let start_agent_executor = app.add_model(StartAgentExecutor::new); - let executor = - app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id)); + let executor = app.add_model(|_| { + RunAgentsExecutor::new( + start_agent_executor.clone(), + terminal_view_id, + UserWorkspaces::teamless_context_resolver_for_test(), + ) + }); RunAgentsTestState { conversation_id, @@ -531,6 +608,74 @@ fn execute_publishes_every_parent_owned_plan_before_dispatch() { }); } +#[test] +fn delayed_dispatch_keeps_auth_secret_and_team_scope_together() { + App::test((), |mut app| async move { + let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk); + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { + model.assign_run_id_for_conversation( + state.conversation_id, + "00000000-0000-0000-0000-000000000001".to_string(), + None, + EntityId::new(), + ctx, + ); + }); + let plan_id = AIDocumentModel::handle(&app).update(&mut app, |model, ctx| { + model.create_document("Plan", "# Plan", state.conversation_id, None, ctx) + }); + let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor); + let action = remote_run_agents_action("claude"); + let action_id = action.id.clone(); + let AIAgentActionType::RunAgents(mut request) = action.action else { + panic!("expected run_agents action"); + }; + request.harness_auth_secret_name = Some("team-a-anthropic-key".to_string()); + let team_a_scope = request_scope_for_team(7); + + state.executor.update(&mut app, |executor, ctx| { + let _receiver = executor.dispatch_prepared_run_agents( + action_id, + request, + state.conversation_id, + team_a_scope.clone(), + ctx, + ); + }); + captured.read(&app, |captured, _ctx| { + assert!(captured.0.is_empty()); + }); + + AIDocumentModel::handle(&app).update(&mut app, |model, ctx| { + model.create_document_from_notebook( + plan_id, + SyncId::ServerId(123.into()), + "Plan", + "# Plan", + state.conversation_id, + None, + ctx, + ); + }); + for _ in 0..3 { + futures_lite::future::yield_now().await; + } + + captured.read(&app, |captured, _ctx| { + let [request] = captured.0.as_slice() else { + panic!("expected one child request"); + }; + assert_eq!(request.request_team_scope, team_a_scope); + let StartAgentExecutionMode::Remote { + auth_secret_name, .. + } = &request.execution_mode + else { + panic!("expected remote child request"); + }; + assert_eq!(auth_secret_name.as_deref(), Some("team-a-anthropic-key")); + }); + }); +} /// A run_agents call holds in the `Publishing` state while it waits for the parent's /// plans to become server-backed, then dispatches children. This verifies that /// cancelling mid-publication prevents fan-out: even when the plan finishes publishing @@ -864,7 +1009,11 @@ fn populate_default_auth_secret_for_autoexecute_uses_persisted_secret() { }; state.executor.update(&mut app, |_, ctx| { - populate_default_auth_secret_for_execution(&mut request, ctx); + populate_default_auth_secret_for_execution( + &mut request, + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), + ctx, + ); }); assert_eq!( diff --git a/app/src/ai/blocklist/action_model/execute/start_agent.rs b/app/src/ai/blocklist/action_model/execute/start_agent.rs index 4b27fa387ac..39a17f7c0de 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent.rs @@ -7,6 +7,7 @@ use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::{LifecycleEventType, StartAgentExecutionMode}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; +use crate::server::team_scope::RequestTeamScope; /// Per-request outcome of a StartAgent dispatch. #[derive(Debug, Clone)] @@ -39,6 +40,7 @@ pub struct StartAgentRequest { pub lifecycle_subscription: Option>, pub parent_conversation_id: AIConversationId, pub parent_run_id: Option, + pub request_team_scope: RequestTeamScope, } struct PendingStartAgent { @@ -251,6 +253,7 @@ impl StartAgentExecutor { lifecycle_subscription: Option>, parent_conversation_id: AIConversationId, parent_run_id: Option, + request_team_scope: RequestTeamScope, ctx: &mut ModelContext, ) -> async_channel::Receiver { let (sender, receiver) = async_channel::bounded(1); @@ -272,6 +275,7 @@ impl StartAgentExecutor { lifecycle_subscription, parent_conversation_id, parent_run_id, + request_team_scope, }, ))); receiver diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index af02ce7960d..cd83dd92ddb 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -1932,7 +1932,7 @@ impl AgentInputFooter { let language = AISettings::as_ref(ctx) .voice_input_language_code() .map(str::to_owned); - let team_scope = RequestTeamScope::from_scope( + let team_scope = crate::server::team_scope::request_team_scope( &UserWorkspaces::as_ref(ctx).team_context_for_view(ctx), ); if !self.cli_voice_input_lifecycle.begin_transcribing() { diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index e115c10f2ce..820dcab9217 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -73,7 +73,6 @@ use crate::send_telemetry_from_ctx; use crate::server::server_api::AIApiError; #[cfg(not(target_family = "wasm"))] use crate::server::server_api::ServerApiProvider; -use crate::server::team_scope::RequestTeamScope; use crate::server::telemetry::TelemetryEvent; use crate::terminal::ShellLaunchData; use crate::terminal::model::block::{ @@ -2552,7 +2551,7 @@ impl BlocklistAIController { let scope = self.team_context(ctx); // Pinned at send, so the request keeps the team the surface was on when the user sent it. - let team_scope = RequestTeamScope::from_scope(&scope); + let team_scope = crate::server::team_scope::request_team_scope(&scope); let mut request_params = api::RequestParams::new( Some(self.terminal_surface_id), SessionContext::from_session(self.active_session.as_ref(ctx), ctx), diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index 6dd9e724434..2dfccf9e759 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -344,7 +344,7 @@ impl ResponseStream { error_event_emitted: false, deferred_retry_pending: false, current_request_id: Some(Uuid::new_v4()), - team_scope: RequestTeamScope::from_scope(&TeamlessScopeForTest), + team_scope: crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), } } @@ -359,7 +359,13 @@ impl ResponseStream { let start_time = Local::now(); let request_id = Uuid::new_v4(); - Self::spawn_request(request_id, params.clone(), team_scope, cancellation_rx, ctx); + Self::spawn_request( + request_id, + params.clone(), + team_scope.clone(), + cancellation_rx, + ctx, + ); Self { id: ResponseStreamId(Uuid::new_v4().to_string()), params, @@ -450,7 +456,7 @@ impl ResponseStream { Self::spawn_request( request_id, self.params.clone(), - self.team_scope, + self.team_scope.clone(), cancellation_rx, ctx, ); diff --git a/app/src/ai/blocklist/handoff/pipeline.rs b/app/src/ai/blocklist/handoff/pipeline.rs index d5e1cf57bab..3042f51765b 100644 --- a/app/src/ai/blocklist/handoff/pipeline.rs +++ b/app/src/ai/blocklist/handoff/pipeline.rs @@ -544,7 +544,7 @@ pub fn prepare_handoff( .map(|environment| environment.id) .collect(); let scope = controller.as_ref(ctx).team_context(ctx); - let team_scope = RequestTeamScope::from_scope(&scope); + let team_scope = crate::server::team_scope::request_team_scope(&scope); let preferences = LLMPreferences::as_ref(ctx); let active_model_id = &preferences .get_active_base_model(&scope, ctx, Some(terminal_surface_id)) @@ -758,9 +758,9 @@ async fn execute_validated_handoff( }, forked.forked_conversation_id.clone(), None, - forked.pending.team_scope, + forked.pending.team_scope.clone(), ), - team_scope: forked.pending.team_scope, + team_scope: forked.pending.team_scope.clone(), cancel, }; if let Err(error) = materialize_handoff_target(materialization) @@ -804,7 +804,7 @@ async fn execute_validated_handoff( settled.spawn_ready, settled.forked_conversation_id, settled.initial_snapshot_token, - settled.team_scope, + settled.team_scope.clone(), ); let response = ai_client .spawn_agent(request.clone(), settled.team_scope) diff --git a/app/src/ai/blocklist/handoff/pipeline_tests.rs b/app/src/ai/blocklist/handoff/pipeline_tests.rs index 09d8f6df11e..f084cd7d79b 100644 --- a/app/src/ai/blocklist/handoff/pipeline_tests.rs +++ b/app/src/ai/blocklist/handoff/pipeline_tests.rs @@ -280,7 +280,9 @@ fn pending( }, snapshot_disabled: true, orchestration_handoff: Some(true), - team_scope: RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(7.into())), + team_scope: crate::server::team_scope::request_team_scope( + &TeamContextForOperation::new_for_test(7.into()), + ), } } @@ -301,7 +303,9 @@ fn request_for_prompt( }, None, snapshot, - RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(7.into())), + crate::server::team_scope::request_team_scope(&TeamContextForOperation::new_for_test( + 7.into(), + )), ) } @@ -768,6 +772,7 @@ async fn fork_materialization_precedes_exactly_one_spawn() { let materialized = Arc::new(AtomicBool::new(false)); let spawn_count = Arc::new(AtomicUsize::new(0)); let observed_request = Arc::new(Mutex::new(None)); + let expected_team_uid = ServerId::from(7).uid(); let mut mock = MockAIClient::new(); mock.expect_fork_conversation() .times(1) @@ -785,7 +790,7 @@ async fn fork_materialization_precedes_exactly_one_spawn() { let observed_request = observed_request.clone(); move |request, team_scope| { assert!(materialized.load(Ordering::SeqCst)); - assert_eq!(team_scope.team_uid(), Some(7.into())); + assert_eq!(team_scope.team_uid(), Some(expected_team_uid.as_str())); spawn_count.fetch_add(1, Ordering::SeqCst); *observed_request.lock().expect("request lock") = Some(request); Ok(SpawnAgentResponse { diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 03a5f466ea6..6e63f27931a 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -48,6 +48,7 @@ pub use crate::ai::orchestration::{ use crate::appearance::Appearance; use crate::menu::{MenuItem, MenuItemFields}; use crate::server::experiments::{ServerExperiment, ServerExperiments}; +use crate::server::team_scope::RequestTeamScope; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::view_components::FilterableDropdown; @@ -602,6 +603,11 @@ pub fn populate_host_picker( // ── Auth secret helpers ────────────────────────────────── +pub fn request_team_scope(ctx: &ViewContext) -> RequestTeamScope { + let team_context = UserWorkspaces::as_ref(ctx).team_context_for_operation(ctx); + crate::server::team_scope::request_team_scope(&team_context) +} + /// Trigger label for the auth-secret dropdown. `Unset` falls back to /// "+ New API key…" rather than auto-picking the first loaded key. fn auth_secret_trigger_label(selection: &AuthSecretSelection, supports_create_new: bool) -> String { @@ -632,9 +638,10 @@ pub fn populate_auth_secret_picker_for_harness> = snapshot @@ -710,7 +717,12 @@ pub fn apply_created_auth_secret_if_matches( return false; } state.auth_secret_selection = AuthSecretSelection::Named(created_name.to_string()); - persist_auth_secret_selection(&state.harness_type, &state.auth_secret_selection, ctx); + persist_auth_secret_selection( + request_team_scope(ctx), + &state.harness_type, + &state.auth_secret_selection, + ctx, + ); true } @@ -738,7 +750,12 @@ pub fn apply_harness_change( fallback_base_model_id: Option, ctx: &mut ViewContext, ) { - orchestration_edit_state.apply_harness_change(new_harness_type, fallback_base_model_id, ctx); + orchestration_edit_state.apply_harness_change( + request_team_scope(ctx), + new_harness_type, + fallback_base_model_id, + ctx, + ); let state = &orchestration_edit_state.orchestration_config_state; let is_local = !state.execution_mode.is_remote(); if is_local @@ -808,7 +825,7 @@ pub fn repopulate_all_pickers( handles: &OrchestrationPickerHandles, ctx: &mut ViewContext, ) { - state.revalidate_after_catalog_change(ctx); + state.revalidate_after_catalog_change(request_team_scope(ctx), ctx); let is_local = !state.execution_mode.is_remote(); if let Some(handle) = &handles.harness_picker { populate_harness_picker(handle, &state.harness_type, is_local, ctx); @@ -876,8 +893,9 @@ pub fn sync_picker_selections( }); } if let Some(auth_secret_picker) = handles.auth_secret_picker.clone() { + let team_scope = request_team_scope(ctx); let supports_create_new = matches!( - api_key_snapshot(state, ctx).footer, + api_key_snapshot(state, team_scope, ctx).footer, Some(OptionFooter::CreateNewAuthSecret) ); let label = auth_secret_trigger_label(&state.auth_secret_selection, supports_create_new); diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 68c0f5f3961..6d51ae23456 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -71,7 +71,7 @@ use crate::view_components::compactible_action_button::{ use crate::view_components::compactible_split_action_button::CompactibleSplitActionButton; use crate::view_components::dropdown::DropdownEvent; use crate::view_components::{FilterableDropdownEvent, FilterableDropdownOrientation}; -use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const RUN_AGENTS_CARD_TITLE: &str = "Can I start additional agents for this task?"; const SPAWN_AGENTS_CANCELLED_LABEL: &str = "Spawn agents cancelled"; @@ -480,6 +480,27 @@ impl RunAgentsCardView { ); } }); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + let affects_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if !affects_window { + return; + } + me.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection = AuthSecretSelection::Unset; + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); + me.refresh_accept_button_state(ctx); + ctx.notify(); + }); // Repopulate pickers when the server-provided harness list, // harness model catalogs, or per-harness auth secrets change. @@ -487,41 +508,50 @@ impl RunAgentsCardView { // "Loading…" forever after the lazy fetch completes. ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), - |me, _, event, ctx| match event { - HarnessAvailabilityEvent::AuthSecretCreated { harness, name } => { - // Adopt the new secret before repopulating the picker. - oc::apply_created_auth_secret_if_matches( - &mut me.orchestration_edit_state.orchestration_config_state, - *harness, - name, - ctx, - ); - oc::repopulate_all_pickers( - &mut me.orchestration_edit_state.orchestration_config_state, - &me.handles.pickers, - ctx, - ); - me.refresh_accept_button_state(ctx); - ctx.notify(); + |me, _, event, ctx| { + let team_scope = oc::request_team_scope(ctx); + if event + .team_scope() + .is_some_and(|event_scope| event_scope != team_scope) + { + return; } - HarnessAvailabilityEvent::Changed - | HarnessAvailabilityEvent::AuthSecretsLoaded - | HarnessAvailabilityEvent::AuthSecretsFetchFailed - | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { - // Repopulate even on fetch failure to replace "Loading…". - // Deleted events also force a repopulate so this card - // stops surfacing the deleted secret as an option. - oc::repopulate_all_pickers( - &mut me.orchestration_edit_state.orchestration_config_state, - &me.handles.pickers, - ctx, - ); - me.refresh_accept_button_state(ctx); - me.maybe_auto_open_create_modal(ctx); - ctx.notify(); + match event { + HarnessAvailabilityEvent::AuthSecretCreated { harness, name, .. } => { + // Adopt the new secret before repopulating the picker. + oc::apply_created_auth_secret_if_matches( + &mut me.orchestration_edit_state.orchestration_config_state, + *harness, + name, + ctx, + ); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); + me.refresh_accept_button_state(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretsLoaded { .. } + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { + // Repopulate even on fetch failure to replace "Loading…". + // Deleted events also force a repopulate so this card + // stops surfacing the deleted secret as an option. + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.handles.pickers, + ctx, + ); + me.refresh_accept_button_state(ctx); + me.maybe_auto_open_create_modal(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} } - HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } - | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, ); @@ -633,6 +663,7 @@ impl RunAgentsCardView { ) { new_state.orchestration_config_state.auth_secret_selection = oc::resolve_auth_secret_selection_for_harness( + oc::request_team_scope(ctx), &new_state.orchestration_config_state.harness_type, ctx, ); @@ -825,7 +856,8 @@ impl RunAgentsCardView { // Only auto-open on `Loaded([])`. Other fetch states are // ambiguous; the `AuthSecretsLoaded` subscription will retry. let has_zero_loaded = matches!( - HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness), + HarnessAvailabilityModel::as_ref(ctx) + .auth_secrets_for(oc::request_team_scope(ctx), harness), AuthSecretFetchState::Loaded(secrets) if secrets.is_empty() ); if !has_zero_loaded { @@ -968,6 +1000,7 @@ impl RunAgentsCardView { self.orchestration_edit_state .orchestration_config_state .auth_secret_selection = oc::resolve_auth_secret_selection_for_harness( + oc::request_team_scope(ctx), &self .orchestration_edit_state .orchestration_config_state @@ -1421,7 +1454,11 @@ impl TypedActionView for RunAgentsCardView { RunAgentsCardViewAction::AuthSecretChanged { auth_secret_name } => { self.orchestration_edit_state .orchestration_config_state - .apply_auth_secret_change(auth_secret_name.clone(), ctx); + .apply_auth_secret_change( + oc::request_team_scope(ctx), + auth_secret_name.clone(), + ctx, + ); self.refresh_accept_button_state(ctx); ctx.notify(); } diff --git a/app/src/ai/blocklist/passive_suggestions/legacy.rs b/app/src/ai/blocklist/passive_suggestions/legacy.rs index d1f03a07a3c..178c6711dc7 100644 --- a/app/src/ai/blocklist/passive_suggestions/legacy.rs +++ b/app/src/ai/blocklist/passive_suggestions/legacy.rs @@ -31,7 +31,6 @@ use crate::ai_assistant::execution_context::WarpAiExecutionContext; use crate::network::NetworkStatus; use crate::safe_warn; use crate::server::server_api::ServerApiProvider; -use crate::server::team_scope::RequestTeamScope; use crate::server::telemetry::PromptSuggestionFallbackReason; use crate::settings::AISettings; use crate::terminal::event::{BlockType, UserBlockCompleted}; @@ -299,8 +298,9 @@ impl PassiveSuggestionsModel { let server_api = ServerApiProvider::handle(ctx).as_ref(ctx).get(); // Resolved before spawning, so a mid-flight team switch cannot re-attribute the request. - let team_scope = - RequestTeamScope::from_scope(&self.ai_controller.as_ref(ctx).team_context(ctx)); + let team_scope = crate::server::team_scope::request_team_scope( + &self.ai_controller.as_ref(ctx).team_context(ctx), + ); let request_future = async move { server_api .generate_am_query_suggestions(&request, team_scope) diff --git a/app/src/ai/blocklist/passive_suggestions/maa.rs b/app/src/ai/blocklist/passive_suggestions/maa.rs index 85bc25dc5f5..95bc8a11093 100644 --- a/app/src/ai/blocklist/passive_suggestions/maa.rs +++ b/app/src/ai/blocklist/passive_suggestions/maa.rs @@ -24,7 +24,6 @@ use crate::ai::blocklist::{ use crate::ai::paths::host_native_absolute_path; use crate::auth::auth_state::AuthStateProvider; use crate::server::server_api::ServerApiProvider; -use crate::server::team_scope::RequestTeamScope; use crate::settings::AISettings; use crate::terminal::event::{BlockType, UserBlockCompleted}; use crate::terminal::model::session::active_session::ActiveSession; @@ -199,8 +198,9 @@ impl PassiveSuggestionsModel { let (cancellation_tx, cancellation_rx) = futures::channel::oneshot::channel(); // Resolved before spawning, so a mid-flight team switch cannot re-attribute the request. - let team_scope = - RequestTeamScope::from_scope(&self.ai_controller.as_ref(ctx).team_context(ctx)); + let team_scope = crate::server::team_scope::request_team_scope( + &self.ai_controller.as_ref(ctx).team_context(ctx), + ); let stream_handle = ctx.spawn( async move { diff --git a/app/src/ai/cloud_agent_settings.rs b/app/src/ai/cloud_agent_settings.rs index b39446e326d..1f570cab766 100644 --- a/app/src/ai/cloud_agent_settings.rs +++ b/app/src/ai/cloud_agent_settings.rs @@ -11,6 +11,7 @@ use warp_cli::agent::Harness; use warp_errors::report_if_error; use crate::server::ids::SyncId; +use crate::server::team_scope::RequestTeamScope; #[derive( Clone, @@ -28,6 +29,37 @@ pub struct HarnessModelSelection { pub reasoning_level: Option, } +#[derive( + Clone, + Debug, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + settings_value::SettingsValue, +)] +pub enum AuthSecretPreference { + Named(String), + Inherit, +} + +#[derive( + Clone, + Debug, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + settings_value::SettingsValue, +)] +pub struct ScopedAuthSecretPreference { + team_scope: RequestTeamScope, + harness: String, + preference: AuthSecretPreference, +} + define_settings_group!(CloudAgentSettings, settings: [ last_selected_environment_id: LastSelectedEnvironmentId { type: Option, @@ -77,6 +109,14 @@ define_settings_group!(CloudAgentSettings, settings: [ surface: settings::SettingSurfaces::GUI, private: true, }, + scoped_auth_secret_preferences: ScopedAuthSecretPreferences { + type: Vec, + default: Vec::new(), + supported_platforms: SupportedPlatforms::ALL, + sync_to_cloud: SyncToCloud::Never, + surface: settings::SettingSurfaces::GUI, + private: true, + }, // Per-harness record of whether the user explicitly chose "Inherit // key from environment" in the orchestration auth secret picker. // Distinct from "never picked anything" (entry absent) so the plan @@ -92,6 +132,74 @@ define_settings_group!(CloudAgentSettings, settings: [ ]); impl CloudAgentSettings { + pub fn auth_secret_preference( + &self, + team_scope: RequestTeamScope, + harness: Harness, + ) -> Option { + self.scoped_auth_secret_preferences + .value() + .iter() + .find(|entry| entry.team_scope == team_scope && entry.harness == harness.config_name()) + .map(|entry| entry.preference.clone()) + .or_else(|| { + if !team_scope.is_unscoped() { + return None; + } + if self + .inherit_auth_secret_harnesses + .value() + .get(harness.config_name()) + .copied() + .unwrap_or(false) + { + Some(AuthSecretPreference::Inherit) + } else { + self.last_selected_auth_secret + .value() + .get(harness.config_name()) + .cloned() + .map(AuthSecretPreference::Named) + } + }) + } + + pub fn persist_auth_secret_preference( + &mut self, + team_scope: RequestTeamScope, + harness: Harness, + preference: Option, + ctx: &mut warpui::ModelContext, + ) { + let harness_key = harness.config_name().to_string(); + let mut preferences = self.scoped_auth_secret_preferences.value().clone(); + preferences.retain(|entry| { + entry.team_scope != team_scope || entry.harness.as_str() != harness_key + }); + if let Some(preference) = preference { + preferences.push(ScopedAuthSecretPreference { + team_scope: team_scope.clone(), + harness: harness_key.clone(), + preference, + }); + } + report_if_error!( + self.scoped_auth_secret_preferences + .set_value(preferences, ctx) + ); + + if team_scope.is_unscoped() { + let mut legacy_named = self.last_selected_auth_secret.value().clone(); + let mut legacy_inherit = self.inherit_auth_secret_harnesses.value().clone(); + legacy_named.remove(&harness_key); + legacy_inherit.remove(&harness_key); + report_if_error!(self.last_selected_auth_secret.set_value(legacy_named, ctx)); + report_if_error!( + self.inherit_auth_secret_harnesses + .set_value(legacy_inherit, ctx) + ); + } + } pub fn is_harness_auth_ftux_completed(&self, harness: Harness) -> bool { self.harness_auth_ftux_completed .value() diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 6ef2ca23232..28a04bc06cd 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -49,7 +49,7 @@ use crate::server::experiments::{ServerExperiments, ServerExperimentsEvent}; use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::workspace::WorkspaceAction; -use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; /// True when the mode is remote and `environment_id` is non-empty. fn env_presence(execution_mode: &RunAgentsExecutionMode) -> bool { @@ -247,6 +247,28 @@ impl OrchestrationConfigBlockView { ); } }); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + let affects_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if !affects_window { + return; + } + me.orchestration_edit_state + .orchestration_config_state + .auth_secret_selection = AuthSecretSelection::Unset; + if me.pickers_initialized { + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.pickers, + ctx, + ); + } + ctx.notify(); + }); // Repopulate pickers when the server-provided harness list, // harness model catalogs, or per-harness auth secrets change. @@ -254,43 +276,52 @@ impl OrchestrationConfigBlockView { // "Loading…" forever after the lazy fetch completes. ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), - |me, _, event, ctx| match event { - HarnessAvailabilityEvent::AuthSecretCreated { harness, name } => { - if me.pickers_initialized { - oc::apply_created_auth_secret_if_matches( - &mut me.orchestration_edit_state.orchestration_config_state, - *harness, - name, - ctx, - ); - oc::repopulate_all_pickers( - &mut me.orchestration_edit_state.orchestration_config_state, - &me.pickers, - ctx, - ); - } - ctx.notify(); + |me, _, event, ctx| { + let team_scope = oc::request_team_scope(ctx); + if event + .team_scope() + .is_some_and(|event_scope| event_scope != team_scope) + { + return; } - HarnessAvailabilityEvent::Changed - | HarnessAvailabilityEvent::AuthSecretsLoaded - | HarnessAvailabilityEvent::AuthSecretsFetchFailed - | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { - // Repopulate even on fetch failure to replace "Loading…". - // The Deleted event also triggers a refresh so any - // already-mounted picker drops the deleted entry from - // its menu. - if me.pickers_initialized { - oc::repopulate_all_pickers( - &mut me.orchestration_edit_state.orchestration_config_state, - &me.pickers, - ctx, - ); + match event { + HarnessAvailabilityEvent::AuthSecretCreated { harness, name, .. } => { + if me.pickers_initialized { + oc::apply_created_auth_secret_if_matches( + &mut me.orchestration_edit_state.orchestration_config_state, + *harness, + name, + ctx, + ); + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.pickers, + ctx, + ); + } + ctx.notify(); } - me.maybe_auto_open_create_modal(ctx); - ctx.notify(); + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretsLoaded { .. } + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { + // Repopulate even on fetch failure to replace "Loading…". + // The Deleted event also triggers a refresh so any + // already-mounted picker drops the deleted entry from + // its menu. + if me.pickers_initialized { + oc::repopulate_all_pickers( + &mut me.orchestration_edit_state.orchestration_config_state, + &me.pickers, + ctx, + ); + } + me.maybe_auto_open_create_modal(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} } - HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } - | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, ); @@ -398,7 +429,8 @@ impl OrchestrationConfigBlockView { // Only auto-open on `Loaded([])`. Other fetch states are // ambiguous; the `AuthSecretsLoaded` subscription will retry. let has_zero_loaded = matches!( - HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness), + HarnessAvailabilityModel::as_ref(ctx) + .auth_secrets_for(oc::request_team_scope(ctx), harness), AuthSecretFetchState::Loaded(secrets) if secrets.is_empty() ); if !has_zero_loaded { @@ -596,6 +628,7 @@ impl OrchestrationConfigBlockView { self.orchestration_edit_state .orchestration_config_state .auth_secret_selection = oc::resolve_auth_secret_selection_for_harness( + oc::request_team_scope(ctx), &self .orchestration_edit_state .orchestration_config_state @@ -1082,7 +1115,11 @@ impl TypedActionView for OrchestrationConfigBlockView { // persisted side-channel, not baked into `OrchestrationConfig`. self.orchestration_edit_state .orchestration_config_state - .apply_auth_secret_change(auth_secret_name.clone(), ctx); + .apply_auth_secret_change( + oc::request_team_scope(ctx), + auth_secret_name.clone(), + ctx, + ); ctx.notify(); } OrchestrationConfigBlockAction::CreateNewAuthSecretRequested => { diff --git a/app/src/ai/harness_availability.rs b/app/src/ai/harness_availability.rs index 3242ebe4536..39aeb5940d3 100644 --- a/app/src/ai/harness_availability.rs +++ b/app/src/ai/harness_availability.rs @@ -19,6 +19,7 @@ use crate::server::retry_strategies::{ OUT_OF_BAND_REQUEST_RETRY_STRATEGY, is_transient_graphql_or_http_error, }; use crate::server::server_api::ServerApiProvider; +use crate::server::team_scope::RequestTeamScope; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const CACHE_KEY: &str = "AvailableHarnesses"; @@ -84,28 +85,50 @@ pub struct AuthSecretEntry { pub name: String, pub owner: SecretOwner, } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct AuthSecretCacheKey { + team_scope: RequestTeamScope, + harness: Harness, +} + +impl AuthSecretCacheKey { + fn new(team_scope: RequestTeamScope, harness: Harness) -> Self { + Self { + team_scope, + harness, + } + } +} pub enum HarnessAvailabilityEvent { Changed, - AuthSecretsLoaded, + AuthSecretsLoaded { + team_scope: RequestTeamScope, + }, /// Emitted when a lazy auth-secrets fetch fails. Subscribers should /// re-render so any "Loading…" placeholders can transition to an /// error state — without this signal the picker would otherwise be /// stuck on the loading placeholder until the next refetch. - AuthSecretsFetchFailed, + AuthSecretsFetchFailed { + team_scope: RequestTeamScope, + }, AuthSecretCreated { + team_scope: RequestTeamScope, harness: Harness, name: String, }, AuthSecretCreationFailed { + team_scope: RequestTeamScope, error: String, }, AuthSecretDeleted { + team_scope: RequestTeamScope, harness: Harness, name: String, owner: SecretOwner, }, AuthSecretDeletionFailed { + team_scope: RequestTeamScope, harness: Harness, name: String, owner: SecretOwner, @@ -113,10 +136,24 @@ pub enum HarnessAvailabilityEvent { }, } +impl HarnessAvailabilityEvent { + pub fn team_scope(&self) -> Option { + match self { + Self::Changed => None, + Self::AuthSecretsLoaded { team_scope, .. } + | Self::AuthSecretsFetchFailed { team_scope, .. } + | Self::AuthSecretCreated { team_scope, .. } + | Self::AuthSecretCreationFailed { team_scope, .. } + | Self::AuthSecretDeleted { team_scope, .. } + | Self::AuthSecretDeletionFailed { team_scope, .. } => Some(team_scope.clone()), + } + } +} pub struct HarnessAvailabilityModel { harnesses: Vec, - auth_secrets: HashMap, - auth_secret_retry_after: HashMap, + auth_secrets: HashMap, + auth_secret_retry_after: HashMap, + auth_secret_generation: u64, } impl HarnessAvailabilityModel { @@ -134,16 +171,14 @@ impl HarnessAvailabilityModel { ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| { if let AuthManagerEvent::AuthComplete = event { - let cached_harnesses: Vec = me.auth_secrets.keys().copied().collect(); - for harness in cached_harnesses { - me.invalidate_auth_secrets(harness); - } + me.invalidate_auth_secrets(); me.refresh(ctx); } }); ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { if let UserWorkspacesEvent::TeamsChanged = event { + me.invalidate_auth_secrets(); me.refresh(ctx); } }); @@ -152,6 +187,7 @@ impl HarnessAvailabilityModel { harnesses, auth_secrets: HashMap::new(), auth_secret_retry_after: HashMap::new(), + auth_secret_generation: 0, }; me.refresh(ctx); me @@ -194,17 +230,27 @@ impl HarnessAvailabilityModel { .filter(|m| !m.is_empty()) } - pub fn auth_secrets_for(&self, harness: Harness) -> &AuthSecretFetchState { + pub fn auth_secrets_for( + &self, + team_scope: RequestTeamScope, + harness: Harness, + ) -> &AuthSecretFetchState { self.auth_secrets - .get(&harness) + .get(&AuthSecretCacheKey::new(team_scope, harness)) .unwrap_or(&AuthSecretFetchState::NotFetched) } - - pub fn ensure_auth_secrets_fetched(&mut self, harness: Harness, ctx: &mut ModelContext) { - match self.auth_secrets_for(harness) { - AuthSecretFetchState::NotFetched => self.fetch_auth_secrets(harness, ctx), - AuthSecretFetchState::Failed(_) if self.can_retry_auth_secret_fetch(harness) => { - self.fetch_auth_secrets(harness, ctx); + pub fn ensure_auth_secrets_fetched( + &mut self, + team_scope: RequestTeamScope, + harness: Harness, + ctx: &mut ModelContext, + ) { + match self.auth_secrets_for(team_scope.clone(), harness) { + AuthSecretFetchState::NotFetched => self.fetch_auth_secrets(team_scope, harness, ctx), + AuthSecretFetchState::Failed(_) + if self.can_retry_auth_secret_fetch(team_scope.clone(), harness) => + { + self.fetch_auth_secrets(team_scope, harness, ctx); } AuthSecretFetchState::Failed(_) | AuthSecretFetchState::Loading @@ -212,7 +258,12 @@ impl HarnessAvailabilityModel { } } - fn fetch_auth_secrets(&mut self, harness: Harness, ctx: &mut ModelContext) { + fn fetch_auth_secrets( + &mut self, + team_scope: RequestTeamScope, + harness: Harness, + ctx: &mut ModelContext, + ) { let Some(agent_harness) = harness_to_graphql_harness(harness) else { return; }; @@ -221,92 +272,124 @@ impl HarnessAvailabilityModel { return; } + let cache_key = AuthSecretCacheKey::new(team_scope.clone(), harness); self.auth_secrets - .insert(harness, AuthSecretFetchState::Loading); - self.auth_secret_retry_after.remove(&harness); + .insert(cache_key.clone(), AuthSecretFetchState::Loading); + self.auth_secret_retry_after.remove(&cache_key); + let generation = self.auth_secret_generation; - let api = ServerApiProvider::as_ref(ctx).get_managed_secrets_client(); + let managed_secrets = ManagedSecretManager::as_ref(ctx).clone(); + let request_team_scope = team_scope.clone(); ctx.spawn_with_retry_on_error_when( move || { - let api = api.clone(); + let managed_secrets = managed_secrets.clone(); let agent_harness = agent_harness.clone(); - async move { api.list_harness_auth_secrets(agent_harness).await } + let team_scope = request_team_scope.clone(); + async move { + managed_secrets + .list_harness_auth_secrets(team_scope, agent_harness) + .await + } }, OUT_OF_BAND_REQUEST_RETRY_STRATEGY, is_transient_graphql_or_http_error, move |me, result: RequestState>, - ctx| match result { - RequestState::RequestSucceeded(secrets) => { - let entries = secrets - .into_iter() - .map(|s| AuthSecretEntry { - owner: secret_owner_from_space(&s.owner), - name: s.name, - }) - .collect(); - me.auth_secrets - .insert(harness, AuthSecretFetchState::Loaded(entries)); - me.auth_secret_retry_after.remove(&harness); - ctx.emit(HarnessAvailabilityEvent::AuthSecretsLoaded); + ctx| { + if !me.is_auth_secret_fetch_current(generation) { + return; } - RequestState::RequestFailedRetryPending(e) => { - log::warn!("Failed to fetch harness auth secrets; retrying: {e:#}"); - } - RequestState::RequestFailed(e) => { - let msg = e.to_string(); - report_error!(e.context("Failed to fetch harness auth secrets")); - me.auth_secrets - .insert(harness, AuthSecretFetchState::Failed(msg)); - me.auth_secret_retry_after - .insert(harness, Instant::now() + AUTH_SECRET_FETCH_FAILURE_COOLDOWN); - // Notify subscribers so they can drop any - // "Loading…" placeholder rendered during the - // in-flight fetch and surface the error state. - ctx.emit(HarnessAvailabilityEvent::AuthSecretsFetchFailed); + match result { + RequestState::RequestSucceeded(secrets) => { + let entries = secrets + .into_iter() + .map(|s| AuthSecretEntry { + owner: secret_owner_from_space(&s.owner), + name: s.name, + }) + .collect(); + me.auth_secrets + .insert(cache_key.clone(), AuthSecretFetchState::Loaded(entries)); + me.auth_secret_retry_after.remove(&cache_key); + ctx.emit(HarnessAvailabilityEvent::AuthSecretsLoaded { + team_scope: team_scope.clone(), + }); + } + RequestState::RequestFailedRetryPending(e) => { + log::warn!("Failed to fetch harness auth secrets; retrying: {e:#}"); + } + RequestState::RequestFailed(e) => { + let msg = e.to_string(); + report_error!(e.context("Failed to fetch harness auth secrets")); + me.auth_secrets + .insert(cache_key.clone(), AuthSecretFetchState::Failed(msg)); + me.auth_secret_retry_after.insert( + cache_key.clone(), + Instant::now() + AUTH_SECRET_FETCH_FAILURE_COOLDOWN, + ); + // Notify subscribers so they can drop any + // "Loading…" placeholder rendered during the + // in-flight fetch and surface the error state. + ctx.emit(HarnessAvailabilityEvent::AuthSecretsFetchFailed { + team_scope: team_scope.clone(), + }); + } } }, ); } - fn can_retry_auth_secret_fetch(&self, harness: Harness) -> bool { + fn invalidate_auth_secrets(&mut self) { + self.auth_secret_generation = self.auth_secret_generation.wrapping_add(1); + self.auth_secrets.clear(); + self.auth_secret_retry_after.clear(); + } + + fn is_auth_secret_fetch_current(&self, generation: u64) -> bool { + self.auth_secret_generation == generation + } + + fn can_retry_auth_secret_fetch(&self, team_scope: RequestTeamScope, harness: Harness) -> bool { self.auth_secret_retry_after - .get(&harness) + .get(&AuthSecretCacheKey::new(team_scope, harness)) .map(|retry_after| Instant::now() >= *retry_after) .unwrap_or(true) } - pub fn invalidate_auth_secrets(&mut self, harness: Harness) { - self.auth_secrets.remove(&harness); - self.auth_secret_retry_after.remove(&harness); - } - pub fn create_auth_secret( &mut self, + team_scope: RequestTeamScope, harness: Harness, name: String, value: ManagedSecretValue, owner: SecretOwner, ctx: &mut ModelContext, ) { - let manager = ManagedSecretManager::handle(ctx); - let create_future = manager.as_ref(ctx).create_secret(owner, name, value, None); + let create_future = ManagedSecretManager::as_ref(ctx).create_secret( + team_scope.clone(), + owner, + name, + value, + None, + ); ctx.spawn(create_future, move |me, result, ctx| match result { Ok(secret) => { let entry = AuthSecretEntry { name: secret.name.clone(), owner: secret_owner_from_space(&secret.owner), }; - match me.auth_secrets.get_mut(&harness) { + let cache_key = AuthSecretCacheKey::new(team_scope.clone(), harness); + match me.auth_secrets.get_mut(&cache_key) { Some(AuthSecretFetchState::Loaded(entries)) => { entries.push(entry); } _ => { me.auth_secrets - .insert(harness, AuthSecretFetchState::Loaded(vec![entry])); + .insert(cache_key, AuthSecretFetchState::Loaded(vec![entry])); } } ctx.emit(HarnessAvailabilityEvent::AuthSecretCreated { + team_scope: team_scope.clone(), harness, name: secret.name, }); @@ -314,30 +397,37 @@ impl HarnessAvailabilityModel { Err(e) => { let msg = e.to_string(); report_error!(e.context("Failed to create harness auth secret")); - ctx.emit(HarnessAvailabilityEvent::AuthSecretCreationFailed { error: msg }); + ctx.emit(HarnessAvailabilityEvent::AuthSecretCreationFailed { + team_scope: team_scope.clone(), + error: msg, + }); } }); } pub fn delete_auth_secret( &mut self, + team_scope: RequestTeamScope, harness: Harness, name: String, owner: SecretOwner, ctx: &mut ModelContext, ) { - let manager = ManagedSecretManager::handle(ctx); - let delete_future = manager - .as_ref(ctx) - .delete_secret(owner.clone(), name.clone()); + let delete_future = ManagedSecretManager::as_ref(ctx).delete_secret( + team_scope.clone(), + owner.clone(), + name.clone(), + ); ctx.spawn(delete_future, move |me, result, ctx| match result { Ok(()) => { - if let Some(AuthSecretFetchState::Loaded(entries)) = - me.auth_secrets.get_mut(&harness) + if let Some(AuthSecretFetchState::Loaded(entries)) = me + .auth_secrets + .get_mut(&AuthSecretCacheKey::new(team_scope.clone(), harness)) { remove_deleted_auth_secret_entry(entries, &name, &owner); } ctx.emit(HarnessAvailabilityEvent::AuthSecretDeleted { + team_scope: team_scope.clone(), harness, name, owner, @@ -347,6 +437,7 @@ impl HarnessAvailabilityModel { let msg = e.to_string(); report_error!(e.context("Failed to delete harness auth secret")); ctx.emit(HarnessAvailabilityEvent::AuthSecretDeletionFailed { + team_scope: team_scope.clone(), harness, name, owner, @@ -372,10 +463,7 @@ impl HarnessAvailabilityModel { me.harnesses = new_harnesses; me.cache(ctx); // Invalidate cached auth secrets so the next menu open refetches. - let stale: Vec = me.auth_secrets.keys().copied().collect(); - for harness in stale { - me.invalidate_auth_secrets(harness); - } + me.invalidate_auth_secrets(); ctx.emit(HarnessAvailabilityEvent::Changed); } } @@ -452,3 +540,7 @@ impl Entity for HarnessAvailabilityModel { } impl SingletonEntity for HarnessAvailabilityModel {} + +#[cfg(test)] +#[path = "harness_availability_tests.rs"] +mod tests; diff --git a/app/src/ai/harness_availability_tests.rs b/app/src/ai/harness_availability_tests.rs new file mode 100644 index 00000000000..bada203d3d8 --- /dev/null +++ b/app/src/ai/harness_availability_tests.rs @@ -0,0 +1,45 @@ +use super::*; +use crate::server::ids::ServerId; +use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; + +#[test] +fn auth_secret_cache_key_distinguishes_team_scope() { + let personal_scope = crate::server::team_scope::request_team_scope(&TeamlessScopeForTest); + let team_scope = crate::server::team_scope::request_team_scope( + &TeamContextForOperation::new_for_test(ServerId::from(7)), + ); + + assert_ne!( + AuthSecretCacheKey::new(personal_scope, Harness::Claude), + AuthSecretCacheKey::new(team_scope, Harness::Claude) + ); +} + +#[test] +fn invalidation_rejects_in_flight_auth_secret_fetch_generation() { + let personal_scope = crate::server::team_scope::request_team_scope(&TeamlessScopeForTest); + let cache_key = AuthSecretCacheKey::new(personal_scope, Harness::Claude); + let mut model = HarnessAvailabilityModel { + harnesses: default_harnesses(), + auth_secrets: HashMap::from([(cache_key, AuthSecretFetchState::Loading)]), + auth_secret_retry_after: HashMap::new(), + auth_secret_generation: 7, + }; + let in_flight_generation = model.auth_secret_generation; + + model.invalidate_auth_secrets(); + + assert!(!model.is_auth_secret_fetch_current(in_flight_generation)); + assert!(model.auth_secrets.is_empty()); + assert!(model.auth_secret_retry_after.is_empty()); +} + +#[test] +fn auth_secret_cache_key_distinguishes_harness() { + let personal_scope = crate::server::team_scope::request_team_scope(&TeamlessScopeForTest); + + assert_ne!( + AuthSecretCacheKey::new(personal_scope.clone(), Harness::Claude), + AuthSecretCacheKey::new(personal_scope, Harness::Codex) + ); +} diff --git a/app/src/ai/managed_secrets.rs b/app/src/ai/managed_secrets.rs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index 352d444554a..980e5460bdf 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -46,8 +46,7 @@ pub struct OrchestrationConfigState { /// Per-call value hidden from the orchestration editors. Kept outside /// `execution_mode` so a temporary switch to Local does not discard it. remote_computer_use_enabled: bool, - /// Drives the picker display and Accept gate. Persisted as - /// `Named(_)` only via `CloudAgentSettings.last_selected_auth_secret`. + /// Drives the picker display and Accept gate. Named preferences are persisted separately. pub auth_secret_selection: AuthSecretSelection, } diff --git a/app/src/ai/orchestration/edit_state.rs b/app/src/ai/orchestration/edit_state.rs index 18464f2aab7..9a987184c67 100644 --- a/app/src/ai/orchestration/edit_state.rs +++ b/app/src/ai/orchestration/edit_state.rs @@ -21,6 +21,7 @@ use super::providers::{ resolve_default_environment_id, }; use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; +use crate::server::team_scope::RequestTeamScope; impl OrchestrationConfigState { /// Toggles Local ↔ Cloud, pre-fills the default environment when @@ -46,15 +47,25 @@ impl OrchestrationConfigState { ); } - /// Records the auth-secret picker choice (`None` means Inherit) and - /// persists it to `CloudAgentSettings`. - pub fn apply_auth_secret_change(&mut self, new_name: Option, ctx: &mut AppContext) { + /// Records the auth-secret picker choice (`None` means Inherit) and persists it for the + /// active scope in `CloudAgentSettings`. + pub fn apply_auth_secret_change( + &mut self, + team_scope: RequestTeamScope, + new_name: Option, + ctx: &mut AppContext, + ) { let normalized = new_name.filter(|s| !s.trim().is_empty()); self.auth_secret_selection = match normalized { Some(name) => AuthSecretSelection::Named(name), None => AuthSecretSelection::Inherit, }; - persist_auth_secret_selection(&self.harness_type, &self.auth_secret_selection, ctx); + persist_auth_secret_selection( + team_scope, + &self.harness_type, + &self.auth_secret_selection, + ctx, + ); } /// Revalidates the state after a live catalog change: resets a @@ -62,11 +73,17 @@ impl OrchestrationConfigState { /// secret, and re-seeds an `Unset` selection from persisted settings. /// This is the frontend-neutral core of the GUI's /// `repopulate_all_pickers`. - pub fn revalidate_after_catalog_change(&mut self, ctx: &AppContext) { + pub fn revalidate_after_catalog_change( + &mut self, + team_scope: RequestTeamScope, + ctx: &AppContext, + ) { let loaded_secret_names = Harness::parse_orchestration_harness(&self.harness_type) .filter(|harness| *harness != Harness::Oz) .and_then(|harness| { - match HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness) { + match HarnessAvailabilityModel::as_ref(ctx) + .auth_secrets_for(team_scope.clone(), harness) + { AuthSecretFetchState::Loaded(secrets) => { Some(secrets.iter().map(|s| s.name.clone()).collect::>()) } @@ -75,7 +92,8 @@ impl OrchestrationConfigState { | AuthSecretFetchState::Failed(_) => None, } }); - let reseeded_selection = resolve_auth_secret_selection_for_harness(&self.harness_type, ctx); + let reseeded_selection = + resolve_auth_secret_selection_for_harness(team_scope, &self.harness_type, ctx); self.revalidate_after_catalog_change_core( loaded_secret_names.as_deref(), reseeded_selection, @@ -199,11 +217,13 @@ impl OrchestrationEditState { /// secret selection for the new harness. pub fn apply_harness_change( &mut self, + team_scope: RequestTeamScope, new_harness_type: &str, fallback_base_model_id: Option, ctx: &mut AppContext, ) { - let resolved_auth = resolve_auth_secret_selection_for_harness(new_harness_type, ctx); + let resolved_auth = + resolve_auth_secret_selection_for_harness(team_scope, new_harness_type, ctx); let ctx: &AppContext = ctx; self.apply_harness_change_core( new_harness_type, diff --git a/app/src/ai/orchestration/providers.rs b/app/src/ai/orchestration/providers.rs index c8d679541ed..387f27ceb25 100644 --- a/app/src/ai/orchestration/providers.rs +++ b/app/src/ai/orchestration/providers.rs @@ -9,12 +9,13 @@ use warpui::{AppContext, SingletonEntity}; use crate::LLMPreferences; use crate::ai::auth_secret_types::auth_secret_types_for_harness; -use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_agent_settings::{AuthSecretPreference, CloudAgentSettings}; use crate::ai::cloud_environments::CloudEnvironmentCatalog; use crate::ai::connected_self_hosted_workers::WARP_WORKER_HOST; use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; use crate::ai::llms::LLMInfo; use crate::ai::orchestration::config_state::AuthSecretSelection; +use crate::server::team_scope::RequestTeamScope; use crate::workspaces::user_workspaces::{TeamScope, UserWorkspaces}; /// Env var override for the workspace default host (developer testing). @@ -180,11 +181,12 @@ pub fn persist_environment_selection(environment_id: &str, ctx: &mut AppContext) } } -/// Returns the persisted last-selected secret name for this harness, or +/// Returns the persisted last-selected secret name for this scope and harness, or /// `None`. Only promotes a persisted name; never auto-picks the first /// loaded secret. Validates against the loaded secrets list when present, /// returning `None` if the persisted name has been deleted server-side. pub fn resolve_default_auth_secret_for_harness( + team_scope: RequestTeamScope, harness_type: &str, ctx: &AppContext, ) -> Option { @@ -192,15 +194,15 @@ pub fn resolve_default_auth_secret_for_harness( if harness == Harness::Oz { return None; } - let persisted = CloudAgentSettings::as_ref(ctx) - .last_selected_auth_secret - .value() - .get(harness.config_name()) - .cloned() - .filter(|name| !name.trim().is_empty()); + let persisted = match CloudAgentSettings::as_ref(ctx) + .auth_secret_preference(team_scope.clone(), harness) + { + Some(AuthSecretPreference::Named(name)) if !name.trim().is_empty() => Some(name), + Some(AuthSecretPreference::Named(_)) | Some(AuthSecretPreference::Inherit) | None => None, + }; let availability = HarnessAvailabilityModel::as_ref(ctx); - match availability.auth_secrets_for(harness) { + match availability.auth_secrets_for(team_scope, harness) { AuthSecretFetchState::Loaded(secrets) => { // Drop the persisted name if the secret was deleted server-side. persisted.filter(|name| secrets.iter().any(|s| s.name == *name)) @@ -218,6 +220,7 @@ pub fn resolve_default_auth_secret_for_harness( /// fallback so the plan card's "Inherit" survives across the RunAgents /// handoff (the `OrchestrationConfig` proto doesn't carry auth state). pub fn resolve_auth_secret_selection_for_harness( + team_scope: RequestTeamScope, harness_type: &str, ctx: &AppContext, ) -> AuthSecretSelection { @@ -228,27 +231,24 @@ pub fn resolve_auth_secret_selection_for_harness( return AuthSecretSelection::Unset; } // Explicit Inherit wins over a stale Named fallback. - let inherit_chosen = CloudAgentSettings::as_ref(ctx) - .inherit_auth_secret_harnesses - .value() - .get(harness.config_name()) - .copied() - .unwrap_or(false); + let inherit_chosen = matches!( + CloudAgentSettings::as_ref(ctx).auth_secret_preference(team_scope.clone(), harness), + Some(AuthSecretPreference::Inherit) + ); if inherit_chosen { return AuthSecretSelection::Inherit; } - match resolve_default_auth_secret_for_harness(harness_type, ctx) { + match resolve_default_auth_secret_for_harness(team_scope, harness_type, ctx) { Some(name) => AuthSecretSelection::Named(name), None => AuthSecretSelection::Unset, } } -/// Persists the user's auth-secret choice for the active harness. -/// `Named` writes to `last_selected_auth_secret` and clears any prior -/// `Inherit` flag. `Inherit` clears the named entry and sets the inherit -/// flag. `Unset`/`CreatingNew` clear both (no recorded choice). No-op for -/// Oz / unknown. +/// Persists the user's auth-secret choice for the active scope and harness. +/// `Named` and `Inherit` replace any prior scoped preference. `Unset`/`CreatingNew` clear it. +/// No-op for Oz / unknown. pub(crate) fn persist_auth_secret_selection( + team_scope: RequestTeamScope, harness_type: &str, selection: &AuthSecretSelection, ctx: &mut AppContext, @@ -259,31 +259,13 @@ pub(crate) fn persist_auth_secret_selection( if harness == Harness::Oz { return; } - let key = harness.config_name().to_string(); - let selection = selection.clone(); + let preference = match selection { + AuthSecretSelection::Named(name) => Some(AuthSecretPreference::Named(name.clone())), + AuthSecretSelection::Inherit => Some(AuthSecretPreference::Inherit), + AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => None, + }; CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - let mut named_map = settings.last_selected_auth_secret.value().clone(); - let mut inherit_map = settings.inherit_auth_secret_harnesses.value().clone(); - match selection { - AuthSecretSelection::Named(name) => { - named_map.insert(key.clone(), name.clone()); - inherit_map.remove(&key); - } - AuthSecretSelection::Inherit => { - named_map.remove(&key); - inherit_map.insert(key, true); - } - AuthSecretSelection::Unset | AuthSecretSelection::CreatingNew => { - named_map.remove(&key); - inherit_map.remove(&key); - } - } - report_if_error!(settings.last_selected_auth_secret.set_value(named_map, ctx)); - report_if_error!( - settings - .inherit_auth_secret_harnesses - .set_value(inherit_map, ctx) - ); + settings.persist_auth_secret_preference(team_scope, harness, preference, ctx); }); } @@ -302,7 +284,11 @@ fn requires_default_auth_secret_for_execution(request: &RunAgentsRequest) -> boo /// Whether the request can execute as-is: either it doesn't need a /// managed auth secret, already carries one, or a persisted default /// exists for the harness. -pub(crate) fn can_execute_with_auth_secret(request: &RunAgentsRequest, ctx: &AppContext) -> bool { +pub(crate) fn can_execute_with_auth_secret( + request: &RunAgentsRequest, + team_scope: RequestTeamScope, + ctx: &AppContext, +) -> bool { if !requires_default_auth_secret_for_execution(request) { return true; } @@ -313,11 +299,12 @@ pub(crate) fn can_execute_with_auth_secret(request: &RunAgentsRequest, ctx: &App { return true; } - default_auth_secret_name_for_harness(&request.harness_type, ctx).is_some() + default_auth_secret_name_for_harness(team_scope, &request.harness_type, ctx).is_some() } /// Returns the persisted default managed-secret name for a harness, if any. pub(crate) fn default_auth_secret_name_for_harness( + team_scope: RequestTeamScope, harness_type: &str, ctx: &AppContext, ) -> Option { @@ -325,18 +312,17 @@ pub(crate) fn default_auth_secret_name_for_harness( if harness == Harness::Oz { return None; } - CloudAgentSettings::as_ref(ctx) - .last_selected_auth_secret - .value() - .get(harness.config_name()) - .cloned() - .filter(|name| !name.trim().is_empty()) + match CloudAgentSettings::as_ref(ctx).auth_secret_preference(team_scope, harness) { + Some(AuthSecretPreference::Named(name)) if !name.trim().is_empty() => Some(name), + Some(AuthSecretPreference::Named(_)) | Some(AuthSecretPreference::Inherit) | None => None, + } } -/// Fills `harness_auth_secret_name` from the persisted per-harness default +/// Fills `harness_auth_secret_name` from the persisted scoped harness default /// when the request needs one and doesn't already carry a name. pub(crate) fn populate_default_auth_secret_for_execution( request: &mut RunAgentsRequest, + team_scope: RequestTeamScope, ctx: &AppContext, ) { if !requires_default_auth_secret_for_execution(request) @@ -348,5 +334,5 @@ pub(crate) fn populate_default_auth_secret_for_execution( return; } request.harness_auth_secret_name = - default_auth_secret_name_for_harness(&request.harness_type, ctx); + default_auth_secret_name_for_harness(team_scope, &request.harness_type, ctx); } diff --git a/app/src/ai/orchestration/remote_child_tests.rs b/app/src/ai/orchestration/remote_child_tests.rs index 119520bf6df..ef312ed7a02 100644 --- a/app/src/ai/orchestration/remote_child_tests.rs +++ b/app/src/ai/orchestration/remote_child_tests.rs @@ -18,7 +18,7 @@ use crate::server::team_scope::RequestTeamScope; use crate::workspaces::user_workspaces::TeamContextForOperation; fn request_team_scope() -> RequestTeamScope { - RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(7.into())) + crate::server::team_scope::request_team_scope(&TeamContextForOperation::new_for_test(7.into())) } fn config(harness_type: &str) -> RemoteChildLaunchConfig { @@ -72,6 +72,7 @@ fn prepared_remote_request_matches_gui_wire_semantics() { lifecycle_subscription: None, parent_conversation_id: crate::ai::agent::conversation::AIConversationId::new(), parent_run_id: Some("parent-run".to_string()), + request_team_scope: request_team_scope(), }; app.read(|ctx| { let prepared = prepare_remote_child_launch( @@ -176,6 +177,7 @@ fn repo_qualified_skill_spec_resolves_into_runtime_skills() { lifecycle_subscription: None, parent_conversation_id: crate::ai::agent::conversation::AIConversationId::new(), parent_run_id: Some("parent-run".to_string()), + request_team_scope: request_team_scope(), }; app.read(|ctx| { let prepared = prepare_remote_child_launch( @@ -227,6 +229,7 @@ fn missing_repo_qualified_skill_reports_repository_and_reason() { lifecycle_subscription: None, parent_conversation_id: crate::ai::agent::conversation::AIConversationId::new(), parent_run_id: Some("parent-run".to_string()), + request_team_scope: request_team_scope(), }; let error = app.read(|ctx| { prepare_remote_child_launch( diff --git a/app/src/ai/orchestration/snapshots.rs b/app/src/ai/orchestration/snapshots.rs index 85db12fdf94..fbbe3381e51 100644 --- a/app/src/ai/orchestration/snapshots.rs +++ b/app/src/ai/orchestration/snapshots.rs @@ -24,6 +24,7 @@ use crate::ai::local_harness_setup::{ LocalHarnessSetupState, local_harness_is_product_enabled, local_harness_setup_state, }; use crate::cloud_object::CloudObjectLookup as _; +use crate::server::team_scope::RequestTeamScope; use crate::workspaces::user_workspaces::TeamScope; const DEFAULT_MODEL_LABEL: &str = "Default model"; @@ -419,14 +420,18 @@ enum AuthSecretNamesInput { /// managed-secret names. Secret values are never included — names only. /// Status mirrors `AuthSecretFetchState`; the `CreateNewAuthSecret` /// footer is emitted for harnesses with managed-secret types. -pub fn api_key_snapshot(state: &OrchestrationConfigState, ctx: &AppContext) -> OptionSnapshot { +pub fn api_key_snapshot( + state: &OrchestrationConfigState, + team_scope: RequestTeamScope, + ctx: &AppContext, +) -> OptionSnapshot { let Some(harness) = Harness::parse_orchestration_harness(&state.harness_type) else { return OptionSnapshot::ready(Vec::new(), None); }; if harness == Harness::Oz { return OptionSnapshot::ready(Vec::new(), None); } - let names = match HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(harness) { + let names = match HarnessAvailabilityModel::as_ref(ctx).auth_secrets_for(team_scope, harness) { AuthSecretFetchState::Loaded(secrets) => { AuthSecretNamesInput::Loaded(secrets.iter().map(|s| s.name.clone()).collect()) } diff --git a/app/src/ai/predict/next_command_model.rs b/app/src/ai/predict/next_command_model.rs index 947008d43cc..bf9e7e847c4 100644 --- a/app/src/ai/predict/next_command_model.rs +++ b/app/src/ai/predict/next_command_model.rs @@ -33,7 +33,6 @@ use crate::completer::SessionContext; #[cfg(feature = "local_fs")] use crate::persistence::{database_file_path_for_current_scope, establish_ro_connection}; use crate::server::server_api::{AIApiError, ServerApi}; -use crate::server::team_scope::RequestTeamScope; use crate::settings::AISettings; #[cfg(feature = "local_fs")] use crate::terminal::ShellHost; @@ -377,8 +376,9 @@ impl NextCommandModel { let server_api = self.server_api.clone(); let terminal_model = self.model.clone(); let cached_next_command_context = self.cached_zerostate_next_command_context.clone(); - let team_scope = - RequestTeamScope::from_scope(&self.ai_controller.as_ref(ctx).team_context(ctx)); + let team_scope = crate::server::team_scope::request_team_scope( + &self.ai_controller.as_ref(ctx).team_context(ctx), + ); let completion_context = completer_data.completion_session_context(ctx); // This is only needed if we have a prefix. diff --git a/app/src/editor/view/voice.rs b/app/src/editor/view/voice.rs index e0c443a65c1..79697244743 100644 --- a/app/src/editor/view/voice.rs +++ b/app/src/editor/view/voice.rs @@ -460,7 +460,7 @@ impl EditorView { let language = AISettings::as_ref(ctx) .voice_input_language_code() .map(str::to_owned); - let team_scope = RequestTeamScope::from_scope( + let team_scope = crate::server::team_scope::request_team_scope( &UserWorkspaces::as_ref(ctx).team_context_for_view(ctx), ); if !state.lifecycle.begin_transcribing() { diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index f3a06cd6911..8e0fcbaef9b 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -133,6 +133,9 @@ fn initialize_app_with_history(app: &mut App, conversations: Vec RequestTeamScope { - RequestTeamScope::from_scope(&TeamlessScopeForTest) + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest) } #[test] fn normalize_local_child_harness_accepts_supported_aliases() { @@ -341,12 +341,13 @@ async fn prepare_local_claude_child_propagates_scope_and_merges_model_env_var() ); let _path = EnvVarGuard::set("PATH", fake_bin_dir.path().as_os_str().to_os_string()); let team_uid = ServerId::from(7); + let expected_team_uid = team_uid.uid(); let mut ai_client = MockAIClient::new(); ai_client .expect_create_agent_task() .times(1) - .withf(move |_, _, _, _, scope| scope.team_uid() == Some(team_uid)) + .withf(move |_, _, _, _, scope| scope.team_uid() == Some(expected_team_uid.as_str())) .returning(|_, _, _, _, _| Ok("550e8400-e29b-41d4-a716-446655440000".parse().unwrap())); let prepared = prepare_local_harness_child_launch( @@ -358,7 +359,9 @@ async fn prepare_local_claude_child_propagates_scope_and_merges_model_env_var() Some(ShellType::Zsh), Some(working_dir), Arc::new(ai_client), - RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)), + crate::server::team_scope::request_team_scope(&TeamContextForOperation::new_for_test( + team_uid, + )), ) .await .unwrap(); diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 959a3d81d63..0ba22df57f8 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1591,7 +1591,7 @@ fn dispatch_start_agent_conversation( runner_id, agent_identity_uid, } => { - let request_team_scope = RequestTeamScope::from_scope(&team_context); + let request_team_scope = request.request_team_scope.clone(); let working_dir = group .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| view.as_ref(ctx).pwd_if_local(ctx)) @@ -1654,7 +1654,7 @@ fn launch_local_no_harness_child( let host_source = group .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| host_terminal_shared_session_source_type(&view, ctx)); - let request_team_scope = RequestTeamScope::from_scope(&team_context); + let request_team_scope = crate::server::team_scope::request_team_scope(&team_context); let launch = prepare_local_oz_child_launch( &request.name, @@ -1795,7 +1795,7 @@ fn launch_local_harness_child( let model_id_for_harness_env = model_id.clone(); let agent_name_for_task = agent_name.clone(); - let request_team_scope = RequestTeamScope::from_scope(&team_context); + let request_team_scope = crate::server::team_scope::request_team_scope(&team_context); let _ = ctx.spawn( async move { prepare_local_harness_child_launch( @@ -1980,7 +1980,7 @@ fn launch_remote_child( model.record_new_conversation_request_complete(request_id, conversation_id, ctx); }); - let prepared = match prepare_remote_child_launch(&request, config, team_scope, ctx) { + let prepared = match prepare_remote_child_launch(&request, config, team_scope.clone(), ctx) { Ok(prepared) => prepared, Err(error) => { let error_message = error.user_message(); diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index ee8d40cc579..32c6976c7a8 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -593,9 +593,7 @@ impl ServerApi { } fn team_uid_header_value(team_scope: RequestTeamScope) -> Option { - team_scope - .team_uid() - .map(|team_uid| team_uid.uid().to_string()) + team_scope.team_uid().map(str::to_string) } /// Opens an SSE stream to the agent event-push endpoint. @@ -1113,7 +1111,7 @@ impl ServerApi { ChannelState::server_root_url() )); if let Some(team_uid) = team_scope.team_uid() { - request_builder = request_builder.header(TEAM_UID_HEADER, team_uid.uid()); + request_builder = request_builder.header(TEAM_UID_HEADER, team_uid); } let response = if let Some(token) = auth_token.as_bearer_token() { request_builder.bearer_auth(token) @@ -1145,7 +1143,7 @@ impl ServerApi { request_builder = request_builder.bearer_auth(token); } if let Some(team_uid) = team_scope.team_uid() { - request_builder = request_builder.header(TEAM_UID_HEADER, team_uid.uid()); + request_builder = request_builder.header(TEAM_UID_HEADER, team_uid); } let response = request_builder .json(request) @@ -1186,7 +1184,7 @@ impl ServerApi { request_builder = request_builder.bearer_auth(token); } if let Some(team_uid) = team_scope.team_uid() { - request_builder = request_builder.header(TEAM_UID_HEADER, team_uid.uid()); + request_builder = request_builder.header(TEAM_UID_HEADER, team_uid); } let response = request_builder .json(request) @@ -1210,7 +1208,7 @@ impl ServerApi { ChannelState::server_root_url() )); if let Some(team_uid) = team_scope.team_uid() { - request_builder = request_builder.header(TEAM_UID_HEADER, team_uid.uid()); + request_builder = request_builder.header(TEAM_UID_HEADER, team_uid); } let response = if let Some(token) = auth_token.as_bearer_token() { request_builder.bearer_auth(token) @@ -1240,7 +1238,7 @@ impl ServerApi { .http_client() .post(format!("{}/ai/transcribe", ChannelState::server_root_url())); if let Some(team_uid) = team_scope.team_uid() { - request_builder = request_builder.header(TEAM_UID_HEADER, team_uid.uid()); + request_builder = request_builder.header(TEAM_UID_HEADER, team_uid); } let response = if let Some(token) = auth_token.as_bearer_token() { request_builder.bearer_auth(token) diff --git a/app/src/server/server_api/managed_secrets.rs b/app/src/server/server_api/managed_secrets.rs index 20d2d5d9461..f5f95588577 100644 --- a/app/src/server/server_api/managed_secrets.rs +++ b/app/src/server/server_api/managed_secrets.rs @@ -33,21 +33,41 @@ use warp_graphql::queries::managed_secret_config::{ use warp_graphql::queries::task_secrets::{ ManagedSecretValue, TaskSecrets, TaskSecretsInput, TaskSecretsResult, TaskSecretsVariables, }; +#[cfg(any(test, all(feature = "tui", feature = "test-util")))] +use warp_managed_secrets::ManagedSecretManager; pub use warp_managed_secrets::client::{ManagedSecretConfigs, ManagedSecretsClient}; use warp_managed_secrets::client::{SecretOwner, TaskIdentityToken}; +use warp_request_context::RequestTeamScope; +#[cfg(any(test, all(feature = "tui", feature = "test-util")))] +use warpui::SingletonEntity as _; use super::ServerApi; use crate::server::graphql::{get_request_context, get_user_facing_error_message}; +#[cfg(any(test, all(feature = "tui", feature = "test-util")))] +pub(crate) fn managed_secret_manager_for_test( + ctx: &mut warpui::ModelContext, +) -> ManagedSecretManager { + ManagedSecretManager::new( + super::ServerApiProvider::as_ref(ctx).get_managed_secrets_client(), + crate::auth::AuthStateProvider::as_ref(ctx).get().clone(), + ) +} + #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl ManagedSecretsClient for ServerApi { - async fn get_managed_secret_configs(&self) -> Result { + async fn get_managed_secret_configs( + &self, + team_scope: RequestTeamScope, + ) -> Result { let variables = GetManagedSecretConfigVariables { request_context: get_request_context(), }; let operation = GetManagedSecretConfig::build(variables); - let response = self.send_graphql_request(operation, None).await?; + let response = self + .send_graphql_request_for_team(operation, team_scope) + .await?; match response.user { UserResult::UserOutput(output) => { @@ -80,6 +100,7 @@ impl ManagedSecretsClient for ServerApi { async fn create_managed_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, secret_type: ManagedSecretType, @@ -108,7 +129,9 @@ impl ManagedSecretsClient for ServerApi { request_context: get_request_context(), }; let operation = CreateManagedSecret::build(variables); - let response = self.send_graphql_request(operation, None).await?; + let response = self + .send_graphql_request_for_team(operation, team_scope) + .await?; match response.create_managed_secret { CreateManagedSecretResult::CreateManagedSecretOutput(output) => { @@ -123,7 +146,12 @@ impl ManagedSecretsClient for ServerApi { } } - async fn delete_managed_secret(&self, owner: SecretOwner, name: String) -> Result<()> { + async fn delete_managed_secret( + &self, + team_scope: RequestTeamScope, + owner: SecretOwner, + name: String, + ) -> Result<()> { let graphql_owner = match owner { SecretOwner::CurrentUser => Owner { type_: OwnerType::User, @@ -143,7 +171,9 @@ impl ManagedSecretsClient for ServerApi { request_context: get_request_context(), }; let operation = DeleteManagedSecret::build(variables); - let response = self.send_graphql_request(operation, None).await?; + let response = self + .send_graphql_request_for_team(operation, team_scope) + .await?; match response.delete_managed_secret { DeleteManagedSecretResult::DeleteManagedSecretOutput(_) => Ok(()), @@ -158,6 +188,7 @@ impl ManagedSecretsClient for ServerApi { async fn update_managed_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, encrypted_value: Option, @@ -184,7 +215,9 @@ impl ManagedSecretsClient for ServerApi { request_context: get_request_context(), }; let operation = UpdateManagedSecret::build(variables); - let response = self.send_graphql_request(operation, None).await?; + let response = self + .send_graphql_request_for_team(operation, team_scope) + .await?; match response.update_managed_secret { UpdateManagedSecretResult::UpdateManagedSecretOutput(output) => { @@ -201,6 +234,7 @@ impl ManagedSecretsClient for ServerApi { async fn list_harness_auth_secrets( &self, + team_scope: RequestTeamScope, harness: warp_graphql::ai::AgentHarness, ) -> Result> { let Some(harness_input) = Option::< @@ -215,7 +249,9 @@ impl ManagedSecretsClient for ServerApi { request_context: get_request_context(), }; let operation = ListHarnessAuthSecrets::build(variables); - let response = self.send_graphql_request(operation, None).await?; + let response = self + .send_graphql_request_for_team(operation, team_scope) + .await?; match response.harness_auth_secrets { warp_graphql::queries::list_harness_auth_secrets::HarnessAuthSecretsResult::HarnessAuthSecretsOutput(output) => { @@ -230,14 +266,16 @@ impl ManagedSecretsClient for ServerApi { } } - async fn list_secrets(&self) -> Result> { + async fn list_secrets(&self, team_scope: RequestTeamScope) -> Result> { let variables = ListManagedSecretsVariables { // Pagination over managed secrets is not yet supported. input: ManagedSecretsInput { cursor: None }, request_context: get_request_context(), }; let operation = ListManagedSecrets::build(variables); - let response = self.send_graphql_request(operation, None).await?; + let response = self + .send_graphql_request_for_team(operation, team_scope) + .await?; match response.managed_secrets { ManagedSecretsResult::ManagedSecretsOutput(output) => Ok(output.managed_secrets), diff --git a/app/src/server/server_api_tests.rs b/app/src/server/server_api_tests.rs index eaa31a80f0f..9b7d623e21e 100644 --- a/app/src/server/server_api_tests.rs +++ b/app/src/server/server_api_tests.rs @@ -113,14 +113,18 @@ fn out_of_credits_429_wraps_quota_limit_and_stays_transient() { #[test] fn team_uid_header_value_includes_only_resolved_team_scope() { let team_uid = 7.into(); - let team_scope = RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)); + let team_scope = crate::server::team_scope::request_team_scope( + &TeamContextForOperation::new_for_test(team_uid), + ); assert_eq!( ServerApi::team_uid_header_value(team_scope), Some(team_uid.uid().to_string()) ); assert_eq!( - ServerApi::team_uid_header_value(RequestTeamScope::from_scope(&TeamlessScopeForTest)), + ServerApi::team_uid_header_value(crate::server::team_scope::request_team_scope( + &TeamlessScopeForTest + )), None ); } diff --git a/app/src/server/team_scope.rs b/app/src/server/team_scope.rs index b8179f7d7fe..abb0c0bb47e 100644 --- a/app/src/server/team_scope.rs +++ b/app/src/server/team_scope.rs @@ -1,28 +1,6 @@ -use crate::server::ids::ServerId; -use crate::workspaces::user_workspaces::TeamScope; - -/// The team an outbound request is scoped to, as sent in `X-Warp-Team-Uid`. -/// -/// A [`TeamScope`] is the only way to name one; there is no constructor from a bare `ServerId`, -/// because a loose uid cannot say which team resolved it, and "no team" is a scope's answer to -/// give rather than a value to pass. The field is private to this module and this module holds -/// nothing else, so [`Self::from_scope`] is provably the only way to build one -- anything else -/// added here gains the ability to forge a team. -/// -/// `Copy`, unlike the [`TeamScope`] types it comes from -- those are deliberately not, so a live -/// scope cannot be stashed where it outlives its window. A resolved snapshot has no such hazard, -/// so `ResponseStream` can reuse one capture across every retry rather than re-resolving to -/// whatever team its window switched to since. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RequestTeamScope(Option); +pub use warp_request_context::RequestTeamScope; -impl RequestTeamScope { - pub fn from_scope(scope: &impl TeamScope) -> Self { - Self(scope.team_uid()) - } - - /// The wire uid. `None` sends no team header, leaving the server to its own default. - pub(crate) fn team_uid(self) -> Option { - self.0 - } +use crate::workspaces::user_workspaces::TeamScope; +pub fn request_team_scope(scope: &(impl TeamScope + ?Sized)) -> RequestTeamScope { + RequestTeamScope::new(scope.team_uid().map(|team_uid| team_uid.uid())) } diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 9b8a9c07e29..4e466be1091 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -186,7 +186,7 @@ use crate::ai::blocklist::{ QueuedQueryOrigin, SlashCommandRequest, ai_brand_color, ai_indicator_height, render_ai_agent_mode_icon, render_ai_follow_up_icon, }; -use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_agent_settings::{AuthSecretPreference, CloudAgentSettings}; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; use crate::ai::connected_self_hosted_workers::{ ConnectedSelfHostedWorkersEvent, ConnectedSelfHostedWorkersModel, @@ -264,7 +264,6 @@ use crate::server::server_api::ServerApi; use crate::server::server_api::ai::AttachmentInput; use crate::server::server_api::ai::{AIClient, AttachmentFileInfo}; use crate::server::server_api::presigned_upload::upload_to_target; -use crate::server::team_scope::RequestTeamScope; use crate::server::telemetry::{ AICommandSearchEntrypoint, AgentModeAutoDetectionFalsePositivePayload, AgentModeAutoDetectionSettingOrigin, AnonymousUserSignupEntrypoint, CommandXRayTrigger, @@ -2640,22 +2639,24 @@ impl Input { } }); - // Cloud-mode side effects on FTUX events: update the pane's harness auth secret, persist - // `last_selected_auth_secret`, mark FTUX completed. let vm_for_events = view_model.clone(); - ctx.subscribe_to_view(&ftux_view, move |_me, _, event, ctx| match event { + ctx.subscribe_to_view(&ftux_view, move |me, _, event, ctx| match event { AuthSecretFtuxViewEvent::SecretSelected { harness, name } | AuthSecretFtuxViewEvent::Created { harness, name } => { let harness = *harness; let name = name.clone(); + let team_scope = crate::server::team_scope::request_team_scope(&me.team_scope(ctx)); vm_for_events.update(ctx, |model, ctx| { model.set_harness_auth_secret_name(Some(name.clone()), ctx); }); CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { settings.mark_harness_auth_ftux_completed(harness, ctx); - let mut map = settings.last_selected_auth_secret.value().clone(); - map.insert(harness.config_name().to_string(), name); - let _ = settings.last_selected_auth_secret.set_value(map, ctx); + settings.persist_auth_secret_preference( + team_scope, + harness, + Some(AuthSecretPreference::Named(name)), + ctx, + ); }); } AuthSecretFtuxViewEvent::Cancelled => { @@ -14377,7 +14378,7 @@ impl Input { }; let server_api = self.server_api.clone(); - let team_scope = RequestTeamScope::from_scope( + let team_scope = crate::server::team_scope::request_team_scope( &UserWorkspaces::as_ref(ctx).team_context_for_operation(ctx), ); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index e2283bad68d..26db6ae470e 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -329,6 +329,9 @@ pub fn initialize_app(app: &mut App) { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model( + crate::server::server_api::managed_secrets::managed_secret_manager_for_test, + ); app.add_singleton_model(LLMPreferences::new); app.add_singleton_model(HarnessAvailabilityModel::new); app.add_singleton_model(ConnectedSelfHostedWorkersModel::new); diff --git a/app/src/terminal/view/ambient_agent/auth_secret_ftux_dropdown.rs b/app/src/terminal/view/ambient_agent/auth_secret_ftux_dropdown.rs index 075bca76db1..57f6b0854a9 100644 --- a/app/src/terminal/view/ambient_agent/auth_secret_ftux_dropdown.rs +++ b/app/src/terminal/view/ambient_agent/auth_secret_ftux_dropdown.rs @@ -10,6 +10,7 @@ use warpui::elements::{ }; use warpui::{ AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, + WeakViewHandle, }; use crate::ai::auth_secret_types::auth_secret_types_for_harness; @@ -21,7 +22,9 @@ use crate::editor::{ SingleLineEditorOptions, TextOptions, }; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuVariant}; +use crate::server::team_scope::RequestTeamScope; use crate::ui_components::icons::Icon; +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const MENU_WIDTH: f32 = 720.; @@ -65,6 +68,7 @@ pub enum FtuxDropdownEvent { } pub struct AuthSecretFtuxDropdown { + view_handle: WeakViewHandle, search_editor: ViewHandle, search_query: String, menu: ViewHandle>, @@ -120,25 +124,46 @@ impl AuthSecretFtuxDropdown { ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), - |me, _, event, ctx| match event { - HarnessAvailabilityEvent::AuthSecretsLoaded - | HarnessAvailabilityEvent::AuthSecretCreated { .. } - | HarnessAvailabilityEvent::AuthSecretDeleted { .. } - | HarnessAvailabilityEvent::AuthSecretsFetchFailed => { - me.refresh_menu(ctx); - ctx.notify(); + |me, _, event, ctx| { + let team_scope = me.request_team_scope(ctx); + if event + .team_scope() + .is_some_and(|event_scope| event_scope != team_scope) + { + return; + } + match event { + HarnessAvailabilityEvent::AuthSecretsLoaded { .. } + | HarnessAvailabilityEvent::AuthSecretCreated { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } => { + me.refresh_menu(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} } - HarnessAvailabilityEvent::Changed - | HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } - | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, ); ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, _, ctx| { me.refresh_menu(ctx); }); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + let affects_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if affects_window { + me.refresh_for_team_scope_change(ctx); + } + }); let mut me = Self { + view_handle: ctx.handle(), search_editor, search_query: String::new(), menu, @@ -153,6 +178,24 @@ impl AuthSecretFtuxDropdown { me } + fn request_team_scope(&self, ctx: &AppContext) -> RequestTeamScope { + crate::server::team_scope::request_team_scope( + &UserWorkspaces::as_ref(ctx).team_context(&self.view_handle, ctx), + ) + } + + fn refresh_for_team_scope_change(&mut self, ctx: &mut ViewContext) { + self.refresh_menu(ctx); + if self.is_menu_open { + let team_scope = self.request_team_scope(ctx); + let harness = self.harness; + HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { + model.ensure_auth_secrets_fetched(team_scope, harness, ctx); + }); + } + ctx.notify(); + } + /// Toggle compact mode. See the `compact_mode` field for what changes. /// Idempotent. pub fn set_compact_mode(&mut self, compact: bool, ctx: &mut ViewContext) { @@ -273,8 +316,9 @@ impl AuthSecretFtuxDropdown { editor.system_clear_buffer(true, ctx); }); if self.is_menu_open { + let team_scope = self.request_team_scope(ctx); HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { - model.ensure_auth_secrets_fetched(harness, ctx); + model.ensure_auth_secrets_fetched(team_scope, harness, ctx); }); } self.refresh_menu(ctx); @@ -283,9 +327,10 @@ impl AuthSecretFtuxDropdown { fn matching_secret_count(&self, app: &AppContext) -> usize { let harness = self.harness; + let team_scope = self.request_team_scope(app); let availability = HarnessAvailabilityModel::as_ref(app); let query = self.search_query.trim().to_lowercase(); - match availability.auth_secrets_for(harness) { + match availability.auth_secrets_for(team_scope, harness) { AuthSecretFetchState::Loaded(secrets) => { if query.is_empty() { secrets.len() @@ -308,6 +353,7 @@ impl AuthSecretFtuxDropdown { let border = Border::all(1.).with_border_color(internal_colors::neutral_4(theme)); let harness = self.harness; + let team_scope = self.request_team_scope(ctx); let availability = HarnessAvailabilityModel::as_ref(ctx); let query = self.search_query.trim().to_lowercase(); let compact = self.compact_mode; @@ -335,7 +381,7 @@ impl AuthSecretFtuxDropdown { return; } - match availability.auth_secrets_for(harness) { + match availability.auth_secrets_for(team_scope, harness) { AuthSecretFetchState::Loaded(secrets) => { let mut matched = false; for secret in secrets { diff --git a/app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs b/app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs index cb759d9dd49..6ddad133b88 100644 --- a/app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs +++ b/app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs @@ -28,13 +28,14 @@ use crate::editor::{ SingleLineEditorOptions, TextOptions, }; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; +use crate::server::team_scope::RequestTeamScope; use crate::terminal::view::ambient_agent::auth_secret_ftux_dropdown::{ AuthSecretFtuxDropdown, FtuxDropdownEvent, }; use crate::ui_components::icons::Icon as UiIcon; use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; -use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const DESCRIPTION_FONT_SIZE: f32 = 14.; @@ -106,6 +107,7 @@ struct SecretCreationState { /// cancel. Used to filter the global `AuthSecretCreated` event so /// another concurrent FTUX view's success can't close us. pending_name: Option, + pending_team_scope: Option, } /// Validated, ready-to-submit snapshot of the creation form. @@ -157,8 +159,9 @@ impl AuthSecretFtuxView { ctx.subscribe_to_view(&ftux_dropdown, |me, _, event, ctx| { if matches!(event, FtuxDropdownEvent::Opened) { let harness = me.harness; + let team_scope = me.request_team_scope(ctx); HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { - model.ensure_auth_secrets_fetched(harness, ctx); + model.ensure_auth_secrets_fetched(team_scope, harness, ctx); }); } }); @@ -195,7 +198,11 @@ impl AuthSecretFtuxView { ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), |me, _, event, ctx| match event { - HarnessAvailabilityEvent::AuthSecretCreated { harness, name } => { + HarnessAvailabilityEvent::AuthSecretCreated { + team_scope, + harness, + name, + } => { // Only consume the event when it matches the request // *this* view actually fired. Without the harness/name // match a concurrent FTUX view's success would close @@ -205,20 +212,23 @@ impl AuthSecretFtuxView { state.is_saving && state.harness == *harness && state.pending_name.as_deref() == Some(name.as_str()) + && state.pending_team_scope == Some(team_scope.clone()) }); if is_ours { me.handle_secret_created(*harness, name.clone(), ctx); } } - HarnessAvailabilityEvent::AuthSecretCreationFailed { error } => { + HarnessAvailabilityEvent::AuthSecretCreationFailed { team_scope, error } => { // Only react if *we* are mid-save; otherwise this // failure belongs to another FTUX view's request. if let Some(state) = me.creation_state.as_mut() { - if !state.is_saving { + if !state.is_saving || state.pending_team_scope != Some(team_scope.clone()) + { return; } state.is_saving = false; state.pending_name = None; + state.pending_team_scope = None; let window_id = ctx.window_id(); let message = format!("Failed to save API key: {error}"); ToastStack::handle(ctx).update(ctx, |ts, ctx| { @@ -235,12 +245,24 @@ impl AuthSecretFtuxView { } } HarnessAvailabilityEvent::Changed - | HarnessAvailabilityEvent::AuthSecretsLoaded - | HarnessAvailabilityEvent::AuthSecretsFetchFailed + | HarnessAvailabilityEvent::AuthSecretsLoaded { .. } + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } | HarnessAvailabilityEvent::AuthSecretDeleted { .. } | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, ); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + let affects_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if affects_window { + me.clear_creation_state(ctx); + ctx.notify(); + } + }); Self { view_handle: ctx.handle(), @@ -262,6 +284,12 @@ impl AuthSecretFtuxView { } } + fn request_team_scope(&self, ctx: &AppContext) -> RequestTeamScope { + crate::server::team_scope::request_team_scope( + &UserWorkspaces::as_ref(ctx).team_context(&self.view_handle, ctx), + ) + } + /// Switch to the orchestration modal's compact presentation. See the /// `compact_mode` field for what changes. pub fn with_compact_mode(mut self, ctx: &mut ViewContext) -> Self { @@ -571,6 +599,7 @@ impl AuthSecretFtuxView { secret_type_index: type_index, is_saving: false, pending_name: None, + pending_team_scope: None, }); self.ftux_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_display_label(Some(info.display_name.to_string()), ctx); @@ -666,8 +695,12 @@ impl AuthSecretFtuxView { // Defensive: `validated_form_snapshot` already enforces // the same required-field rules. let msg = err.to_string(); + let team_scope = self.request_team_scope(ctx); HarnessAvailabilityModel::handle(ctx).update(ctx, |_model, ctx| { - ctx.emit(HarnessAvailabilityEvent::AuthSecretCreationFailed { error: msg }); + ctx.emit(HarnessAvailabilityEvent::AuthSecretCreationFailed { + team_scope, + error: msg, + }); }); return; } @@ -677,25 +710,31 @@ impl AuthSecretFtuxView { state.is_saving = true; state.pending_name = Some(name.clone()); } + let (team_scope, owner) = { + let workspaces = UserWorkspaces::as_ref(ctx); + let team_context = workspaces.team_context_for_operation(ctx); + let team_scope = crate::server::team_scope::request_team_scope(&team_context); + let owner = if self.share_with_team { + workspaces + .team_for_view(ctx) + .map(|team| SecretOwner::Team { + team_uid: team.uid.uid(), + }) + .unwrap_or(SecretOwner::CurrentUser) + } else { + SecretOwner::CurrentUser + }; + (team_scope, owner) + }; + if let Some(state) = self.creation_state.as_mut() { + state.pending_team_scope = Some(team_scope.clone()); + } ctx.notify(); - - let owner = self.resolve_secret_owner(ctx); HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { - model.create_auth_secret(harness, name, value, owner, ctx); + model.create_auth_secret(team_scope, harness, name, value, owner, ctx); }); } - fn resolve_secret_owner(&self, ctx: &ViewContext) -> SecretOwner { - if self.share_with_team - && let Some(team) = UserWorkspaces::as_ref(ctx).team_for_view(ctx) - { - return SecretOwner::Team { - team_uid: team.uid.uid(), - }; - } - SecretOwner::CurrentUser - } - fn clear_creation_state(&mut self, ctx: &mut ViewContext) { if self.creation_state.is_some() { self.creation_state = None; diff --git a/app/src/terminal/view/ambient_agent/auth_secret_selector.rs b/app/src/terminal/view/ambient_agent/auth_secret_selector.rs index d4b29bff6f5..3a54909a5a6 100644 --- a/app/src/terminal/view/ambient_agent/auth_secret_selector.rs +++ b/app/src/terminal/view/ambient_agent/auth_secret_selector.rs @@ -1,13 +1,11 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; use pathfinder_geometry::vector::vec2f; -use settings::Setting as _; use warp_cli::agent::Harness; use warp_core::ui::appearance::Appearance; use warp_core::ui::theme::Fill; use warp_core::ui::theme::color::internal_colors; -use warp_errors::report_if_error; use warp_managed_secrets::client::SecretOwner; use warpui::elements::{ Border, ChildAnchor, ChildView, OffsetPositioning, ParentAnchor, ParentElement as _, @@ -19,11 +17,12 @@ use warpui::{ }; use crate::ai::auth_secret_types::auth_secret_types_for_harness; -use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_agent_settings::{AuthSecretPreference, CloudAgentSettings}; use crate::ai::harness_availability::{ AuthSecretFetchState, HarnessAvailabilityEvent, HarnessAvailabilityModel, }; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuVariant}; +use crate::server::team_scope::RequestTeamScope; use crate::terminal::input::{MenuPositioning, MenuPositioningProvider}; use crate::terminal::view::ambient_agent::delete_auth_secret_confirmation_dialog::{ DeleteAuthSecretConfirmationDialog, DeleteAuthSecretConfirmationDialogEvent, @@ -35,6 +34,7 @@ use crate::ui_components::icons::Icon; use crate::view_components::DismissibleToast; use crate::view_components::action_button::{ActionButton, ButtonSize}; use crate::workspace::ToastStack; +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const HEADER_FONT_SIZE: f32 = 12.; @@ -166,36 +166,47 @@ impl AuthSecretSelector { ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), - |me, _, event, ctx| match event { - HarnessAvailabilityEvent::AuthSecretsLoaded - | HarnessAvailabilityEvent::AuthSecretCreated { .. } - | HarnessAvailabilityEvent::AuthSecretsFetchFailed => { - me.refresh_menu(ctx); - me.refresh_button(ctx); + |me, _, event, ctx| { + let team_scope = me.request_team_scope(ctx); + if event + .team_scope() + .is_some_and(|event_scope| event_scope != team_scope) + { + return; } - HarnessAvailabilityEvent::AuthSecretDeleted { - harness, - name, - owner, - } => { - me.handle_secret_deleted(*harness, name.clone(), owner.clone(), ctx); - } - HarnessAvailabilityEvent::AuthSecretDeletionFailed { - harness, - name, - owner, - error, - } => { - me.handle_secret_deletion_failed( - *harness, - name.clone(), - owner.clone(), - error.clone(), - ctx, - ); + match event { + HarnessAvailabilityEvent::AuthSecretsLoaded { .. } + | HarnessAvailabilityEvent::AuthSecretCreated { .. } + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } => { + me.refresh_menu(ctx); + me.refresh_button(ctx); + } + HarnessAvailabilityEvent::AuthSecretDeleted { + harness, + name, + owner, + .. + } => { + me.handle_secret_deleted(*harness, name.clone(), owner.clone(), ctx); + } + HarnessAvailabilityEvent::AuthSecretDeletionFailed { + harness, + name, + owner, + error, + .. + } => { + me.handle_secret_deletion_failed( + *harness, + name.clone(), + owner.clone(), + error.clone(), + ctx, + ); + } + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } => {} } - HarnessAvailabilityEvent::Changed - | HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } => {} }, ); @@ -203,6 +214,17 @@ impl AuthSecretSelector { me.refresh_menu(ctx); me.refresh_sidecar(ctx); }); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + let affects_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if affects_window { + me.handle_team_scope_changed(ctx); + } + }); let mut me = Self { button, @@ -233,11 +255,12 @@ impl AuthSecretSelector { return; } let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); - let saved_name = CloudAgentSettings::as_ref(ctx) - .last_selected_auth_secret - .value() - .get(harness.config_name()) - .cloned(); + let saved_name = match CloudAgentSettings::as_ref(ctx) + .auth_secret_preference(self.request_team_scope(ctx), harness) + { + Some(AuthSecretPreference::Named(name)) => Some(name), + Some(AuthSecretPreference::Inherit) | None => None, + }; if let Some(saved_name) = saved_name { // Apply optimistically — secrets may not be fetched yet, but the UI // will update once auth secrets are loaded. @@ -247,6 +270,29 @@ impl AuthSecretSelector { } } + fn request_team_scope(&self, ctx: &ViewContext) -> RequestTeamScope { + crate::server::team_scope::request_team_scope( + &UserWorkspaces::as_ref(ctx).team_context_for_operation(ctx), + ) + } + + fn handle_team_scope_changed(&mut self, ctx: &mut ViewContext) { + self.pending_deletes.clear(); + self.ambient_agent_model.update(ctx, |model, ctx| { + model.set_harness_auth_secret_name(None, ctx); + }); + self.maybe_restore_auth_secret_from_settings(ctx); + self.refresh_button(ctx); + self.refresh_menu(ctx); + if self.is_menu_open { + let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); + let team_scope = self.request_team_scope(ctx); + HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { + model.ensure_auth_secrets_fetched(team_scope, harness, ctx); + }); + } + } + pub fn is_menu_open(&self) -> bool { self.is_menu_open } @@ -266,8 +312,9 @@ impl AuthSecretSelector { self.is_menu_open = is_open; if is_open { let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); + let team_scope = self.request_team_scope(ctx); HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { - model.ensure_auth_secrets_fetched(harness, ctx); + model.ensure_auth_secrets_fetched(team_scope, harness, ctx); }); let selected_action = self .ambient_agent_model @@ -349,10 +396,11 @@ impl AuthSecretSelector { let border = Border::all(1.).with_border_fill(theme.outline()); let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); + let team_scope = self.request_team_scope(ctx); let availability = HarnessAvailabilityModel::as_ref(ctx); let items = build_main_menu_items( harness, - availability.auth_secrets_for(harness), + availability.auth_secrets_for(team_scope, harness), &self.pending_deletes, hover_background, header_text_color, @@ -373,10 +421,13 @@ impl AuthSecretSelector { ) { let removed_pending = self.pending_deletes.remove(&(harness, name.clone(), owner)); + let team_scope = self.request_team_scope(ctx); CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - let mut map = settings.last_selected_auth_secret.value().clone(); - if remove_persisted_auth_secret_selection_if_deleted(&mut map, harness, &name) { - report_if_error!(settings.last_selected_auth_secret.set_value(map, ctx)); + if matches!( + settings.auth_secret_preference(team_scope.clone(), harness), + Some(AuthSecretPreference::Named(selected)) if selected == name + ) { + settings.persist_auth_secret_preference(team_scope, harness, None, ctx); } }); @@ -481,8 +532,9 @@ impl AuthSecretSelector { return; } + let team_scope = self.request_team_scope(ctx); HarnessAvailabilityModel::handle(ctx).update(ctx, |model, ctx| { - model.delete_auth_secret(harness, name, owner, ctx); + model.delete_auth_secret(team_scope, harness, name, owner, ctx); }); // Re-render so the X for the pending row becomes // disabled. @@ -641,19 +693,6 @@ fn build_main_menu_items( items } -fn remove_persisted_auth_secret_selection_if_deleted( - selections: &mut HashMap, - harness: Harness, - name: &str, -) -> bool { - if selections.get(harness.config_name()).map(String::as_str) == Some(name) { - selections.remove(harness.config_name()); - return true; - } - - false -} - fn build_sidecar_items( harness: Harness, hover_background: Fill, @@ -701,28 +740,34 @@ impl TypedActionView for AuthSecretSelector { AuthSecretSelectorAction::SelectSecret(name) => { let name = name.clone(); let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); + let team_scope = self.request_team_scope(ctx); self.ambient_agent_model.update(ctx, |model, ctx| { model.set_harness_auth_secret_name(Some(name.clone()), ctx); }); - // Persist the selection per-harness and mark FTUX completed. CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { settings.mark_harness_auth_ftux_completed(harness, ctx); - let mut map = settings.last_selected_auth_secret.value().clone(); - map.insert(harness.config_name().to_string(), name); - report_if_error!(settings.last_selected_auth_secret.set_value(map, ctx)); + settings.persist_auth_secret_preference( + team_scope, + harness, + Some(AuthSecretPreference::Named(name)), + ctx, + ); }); self.set_menu_visibility(false, ctx); } AuthSecretSelectorAction::ClearSecret => { let harness = self.ambient_agent_model.as_ref(ctx).selected_harness(); + let team_scope = self.request_team_scope(ctx); self.ambient_agent_model.update(ctx, |model, ctx| { model.set_harness_auth_secret_name(None, ctx); }); - // Clear the persisted selection for this harness. CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { - let mut map = settings.last_selected_auth_secret.value().clone(); - map.remove(harness.config_name()); - report_if_error!(settings.last_selected_auth_secret.set_value(map, ctx)); + settings.persist_auth_secret_preference( + team_scope, + harness, + Some(AuthSecretPreference::Inherit), + ctx, + ); }); self.set_menu_visibility(false, ctx); } diff --git a/app/src/terminal/view/ambient_agent/model.rs b/app/src/terminal/view/ambient_agent/model.rs index ddd6299a452..8b2c289af64 100644 --- a/app/src/terminal/view/ambient_agent/model.rs +++ b/app/src/terminal/view/ambient_agent/model.rs @@ -1155,7 +1155,11 @@ impl AmbientAgentViewModel { orchestration_handoff: None, }; - self.spawn_internal(request, RequestTeamScope::from_scope(scope), ctx); + self.spawn_internal( + request, + crate::server::team_scope::request_team_scope(scope), + ctx, + ); } /// Spawn an ambient agent with a fully-constructed request. @@ -1204,7 +1208,7 @@ impl AmbientAgentViewModel { ) { request.interactive = Some(true); self.request = Some(request.clone()); - self.request_team_scope = Some(team_scope); + self.request_team_scope = Some(team_scope.clone()); self.source = None; let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); let stream = spawn_task(request, team_scope, ai_client, None); @@ -1527,7 +1531,8 @@ impl AmbientAgentViewModel { if !matches!(self.status, Status::NeedsGithubAuth { .. }) { return; } - let (Some(request), Some(team_scope)) = (self.request.clone(), self.request_team_scope) + let (Some(request), Some(team_scope)) = + (self.request.clone(), self.request_team_scope.clone()) else { return; }; diff --git a/app/src/terminal/view/ambient_agent/model_selector.rs b/app/src/terminal/view/ambient_agent/model_selector.rs index f2e6221f114..520cd11df91 100644 --- a/app/src/terminal/view/ambient_agent/model_selector.rs +++ b/app/src/terminal/view/ambient_agent/model_selector.rs @@ -191,10 +191,10 @@ impl ModelSelector { me.refresh_button(ctx); me.refresh_menu(ctx); } - HarnessAvailabilityEvent::AuthSecretsLoaded + HarnessAvailabilityEvent::AuthSecretsLoaded { .. } | HarnessAvailabilityEvent::AuthSecretCreated { .. } | HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } - | HarnessAvailabilityEvent::AuthSecretsFetchFailed + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } | HarnessAvailabilityEvent::AuthSecretDeleted { .. } | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, diff --git a/app/src/terminal/view/ambient_agent/model_tests.rs b/app/src/terminal/view/ambient_agent/model_tests.rs index 982322da05e..760b5dd5fe1 100644 --- a/app/src/terminal/view/ambient_agent/model_tests.rs +++ b/app/src/terminal/view/ambient_agent/model_tests.rs @@ -16,7 +16,7 @@ fn attachment() -> AttachmentInput { } fn team_request_scope() -> RequestTeamScope { - RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(7.into())) + crate::server::team_scope::request_team_scope(&TeamContextForOperation::new_for_test(7.into())) } fn add_model(app: &mut App) -> warpui::ModelHandle { @@ -187,7 +187,7 @@ fn duplicate_handoff_completion_is_ignored() { let (cancel, _) = oneshot::channel(); model.begin_local_to_cloud_handoff( retry_request("initial request"), - RequestTeamScope::from_scope(&TeamlessScopeForTest), + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), cancel, ctx, ); @@ -239,7 +239,7 @@ fn handoff_cancellation_is_signalled_and_late_failure_is_ignored() { model.update(&mut app, |model, ctx| { model.begin_local_to_cloud_handoff( retry_request("queued prompt"), - RequestTeamScope::from_scope(&TeamlessScopeForTest), + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), cancel, ctx, ); diff --git a/app/src/terminal/view/queued_prompts_tests.rs b/app/src/terminal/view/queued_prompts_tests.rs index 7c173e018cc..7726c8558cc 100644 --- a/app/src/terminal/view/queued_prompts_tests.rs +++ b/app/src/terminal/view/queued_prompts_tests.rs @@ -38,7 +38,7 @@ use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_te use crate::workspaces::user_workspaces::TeamlessScopeForTest; fn request_team_scope() -> RequestTeamScope { - RequestTeamScope::from_scope(&TeamlessScopeForTest) + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest) } fn user_query(text: &str) -> QueuedQuery { diff --git a/app/src/terminal/view/shared_session/view_impl_tests.rs b/app/src/terminal/view/shared_session/view_impl_tests.rs index caca5342947..e99ccb8c4a1 100644 --- a/app/src/terminal/view/shared_session/view_impl_tests.rs +++ b/app/src/terminal/view/shared_session/view_impl_tests.rs @@ -35,7 +35,6 @@ use crate::server::ids::ServerId; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::server::server_api::ai::SpawnAgentRequest; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] -use crate::server::team_scope::RequestTeamScope; use crate::terminal::TerminalView; use crate::terminal::model::blocks::{INLINE_BANNER_HEIGHT, ToTotalIndex as _}; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; @@ -1011,7 +1010,7 @@ fn test_local_to_cloud_handoff_session_join_keeps_details_panel_hidden() { let (cancel, _) = oneshot::channel(); model.begin_local_to_cloud_handoff( handoff_request_for_test(), - RequestTeamScope::from_scope(&TeamlessScopeForTest), + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), cancel, ctx, ); diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index c9df47ad311..1bc41111c42 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -55,7 +55,6 @@ use crate::pane_group::pane::PaneStack; use crate::pane_group::{BackingView, TerminalPaneId}; use crate::server::ids::{ClientId, SyncId}; use crate::server::server_api::ai::SpawnAgentRequest; -use crate::server::team_scope::RequestTeamScope; use crate::settings::import::model::ImportedConfigModel; use crate::settings::{AISettings, AppEditorSettings, RightClickBehavior, WarpPromptSeparator}; use crate::terminal::alt_screen::should_intercept_mouse; @@ -3451,7 +3450,7 @@ fn cloud_mode_dispatched_agent_inserts_queued_user_query() { snapshot_disabled: None, orchestration_handoff: None, }, - RequestTeamScope::from_scope(&TeamlessScopeForTest), + crate::server::team_scope::request_team_scope(&TeamlessScopeForTest), ctx, ); }); diff --git a/app/src/test_util/terminal.rs b/app/src/test_util/terminal.rs index 8f2d54ecc5b..04f1498440f 100644 --- a/app/src/test_util/terminal.rs +++ b/app/src/test_util/terminal.rs @@ -132,6 +132,9 @@ pub fn initialize_app_for_terminal_view(app: &mut App) { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model( + crate::server::server_api::managed_secrets::managed_secret_manager_for_test, + ); app.add_singleton_model(LLMPreferences::new); app.add_singleton_model(HarnessAvailabilityModel::new); app.add_singleton_model(|ctx| AITipModel::new_for_agent_tips(ctx)); diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 47404d43b40..84ac7aadd68 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -191,7 +191,7 @@ pub use crate::server::server_api::TranscribeError; pub use crate::server::server_api::ai::{ AIClient, AgentConfigSnapshot, AttachmentInput, SpawnAgentRequest, SpawnAgentResponse, }; -pub use crate::server::team_scope::RequestTeamScope; +pub use crate::server::team_scope::{RequestTeamScope, request_team_scope}; pub use crate::server::telemetry::{SlashMenuSource, TelemetryEvent}; pub use crate::settings::{AISettingsChangedEvent, InputSettings}; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; @@ -266,8 +266,8 @@ pub use crate::tui_test_support::{ add_tui_history_test_models, append_tui_history_test_command, blocklist_ai_history_model_with_queries, forkable_tui_conversation_for_test, queue_tui_permission_action, register_tui_input_mode_test_settings, - register_tui_session_view_test_singletons, set_tui_default_team_admin_for_test, - set_tui_workspace_teams_for_test, + register_tui_session_view_test_singletons, set_tui_auth_secret_preference_for_test, + set_tui_default_team_admin_for_test, set_tui_workspace_teams_for_test, }; pub use crate::user_config::{WarpConfig, WarpConfigUpdateEvent}; pub use crate::util::image::{ diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs index 640ac9987fb..851399ddef0 100644 --- a/app/src/tui_test_support.rs +++ b/app/src/tui_test_support.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use ai::api_keys::ApiKeyManager; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use chrono::{Duration, Local}; +use warp_cli::agent::Harness; use warp_core::SessionId; use warp_core::execution_mode::{AppExecutionMode, ExecutionMode}; use warpui::{AppContext, ModelContext, ModelHandle, SingletonEntity as _}; @@ -23,7 +24,7 @@ use crate::ai::blocklist::{ BlocklistAIActionModel, BlocklistAIHistoryModel, BlocklistAIPermissions, PersistedAIInput, PersistedAIInputType, QueuedQueryModel, }; -use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_agent_settings::{AuthSecretPreference, CloudAgentSettings}; use crate::ai::cloud_environments::CloudEnvironmentCatalog; use crate::ai::connected_self_hosted_workers::ConnectedSelfHostedWorkersModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; @@ -41,6 +42,7 @@ use crate::server::experiments::ServerExperiments; use crate::server::ids::ServerId; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; +use crate::server::team_scope::RequestTeamScope; #[cfg(feature = "voice_input")] use crate::server::voice_transcriber::ServerVoiceTranscriber; use crate::settings::manager::SettingsManager; @@ -309,6 +311,22 @@ pub fn set_tui_workspace_teams_for_test(teams: Vec<(ServerId, String)>, ctx: &mu workspaces.set_current_workspace_uid(workspace_uid, ctx); }); } +pub fn set_tui_auth_secret_preference_for_test( + team_scope: RequestTeamScope, + harness: Harness, + secret_name: String, + ctx: &mut AppContext, +) { + CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { + settings.persist_auth_secret_preference( + team_scope, + harness, + Some(AuthSecretPreference::Named(secret_name)), + ctx, + ); + }); +} + /// Queues an action as the active confirmation request for a TUI view test. pub fn queue_tui_permission_action( action_model: &mut BlocklistAIActionModel, @@ -340,6 +358,9 @@ pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model( + crate::server::server_api::managed_secrets::managed_secret_manager_for_test, + ); app.add_singleton_model(|_| TuiOnboardingMarkers::new_ready_for_test(false, false)); app.add_singleton_model(PrivacySettings::mock); app.add_singleton_model(|ctx| { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 4f3a0a70e45..b85d587e8dd 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -210,7 +210,7 @@ use crate::ai::blocklist::{ BlocklistAIHistoryEvent, FORK_PREFIX, PendingAttachment, PendingQueryState, QueuedQueryOrigin, SerializedBlockListItem, SlashCommandRequest, }; -use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_agent_settings::{AuthSecretPreference, CloudAgentSettings}; #[cfg(target_family = "wasm")] use crate::ai::conversation_details_panel::ConversationDetailsPanel; use crate::ai::conversation_utils; @@ -15412,11 +15412,17 @@ impl Workspace { | AuthSecretFtuxViewEvent::Created { harness, name } => { let harness = *harness; let name = name.clone(); + let team_scope = crate::server::team_scope::request_team_scope( + &UserWorkspaces::as_ref(ctx).team_context_for_operation(ctx), + ); CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { settings.mark_harness_auth_ftux_completed(harness, ctx); - let mut map = settings.last_selected_auth_secret.value().clone(); - map.insert(harness.config_name().to_string(), name); - let _ = settings.last_selected_auth_secret.set_value(map, ctx); + settings.persist_auth_secret_preference( + team_scope, + harness, + Some(AuthSecretPreference::Named(name)), + ctx, + ); }); me.dismiss_create_auth_secret_modal(ctx); } diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 63570077665..30a9f417c52 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -98,6 +98,9 @@ pub(crate) fn initialize_app(app: &mut App) { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model( + crate::server::server_api::managed_secrets::managed_secret_manager_for_test, + ); app.add_singleton_model(|_ctx| PtySpawner::new_for_test()); app.add_singleton_model(|_| Prompt::mock()); app.add_singleton_model(|ctx| AutoupdateState::new(ServerApiProvider::as_ref(ctx).get())); diff --git a/app/src/workspaces/user_workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces/user_workspaces_tests.rs index 838afe1efb1..90037722882 100644 --- a/app/src/workspaces/user_workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces/user_workspaces_tests.rs @@ -93,6 +93,37 @@ struct CachedResources { workspaces: Vec, } +#[test] +fn long_lived_view_resolvers_follow_only_their_own_window_team_switch() { + let (platform, security, workspace) = platform_and_security(); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_a, view_a) = create_test_window(&mut app); + let (window_b, view_b) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_a, Some(platform.uid), ctx); + user_workspaces.register_window(window_b, Some(platform.uid), ctx); + }); + let resolver_a = UserWorkspaces::team_context_resolver(view_a.downgrade()); + let resolver_b = UserWorkspaces::team_context_resolver(view_b.downgrade()); + let original_scope = + app.read(|ctx| crate::server::team_scope::request_team_scope(&resolver_a(ctx))); + + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.switch_window_to_team(window_a, security.uid, ctx); + }); + + app.read(|ctx| { + let switched_scope = crate::server::team_scope::request_team_scope(&resolver_a(ctx)); + let sibling_scope = crate::server::team_scope::request_team_scope(&resolver_b(ctx)); + assert_ne!(switched_scope, original_scope); + assert_eq!(sibling_scope, original_scope); + }); + }) +} + fn initialize_app( app: &mut App, resources: CachedResources, diff --git a/crates/managed_secrets/Cargo.toml b/crates/managed_secrets/Cargo.toml index 06da375d4c5..7b400f2429e 100644 --- a/crates/managed_secrets/Cargo.toml +++ b/crates/managed_secrets/Cargo.toml @@ -26,6 +26,7 @@ warp_core.workspace = true warp_graphql.workspace = true vec1.workspace = true warp_isolation_platform.workspace = true +warp_request_context.workspace = true # Pure-Rust HPKE implementation (RFC 9180). hpke = { version = "0.13", default-features = false, features = ["alloc", "x25519"] } diff --git a/crates/managed_secrets/src/client.rs b/crates/managed_secrets/src/client.rs index 2a419c0aed5..b0de11b7caa 100644 --- a/crates/managed_secrets/src/client.rs +++ b/crates/managed_secrets/src/client.rs @@ -7,6 +7,7 @@ use chrono::{DateTime, Utc}; use vec1::Vec1; use warp_graphql::managed_secrets::{ManagedSecret, ManagedSecretConfig, ManagedSecretType}; pub use warp_graphql::queries::task_secrets::ManagedSecretValue; +use warp_request_context::RequestTeamScope; /// An OIDC identity token issued for a task workload. #[derive(Debug, Clone)] @@ -48,10 +49,14 @@ pub enum SecretOwner { #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] pub trait ManagedSecretsClient: 'static + Send + Sync { - async fn get_managed_secret_configs(&self) -> Result; + async fn get_managed_secret_configs( + &self, + team_scope: RequestTeamScope, + ) -> Result; async fn create_managed_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, secret_type: ManagedSecretType, @@ -59,22 +64,29 @@ pub trait ManagedSecretsClient: 'static + Send + Sync { description: Option, ) -> Result; - async fn delete_managed_secret(&self, owner: SecretOwner, name: String) -> Result<()>; + async fn delete_managed_secret( + &self, + team_scope: RequestTeamScope, + owner: SecretOwner, + name: String, + ) -> Result<()>; async fn update_managed_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, encrypted_value: Option, description: Option, ) -> Result; - async fn list_secrets(&self) -> Result>; + async fn list_secrets(&self, team_scope: RequestTeamScope) -> Result>; /// List managed secrets that authenticate the given harness. /// Returns an empty list for harnesses that do not use auth secrets (e.g. Oz). async fn list_harness_auth_secrets( &self, + team_scope: RequestTeamScope, harness: warp_graphql::ai::AgentHarness, ) -> Result>; diff --git a/crates/managed_secrets/src/manager.rs b/crates/managed_secrets/src/manager.rs index 5a9f78a4498..0c271a488c5 100644 --- a/crates/managed_secrets/src/manager.rs +++ b/crates/managed_secrets/src/manager.rs @@ -7,6 +7,7 @@ use vec1::vec1; use warp_core::features::FeatureFlag; use warp_graphql::managed_secrets::ManagedSecret; use warp_graphql::queries::task_secrets::ManagedSecretValue as GqlManagedSecretValue; +use warp_request_context::RequestTeamScope; use warpui_core::{Entity, SingletonEntity}; use crate::ManagedSecretValue; @@ -18,6 +19,7 @@ use crate::envelope::UploadKey; use crate::gcp::{self, GcpWorkloadIdentityFederationError, GcpWorkloadIdentityFederationToken}; /// Singleton model for working with Warp-managed secrets. +#[derive(Clone)] pub struct ManagedSecretManager { client: Arc, actor_provider: Arc, @@ -41,6 +43,7 @@ impl ManagedSecretManager { pub fn create_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, value: ManagedSecretValue, @@ -57,7 +60,9 @@ impl ManagedSecretManager { // We retrieve all upload keys on demand. These should potentially be fetched and stored // ahead of time instead. - let configs = client.get_managed_secret_configs().await?; + let configs = client + .get_managed_secret_configs(team_scope.clone()) + .await?; let Some(actor) = actor_provider.actor_uid() else { return Err(anyhow::anyhow!("No authenticated user")); @@ -76,6 +81,7 @@ impl ManagedSecretManager { let managed_secret = client .create_managed_secret( + team_scope, owner, name, value.secret_type(), @@ -89,6 +95,7 @@ impl ManagedSecretManager { pub fn delete_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, ) -> impl Future> + use<> { @@ -98,13 +105,16 @@ impl ManagedSecretManager { return Err(anyhow::anyhow!("This feature is not enabled")); } - client.delete_managed_secret(owner, name).await?; + client + .delete_managed_secret(team_scope, owner, name) + .await?; Ok(()) } } pub fn update_secret( &self, + team_scope: RequestTeamScope, owner: SecretOwner, name: String, value: Option, @@ -124,7 +134,9 @@ impl ManagedSecretManager { let encrypted_value = if let Some(value) = value { // We retrieve all upload keys on demand. These should potentially be fetched and stored // ahead of time instead. - let configs = client.get_managed_secret_configs().await?; + let configs = client + .get_managed_secret_configs(team_scope.clone()) + .await?; let Some(actor) = actor_provider.actor_uid() else { return Err(anyhow::anyhow!("No authenticated user")); @@ -146,17 +158,34 @@ impl ManagedSecretManager { }; let managed_secret = client - .update_managed_secret(owner, name, encrypted_value, description) + .update_managed_secret(team_scope, owner, name, encrypted_value, description) .await?; Ok(managed_secret) } } /// List all managed secrets accessible to the current user. - pub fn list_secrets(&self) -> impl Future>> + use<> { + pub fn list_secrets( + &self, + team_scope: RequestTeamScope, + ) -> impl Future>> + use<> { let client = self.client.clone(); async move { - let secrets = client.list_secrets().await?; + let secrets = client.list_secrets(team_scope).await?; + Ok(secrets) + } + } + + pub fn list_harness_auth_secrets( + &self, + team_scope: RequestTeamScope, + harness: warp_graphql::ai::AgentHarness, + ) -> impl Future>> + use<> { + let client = self.client.clone(); + async move { + let secrets = client + .list_harness_auth_secrets(team_scope, harness) + .await?; Ok(secrets) } } diff --git a/crates/request_context/Cargo.toml b/crates/request_context/Cargo.toml new file mode 100644 index 00000000000..f85626b9664 --- /dev/null +++ b/crates/request_context/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "warp_request_context" +version = "0.1.0" +edition = "2024" +authors.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +schemars.workspace = true +serde.workspace = true +settings_value.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/request_context/src/lib.rs b/crates/request_context/src/lib.rs new file mode 100644 index 00000000000..15fe5c7cb13 --- /dev/null +++ b/crates/request_context/src/lib.rs @@ -0,0 +1,24 @@ +#[derive( + Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, schemars::JsonSchema, +)] +pub struct RequestTeamScope(Option); + +impl RequestTeamScope { + pub fn new(team_uid: Option) -> Self { + Self(team_uid) + } + + pub fn is_unscoped(&self) -> bool { + self.0.is_none() + } + + pub fn team_uid(&self) -> Option<&str> { + self.0.as_deref() + } +} + +impl settings_value::SettingsValue for RequestTeamScope {} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/request_context/src/lib_tests.rs b/crates/request_context/src/lib_tests.rs new file mode 100644 index 00000000000..bb596289775 --- /dev/null +++ b/crates/request_context/src/lib_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn unscoped_scope_has_no_team_uid() { + let scope = RequestTeamScope::new(None); + + assert!(scope.is_unscoped()); + assert_eq!(scope.team_uid(), None); +} + +#[test] +fn team_scope_exposes_its_team_uid() { + let scope = RequestTeamScope::new(Some("team-uid".to_string())); + + assert!(!scope.is_unscoped()); + assert_eq!(scope.team_uid(), Some("team-uid")); +} + +#[test] +fn scope_serializes_as_optional_team_uid() { + assert_eq!( + serde_json::to_value(RequestTeamScope::new(Some("team-uid".to_string()))).unwrap(), + serde_json::json!("team-uid") + ); + assert_eq!( + serde_json::to_value(RequestTeamScope::new(None)).unwrap(), + serde_json::Value::Null + ); +} diff --git a/crates/warp_cli/src/secret.rs b/crates/warp_cli/src/secret.rs index 0e8d5b3f978..715f2207c0f 100644 --- a/crates/warp_cli/src/secret.rs +++ b/crates/warp_cli/src/secret.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use clap::{Args, Subcommand, ValueEnum}; -use crate::scope::ObjectScope; +use crate::scope::{ObjectScope, TeamSelection}; /// Secret-related subcommands. #[derive(Debug, Clone, Subcommand)] @@ -217,7 +217,8 @@ pub struct UpdateSecretArgs { #[derive(Debug, Clone, Args)] pub struct ListSecretsArgs { - // TODO: consider flags to filter secrets. + #[clap(flatten)] + pub team_selection: TeamSelection, } #[derive(Debug, Clone, Args)] @@ -252,3 +253,7 @@ impl fmt::Display for SecretType { } } } + +#[cfg(test)] +#[path = "secret_tests.rs"] +mod tests; diff --git a/crates/warp_cli/src/secret_tests.rs b/crates/warp_cli/src/secret_tests.rs new file mode 100644 index 00000000000..82724c1bf4f --- /dev/null +++ b/crates/warp_cli/src/secret_tests.rs @@ -0,0 +1,44 @@ +use clap::Parser; + +use super::{ListSecretsArgs, SecretCommand}; + +#[derive(Debug, Parser)] +struct TestSecret { + #[command(subcommand)] + command: SecretCommand, +} + +fn parse_list(argv: &[&str]) -> ListSecretsArgs { + let mut full = vec!["test"]; + full.extend_from_slice(argv); + let command = TestSecret::try_parse_from(full) + .expect("parse succeeds") + .command; + let SecretCommand::List(args) = command else { + panic!("expected list command"); + }; + args +} + +#[test] +fn list_accepts_explicit_team_uid() { + let args = parse_list(&["list", "--team=team-uid"]); + + assert_eq!(args.team_selection.requested_team_uid(), Some("team-uid")); +} + +#[test] +fn list_accepts_bare_team_selection() { + let args = parse_list(&["list", "--team"]); + + assert!(args.team_selection.is_team()); + assert_eq!(args.team_selection.requested_team_uid(), None); +} + +#[test] +fn list_accepts_no_team_flag() { + let args = parse_list(&["list"]); + + assert!(!args.team_selection.is_team()); + assert_eq!(args.team_selection.requested_team_uid(), None); +} diff --git a/crates/warp_tui/src/orchestration_block.rs b/crates/warp_tui/src/orchestration_block.rs index be4b54647eb..d94d015f029 100644 --- a/crates/warp_tui/src/orchestration_block.rs +++ b/crates/warp_tui/src/orchestration_block.rs @@ -22,7 +22,7 @@ use warp::tui_export::{ OrchestrationEditState, OrchestrationEnteredEvent, OrchestrationEntrySource, RunAgentsCardDecision, RunAgentsExecutionMode, RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsRequest, RunAgentsSpawningSnapshot, TeamContextResolver, UserWorkspaces, - persist_host_selection, resolve_auth_secret_selection_for_harness, + UserWorkspacesEvent, persist_host_selection, resolve_auth_secret_selection_for_harness, resolve_default_environment_id, resolve_default_host_slug, run_agents_card_decision_event, should_show_auth_secret_picker, }; @@ -242,20 +242,30 @@ impl TuiOrchestrationBlock { // so revalidate the edit state and rebuild the active page. ctx.subscribe_to_model( &HarnessAvailabilityModel::handle(ctx), - |me, _, event, ctx| match event { - HarnessAvailabilityEvent::Changed - | HarnessAvailabilityEvent::AuthSecretsLoaded - | HarnessAvailabilityEvent::AuthSecretsFetchFailed - | HarnessAvailabilityEvent::AuthSecretCreated { .. } - | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { - me.orchestration_edit_state - .orchestration_config_state - .revalidate_after_catalog_change(ctx); - me.refresh_active_page(ctx); - ctx.notify(); + |me, _, event, ctx| { + let team_context = (me.team_context_resolver)(ctx); + let team_scope = warp::tui_export::request_team_scope(&team_context); + if event + .team_scope() + .is_some_and(|event_scope| event_scope != team_scope) + { + return; + } + match event { + HarnessAvailabilityEvent::Changed + | HarnessAvailabilityEvent::AuthSecretsLoaded { .. } + | HarnessAvailabilityEvent::AuthSecretsFetchFailed { .. } + | HarnessAvailabilityEvent::AuthSecretCreated { .. } + | HarnessAvailabilityEvent::AuthSecretDeleted { .. } => { + me.orchestration_edit_state + .orchestration_config_state + .revalidate_after_catalog_change(team_scope, ctx); + me.refresh_active_page(ctx); + ctx.notify(); + } + HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } + | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} } - HarnessAvailabilityEvent::AuthSecretCreationFailed { .. } - | HarnessAvailabilityEvent::AuthSecretDeletionFailed { .. } => {} }, ); @@ -263,14 +273,28 @@ impl TuiOrchestrationBlock { // revalidate the edit state and rebuild the active model page. ctx.subscribe_to_model(&LLMPreferences::handle(ctx), |me, _, event, ctx| { if let LLMPreferencesEvent::UpdatedAvailableLLMs = event { + let team_context = (me.team_context_resolver)(ctx); + let team_scope = warp::tui_export::request_team_scope(&team_context); me.orchestration_edit_state .orchestration_config_state - .revalidate_after_catalog_change(ctx); + .revalidate_after_catalog_change(team_scope, ctx); me.refresh_active_page(ctx); ctx.notify(); } }); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + let affects_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if affects_window { + me.handle_team_scope_change(ctx); + } + }); + // Connected worker changes alter the remote host choices shown on // the active page. ctx.subscribe_to_model( @@ -404,9 +428,35 @@ impl TuiOrchestrationBlock { } } if matches!(state.auth_secret_selection, AuthSecretSelection::Unset) { - state.auth_secret_selection = - resolve_auth_secret_selection_for_harness(&state.harness_type, ctx); + state.auth_secret_selection = resolve_auth_secret_selection_for_harness( + warp::tui_export::request_team_scope(&team_context), + &state.harness_type, + ctx, + ); + } + } + + fn handle_team_scope_change(&mut self, ctx: &mut ViewContext) { + let team_context = (self.team_context_resolver)(ctx); + let team_scope = warp::tui_export::request_team_scope(&team_context); + let state = &mut self.orchestration_edit_state.orchestration_config_state; + state.auth_secret_selection = AuthSecretSelection::Unset; + state.revalidate_after_catalog_change(team_scope, ctx); + + if self.accept_error.is_some() { + self.accept_error = self.controller.accept_disabled_reason(state, ctx); + } + if matches!( + self.mode, + CardMode::Configuring { + page: ConfigPage::ApiKey + } + ) { + self.ensure_auth_secrets_fetched(ctx); } + self.refresh_active_page(ctx); + ctx.emit(TuiOrchestrationBlockEvent::LayoutInvalidated); + ctx.notify(); } /// Re-syncs edit state from the latest streaming request chunk @@ -581,8 +631,10 @@ impl TuiOrchestrationBlock { ) else { return; }; + let team_context = (self.team_context_resolver)(ctx); + let team_scope = warp::tui_export::request_team_scope(&team_context); HarnessAvailabilityModel::handle(ctx).update(ctx, |availability, ctx| { - availability.ensure_auth_secrets_fetched(harness, ctx); + availability.ensure_auth_secrets_fetched(team_scope, harness, ctx); }); } @@ -641,11 +693,16 @@ impl TuiOrchestrationBlock { let CardMode::Configuring { page } = self.mode else { return; }; + let team_scope = { + let team_context = (self.team_context_resolver)(ctx); + warp::tui_export::request_team_scope(&team_context) + }; self.controller.apply_page_selection( page, id, &mut self.orchestration_edit_state, self.fallback_base_model_id.clone(), + team_scope, ctx, ); self.finish_page_confirmation(page, ctx); diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index d628763b396..c80851359b2 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -2,9 +2,9 @@ use warp::tui_export::{ AIActionStatus, AIAgentActionId, BlocklistAIActionModel, OptionSnapshot, - OrchestrationConfigState, OrchestrationEditState, RunAgentsExecutionMode, RunAgentsRequest, - TeamContext, accept_disabled_reason_with_auth, api_key_snapshot, environment_snapshot, - harness_snapshot, host_snapshot, location_snapshot, model_snapshot, + OrchestrationConfigState, OrchestrationEditState, RequestTeamScope, RunAgentsExecutionMode, + RunAgentsRequest, TeamContext, accept_disabled_reason_with_auth, api_key_snapshot, + environment_snapshot, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, persist_environment_selection, persist_host_selection, }; use warpui_core::{AppContext, ModelHandle}; @@ -98,6 +98,7 @@ pub(super) trait OrchestrationBlockController { id: &str, edit_state: &mut OrchestrationEditState, fallback_base_model_id: Option, + team_scope: RequestTeamScope, ctx: &mut AppContext, ); @@ -136,7 +137,9 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { match page { ConfigPage::Location => location_snapshot(state, ctx), ConfigPage::Harness => harness_snapshot(state, ctx), - ConfigPage::ApiKey => api_key_snapshot(state, ctx), + ConfigPage::ApiKey => { + api_key_snapshot(state, warp::tui_export::request_team_scope(scope), ctx) + } ConfigPage::Host => host_snapshot(state, scope, ctx), ConfigPage::Environment => environment_snapshot(state, ctx), ConfigPage::Model => model_snapshot(state, ctx), @@ -149,6 +152,7 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { id: &str, edit_state: &mut OrchestrationEditState, fallback_base_model_id: Option, + team_scope: RequestTeamScope, ctx: &mut AppContext, ) { match page { @@ -156,7 +160,12 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { let is_remote = id == LOCATION_CLOUD_ID; if !is_remote { // For now, we only allow local runs to use the oz harness - edit_state.apply_harness_change("oz", fallback_base_model_id.clone(), ctx); + edit_state.apply_harness_change( + team_scope, + "oz", + fallback_base_model_id.clone(), + ctx, + ); } edit_state @@ -165,13 +174,13 @@ impl OrchestrationBlockController for ModelOrchestrationBlockController { normalize_tui_local_harness(&mut edit_state.orchestration_config_state); } ConfigPage::Harness => { - edit_state.apply_harness_change(id, fallback_base_model_id, ctx); + edit_state.apply_harness_change(team_scope, id, fallback_base_model_id, ctx); } ConfigPage::ApiKey => { let name = (!id.is_empty()).then(|| id.to_string()); edit_state .orchestration_config_state - .apply_auth_secret_change(name, ctx); + .apply_auth_secret_change(team_scope, name, ctx); } ConfigPage::Host => { edit_state diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 54a4f4f1f0d..33bb5d3be2d 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -6,10 +6,11 @@ use ai::agent::orchestration_config::{ }; use warp::tui_export::{ AIActionStatus, AIAgentAction, AIAgentActionId, AIAgentActionType, AIConversationId, - Appearance, AuthSecretSelection, OptionRow, OptionSnapshot, OptionSourceStatus, - OrchestrationConfigState, OrchestrationEditState, RunAgentsAgentRunConfig, + Appearance, AuthSecretSelection, Harness, OptionRow, OptionSnapshot, OptionSourceStatus, + OrchestrationConfigState, OrchestrationEditState, RequestTeamScope, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest, ServerApiProvider, TaskId, TeamContext, UserWorkspaces, register_tui_session_view_test_singletons, + set_tui_auth_secret_preference_for_test, set_tui_workspace_teams_for_test, }; use warp_core::features::FeatureFlag; use warpui::platform::WindowStyle; @@ -24,7 +25,7 @@ use super::{ TuiOrchestrationBlockAction, TuiOrchestrationBlockEvent, build_request, }; use crate::option_selector::{TuiOptionSelectorAction, TuiOptionSelectorEvent}; -use crate::test_fixtures::TestHostView; +use crate::test_fixtures::{TestHostView, add_test_action_model}; /// Builds a request with the given harness and execution mode. fn request(harness: &str, execution_mode: RunAgentsExecutionMode) -> RunAgentsRequest { @@ -151,6 +152,100 @@ fn edit_state_carries_the_request_auth_secret() { assert_eq!(state.auth_secret_selection, AuthSecretSelection::Unset); } +#[test] +fn window_team_change_reseeds_the_auth_secret_for_the_new_scope() { + App::test((), |mut app| async move { + register_tui_session_view_test_singletons(&mut app); + let (window_id, _) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| TestHostView, + ) + }); + let team_a = 1.into(); + let team_b = 2.into(); + app.update(|ctx| { + set_tui_workspace_teams_for_test( + vec![ + (team_a, "Team A".to_string()), + (team_b, "Team B".to_string()), + ], + ctx, + ); + UserWorkspaces::handle(ctx).update(ctx, |workspaces, ctx| { + workspaces.register_window(window_id, Some(team_a), ctx); + workspaces.switch_window_to_team(window_id, team_b, ctx); + }); + let team_b_scope = warp::tui_export::request_team_scope( + &UserWorkspaces::as_ref(ctx).team_context_for_window(window_id), + ); + set_tui_auth_secret_preference_for_test( + team_b_scope, + Harness::Claude, + "team-b-key".to_string(), + ctx, + ); + UserWorkspaces::handle(ctx).update(ctx, |workspaces, ctx| { + workspaces.switch_window_to_team(window_id, team_a, ctx); + }); + }); + + let mut incoming = request("claude", remote("env-1", "warp")); + incoming.harness_auth_secret_name = Some("team-a-key".to_string()); + let action = AIAgentAction { + id: AIAgentActionId::from("run-agents-team-scope".to_string()), + task_id: TaskId::new("task-1".to_string()), + action: AIAgentActionType::RunAgents(incoming.clone()), + requires_result: true, + }; + let action_model = add_test_action_model(&mut app); + let run_agents_executor = app.read(|ctx| action_model.as_ref(ctx).run_agents_executor(ctx)); + let block = app.update(|ctx| { + ctx.add_typed_action_tui_view(window_id, move |ctx| { + TuiOrchestrationBlock::new( + AIConversationId::new(), + action, + &incoming, + None, + action_model, + run_agents_executor, + Some("auto".to_string()), + false, + ctx, + ) + }) + }); + + assert_eq!( + block.read(&app, |block, _| block.to_request().harness_auth_secret_name), + Some("team-a-key".to_string()) + ); + block.update(&mut app, |block, ctx| { + block.open_page(ConfigPage::ApiKey, ctx); + }); + + app.update(|ctx| { + UserWorkspaces::handle(ctx).update(ctx, |workspaces, ctx| { + workspaces.switch_window_to_team(window_id, team_b, ctx); + }); + }); + + assert_eq!( + block.read(&app, |block, _| block.to_request().harness_auth_secret_name), + Some("team-b-key".to_string()) + ); + assert_eq!( + block.read(&app, |block, _| block.mode), + CardMode::Configuring { + page: ConfigPage::ApiKey + } + ); + }); +} + #[test] fn edit_state_is_overridden_by_an_approved_config() { let incoming = request("oz", RunAgentsExecutionMode::Local); @@ -297,6 +392,7 @@ impl OrchestrationBlockController for TestController { id: &str, edit_state: &mut OrchestrationEditState, _fallback_base_model_id: Option, + _team_scope: RequestTeamScope, _ctx: &mut warpui::AppContext, ) { let state = &mut edit_state.orchestration_config_state; diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 003c97bb954..37ecab52015 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -538,6 +538,7 @@ impl TuiOrchestrationModel { runner_id, agent_identity_uid, } => { + let request_team_scope = request.request_team_scope.clone(); self.register_event_consumer( parent_session_id, request.parent_conversation_id, @@ -559,7 +560,7 @@ impl TuiOrchestrationModel { runner_id, agent_identity_uid, }, - RequestTeamScope::from_scope(team_context), + request_team_scope, ctx, ); } @@ -574,7 +575,8 @@ impl TuiOrchestrationModel { team_scope: RequestTeamScope, ctx: &mut ModelContext, ) { - let prepared = match prepare_remote_child_launch(&request, config, team_scope, ctx) { + let prepared = match prepare_remote_child_launch(&request, config, team_scope.clone(), ctx) + { Ok(prepared) => prepared, Err(error) => { self.fail_child_request(&request, error.user_message(), ctx); @@ -776,7 +778,7 @@ impl TuiOrchestrationModel { team_context: &TeamContextForOperation, ctx: &mut ModelContext, ) { - let request_team_scope = RequestTeamScope::from_scope(team_context); + let request_team_scope = warp::tui_export::request_team_scope(team_context); let team_scope = ResolvedTeamScope::from_scope(team_context); let launch = prepare_local_oz_child_launch( &request.name, diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 38e438a229c..a9ee40c8325 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -45,6 +45,9 @@ fn remote_request(parent_conversation_id: AIConversationId) -> StartAgentRequest lifecycle_subscription: None, parent_conversation_id, parent_run_id: Some("parent-run-1".to_string()), + request_team_scope: warp::tui_export::request_team_scope( + &UserWorkspaces::teamless_context_for_operation_for_test(), + ), } } @@ -221,6 +224,9 @@ fn dispatch_and_recv( None, parent_conversation_id, Some("parent-run-1".to_string()), + warp::tui_export::request_team_scope( + &UserWorkspaces::teamless_context_for_operation_for_test(), + ), ctx, ) }); @@ -305,6 +311,9 @@ fn local_oz_child_session_indexes_run_id_immediately() { lifecycle_subscription: None, parent_conversation_id, parent_run_id: Some("parent-run-1".to_string()), + request_team_scope: warp::tui_export::request_team_scope( + &UserWorkspaces::teamless_context_for_operation_for_test(), + ), }; app.update(|ctx| { let team_scope = ResolvedTeamScope::from_scope( diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index d9c5c2b1814..6f02c17d18f 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -477,7 +477,7 @@ impl TuiSessions { child, (**request).clone(), (**prepared).clone(), - *team_scope, + team_scope.clone(), ctx, ); }); diff --git a/crates/warp_tui/src/voice_input.rs b/crates/warp_tui/src/voice_input.rs index 719e87f602e..26a75c4d7d5 100644 --- a/crates/warp_tui/src/voice_input.rs +++ b/crates/warp_tui/src/voice_input.rs @@ -322,7 +322,7 @@ impl TuiVoiceInputModel { let language = AISettings::as_ref(ctx) .voice_input_language_code() .map(str::to_owned); - let team_scope = RequestTeamScope::from_scope(&(self.team_context_resolver)(ctx)); + let team_scope = warp::tui_export::request_team_scope(&(self.team_context_resolver)(ctx)); VoiceInput::handle(ctx).update(ctx, |voice_input, _| { voice_input.set_transcribing_active(true); });