fix(permissions): tools MCP inter-projets exposés (idea_project_*) + contactable agent/template persisté — update_agent_contactable (application/web-server/app-tauri), defaultContactable TemplateEditor save/reopen, groupes MCP 'IdeA inter-projets' avec fallback, permissions agent Contactable — #267 (QA verte)

This commit is contained in:
2026-09-07 18:13:27 +02:00
parent 9266d121cd
commit f6101a8249
30 changed files with 632 additions and 89 deletions

View File

@ -522,6 +522,72 @@ pub struct UpdateAgentEffortOutput {
pub agent: Agent,
}
// ---------------------------------------------------------------------------
// UpdateAgentContactable
// ---------------------------------------------------------------------------
/// Input for [`UpdateAgentContactable::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContactableInput {
/// The owning project.
pub project: Project,
/// The agent whose cross-project contactability changes.
pub agent_id: AgentId,
/// Whether agents from other IdeA projects may contact this agent.
pub contactable: bool,
}
/// Output of [`UpdateAgentContactable::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateAgentContactableOutput {
/// The updated agent.
pub agent: Agent,
}
/// Updates an agent's cross-project contactability in the project manifest.
pub struct UpdateAgentContactable {
contexts: Arc<dyn AgentContextStore>,
}
impl UpdateAgentContactable {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>) -> Self {
Self { contexts }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: UpdateAgentContactableInput,
) -> Result<UpdateAgentContactableOutput, AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|entry| entry.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent = entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?
.with_contactable(input.contactable);
*entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
Ok(UpdateAgentContactableOutput { agent })
}
}
/// Sets or clears an agent's per-agent effort override in the manifest.
///
/// The change applies at the agent's next launch; live sessions are not mutated.

View File

@ -37,7 +37,8 @@ pub use lifecycle::{
LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, ListedAgentCapabilities,
LiveStateLeanProvider, ManagedTempExposureRequest, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContext,
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
};

View File

@ -66,8 +66,9 @@ pub use agent::{
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveOpenCodeProviderProfile,
SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile,
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET,
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use background::{

View File

@ -57,8 +57,8 @@ use application::{
DeleteAgent, DeleteAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput,
LiveSessions, ManagedTempExposureRequest, PermissionProjectorRegistry, ReadAgentContext,
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
StructuredSessions, TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
UpdateAgentEffort, UpdateAgentEffortInput,
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
};
// ---------------------------------------------------------------------------
@ -1163,6 +1163,53 @@ async fn update_agent_effort_not_found_for_unknown_agent_id() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn update_agent_contactable_persists_manifest_and_can_be_cleared() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentContactable::new(Arc::new(contexts.clone()));
let enabled = update
.execute(UpdateAgentContactableInput {
project: project(),
agent_id: a.id,
contactable: true,
})
.await
.unwrap();
assert!(enabled.agent.contactable);
assert!(contexts.manifest().entries[0].contactable);
let disabled = update
.execute(UpdateAgentContactableInput {
project: project(),
agent_id: a.id,
contactable: false,
})
.await
.unwrap();
assert!(!disabled.agent.contactable);
assert!(!contexts.manifest().entries[0].contactable);
}
#[tokio::test]
async fn update_agent_contactable_not_found_for_unknown_agent_id() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a, "ctx");
let update = UpdateAgentContactable::new(Arc::new(contexts));
let err = update
.execute(UpdateAgentContactableInput {
project: project(),
agent_id: aid(404),
contactable: true,
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn delete_removes_entry_then_unknown_is_not_found() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));