feat(attachments): fondation pipeline durable d'attachments agent/chat + sandbox-safe (#154, QA verte)

Socle #154 : entité attachment, store, contrat DTO et routage session. Modules : domain/chat_attachment, application/chat_attachments, infrastructure/chat_attachments, app-tauri (commands/lib), backend dto.
This commit is contained in:
2026-08-06 10:44:15 +02:00
parent fb19ee48dc
commit 1c80d08f92
16 changed files with 1210 additions and 158 deletions

View File

@ -7,7 +7,6 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};
@ -31,7 +30,7 @@ use application::{
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, TICKET_ATTACHMENT_MAX_BYTES,
UpdateProjectSystemPermissionsInput, UpdateSkillInput,
};
use backend::stream::OutputSink;
use domain::ports::ModelServerRuntime;
@ -45,34 +44,36 @@ use crate::dto::{
parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto,
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto,
ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto,
ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto,
SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto,
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
UpdateAgentEffortRequestDto, UpdateAgentMcpToolPermissionsRequestDto,
UpdateAgentPermissionsRequestDto, UpdateAgentSystemPermissionsRequestDto,
UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto,
@ -2159,6 +2160,7 @@ pub async fn agent_send(
session_id: String,
prompt: String,
attachment_paths: Option<Vec<String>>,
attachments: Option<Vec<ChatAttachmentInputDto>>,
on_reply: Channel<ReplyChunk>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
@ -2175,9 +2177,14 @@ pub async fn agent_send(
// pump (if any) is superseded and stops delivering to its stale channel.
let gen = state.chat_bridge.register(sid, on_reply);
let staged_attachments =
stage_chat_attachments(&state, &sid, attachment_paths.unwrap_or_default()).await?;
let prompt_for_model = prompt_with_staged_attachments(&prompt, &staged_attachments);
let imported_attachments = import_chat_attachments_for_session(
&state,
&sid,
attachment_paths.unwrap_or_default(),
attachments.unwrap_or_default(),
)
.await?;
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments);
// Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation.
@ -2262,12 +2269,39 @@ pub async fn agent_send(
Ok(())
}
async fn stage_chat_attachments(
/// `import_chat_attachments` — imports user-supplied files into durable,
/// sandbox-readable storage for a live structured chat session.
///
/// This command exposes the same backend pipeline used by [`agent_send`] without
/// starting a model turn, so frontend code can preflight/import attachments and
/// render the durable metadata before submitting the prompt.
///
/// # Errors
/// Returns an [`ErrorDto`] for malformed ids, missing live session metadata, or
/// invalid/unreadable source files.
#[tauri::command]
pub async fn import_chat_attachments(
request: ImportChatAttachmentsRequestDto,
state: State<'_, AppState>,
) -> Result<ImportChatAttachmentsResponseDto, ErrorDto> {
let sid = parse_session_id(&request.session_id)?;
let attachments = import_chat_attachments_for_session(
&state,
&sid,
request.attachment_paths,
request.attachments,
)
.await?;
Ok(ImportChatAttachmentsResponseDto { attachments })
}
async fn import_chat_attachments_for_session(
state: &AppState,
session_id: &domain::SessionId,
paths: Vec<String>,
) -> Result<Vec<PathBuf>, ErrorDto> {
if paths.is_empty() {
legacy_paths: Vec<String>,
attachments: Vec<ChatAttachmentInputDto>,
) -> Result<Vec<ChatAttachmentDto>, ErrorDto> {
if legacy_paths.is_empty() && attachments.is_empty() {
return Ok(Vec::new());
}
let (project_id, agent_id, _, _) = state
@ -2287,82 +2321,64 @@ async fn stage_chat_attachments(
.find(|project| project.id == project_id)
.ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("project {project_id}"))))?;
let stage_dir = Path::new(project.root.as_str())
.join(".ideai")
.join("run")
.join(agent_id.to_string())
.join("attachments")
.join(session_id.to_string());
if tokio::fs::metadata(&stage_dir).await.is_ok() {
tokio::fs::remove_dir_all(&stage_dir)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
let mut seen_paths = std::collections::HashSet::new();
let mut items = Vec::with_capacity(legacy_paths.len() + attachments.len());
for path in legacy_paths {
if seen_paths.insert(path.clone()) {
items.push(application::ImportChatAttachmentItem {
path,
mime: None,
source_kind: domain::ChatAttachmentSourceKind::LocalFile,
});
}
}
tokio::fs::create_dir_all(&stage_dir)
for attachment in attachments {
if seen_paths.insert(attachment.path.clone()) {
items.push(application::ImportChatAttachmentItem {
path: attachment.path,
mime: attachment.mime,
source_kind: attachment
.source_kind
.unwrap_or(domain::ChatAttachmentSourceKind::LocalFile),
});
}
}
let output = state
.import_chat_attachments
.execute(application::ImportChatAttachmentsInput {
project,
agent_id,
session_id: *session_id,
attachments: items,
})
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
let mut staged = Vec::with_capacity(paths.len());
for (index, raw_path) in paths.iter().enumerate() {
let source = PathBuf::from(raw_path);
let meta = tokio::fs::metadata(&source)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
if !meta.is_file() {
return Err(ErrorDto::from(AppError::Invalid(
"chat attachment source must be a file".to_owned(),
)));
}
if meta.len() > TICKET_ATTACHMENT_MAX_BYTES {
return Err(ErrorDto::from(AppError::Invalid(format!(
"chat attachment exceeds {} bytes",
TICKET_ATTACHMENT_MAX_BYTES
))));
}
let filename = source
.file_name()
.and_then(|name| name.to_str())
.filter(|name| valid_chat_attachment_filename(name))
.ok_or_else(|| {
ErrorDto::from(AppError::Invalid(
"invalid chat attachment filename".to_owned(),
))
})?;
let dest = stage_dir.join(format!("{index}-{filename}"));
tokio::fs::copy(&source, &dest)
.await
.map_err(|err| ErrorDto::from(AppError::FileSystem(err.to_string())))?;
staged.push(dest);
}
Ok(staged)
.map_err(ErrorDto::from)?;
Ok(output
.attachments
.into_iter()
.map(ChatAttachmentDto::from)
.collect())
}
fn valid_chat_attachment_filename(filename: &str) -> bool {
let lowered = filename.to_ascii_lowercase();
let blocked = [
"exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app",
"deb", "rpm",
];
!lowered.trim().is_empty()
&& !lowered.contains('/')
&& !lowered.contains('\\')
&& lowered != "."
&& lowered != ".."
&& !lowered
.rsplit_once('.')
.is_some_and(|(_, ext)| blocked.contains(&ext))
}
fn prompt_with_staged_attachments(prompt: &str, staged: &[PathBuf]) -> String {
if staged.is_empty() {
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String {
if attachments.is_empty() {
return prompt.to_owned();
}
let mut out = String::with_capacity(prompt.len() + staged.len() * 96);
let mut out = String::with_capacity(prompt.len() + attachments.len() * 160);
out.push_str(prompt);
out.push_str("\n\nPièces jointes copiées dans le run dir de cette session :\n");
for path in staged {
out.push_str("- ");
out.push_str(&path.to_string_lossy());
out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n");
for attachment in attachments {
out.push_str("- id: ");
out.push_str(&attachment.id);
out.push_str("; file: ");
out.push_str(&attachment.filename);
out.push_str("; mime: ");
out.push_str(&attachment.mime);
out.push_str("; bytes: ");
out.push_str(&attachment.size_bytes.to_string());
out.push_str("; path: ");
out.push_str(&attachment.readable_path);
out.push('\n');
}
out