Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions app/src/ai/agent/api/convert_from.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
359 changes: 209 additions & 150 deletions app/src/ai/agent_sdk/secret.rs

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions app/src/ai/blocklist/action_model/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,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));
Expand Down
47 changes: 37 additions & 10 deletions app/src/ai/blocklist/action_model/execute/run_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +72,7 @@ pub struct RunAgentsExecutor {
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
start_agent_executor: ModelHandle<StartAgentExecutor>,
terminal_view_id: EntityId,
team_context_resolver: TeamContextResolver,
}

/// Lifecycle events for in-flight dispatches.
Expand All @@ -91,15 +94,21 @@ impl RunAgentsExecutor {
pub fn new(
start_agent_executor: ModelHandle<StartAgentExecutor>,
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<Self>) -> RequestTeamScope {
RequestTeamScope::from_scope(&(self.team_context_resolver)(ctx))
}

pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool {
self.pending.contains_key(action_id)
}
Expand Down Expand Up @@ -166,6 +175,7 @@ impl RunAgentsExecutor {
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
team_scope: RequestTeamScope,
ctx: &mut ModelContext<Self>,
) -> async_channel::Receiver<RunAgentsResult> {
let (sender, receiver) = async_channel::bounded(1);
Expand Down Expand Up @@ -210,6 +220,7 @@ impl RunAgentsExecutor {
action_id_for_wait.clone(),
request,
parent_conversation_id,
team_scope,
sender,
ctx,
)
Expand All @@ -224,6 +235,7 @@ impl RunAgentsExecutor {
action_id: AIAgentActionId,
request: RunAgentsRequest,
parent_conversation_id: AIConversationId,
team_scope: RequestTeamScope,
sender: async_channel::Sender<RunAgentsResult>,
ctx: &mut ModelContext<Self>,
) {
Expand Down Expand Up @@ -277,6 +289,7 @@ impl RunAgentsExecutor {
None, /* lifecycle_subscription */
parent_conversation_id,
parent_run_id.clone(),
team_scope,
exec_ctx,
)
});
Expand Down Expand Up @@ -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,
ctx,
) {
let result = RunAgentsResult::Denied { reason };
Expand All @@ -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 {
Expand Down Expand Up @@ -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, 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(
Expand All @@ -485,12 +510,13 @@ enum ChildSlot {
fn approved_orchestration_config_can_autoexecute(
request: &RunAgentsRequest,
parent_conversation_id: AIConversationId,
team_scope: RequestTeamScope,
ctx: &ModelContext<RunAgentsExecutor>,
) -> 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(
Expand All @@ -517,10 +543,11 @@ fn prepare_request_for_execution(
parent_conversation_id: AIConversationId,
terminal_view_id: EntityId,
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
team_scope: RequestTeamScope,
ctx: &ModelContext<RunAgentsExecutor>,
) -> Option<String> {
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, ctx);
if let Some(reason) =
duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx)
{
Expand Down Expand Up @@ -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(),
Expand Down
159 changes: 154 additions & 5 deletions app/src/ai/blocklist/action_model/execute/run_agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
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,
RequestTeamScope::from_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 {
RequestTeamScope::from_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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
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
Expand Down Expand Up @@ -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,
RequestTeamScope::from_scope(&TeamlessScopeForTest),
ctx,
);
});

assert_eq!(
Expand Down
Loading
Loading