From 1c80d08f92f4c017566fcc39c86b8eb0cd738847 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 6 Aug 2026 10:44:15 +0200 Subject: [PATCH] feat(attachments): fondation pipeline durable d'attachments agent/chat + sandbox-safe (#154, QA verte) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/app-tauri/src/commands.rs | 228 ++++++++++-------- crates/app-tauri/src/lib.rs | 1 + crates/app-tauri/tests/dto_chat.rs | 85 ++++++- crates/application/src/chat_attachments.rs | 173 +++++++++++++ crates/application/src/error.rs | 15 +- crates/application/src/lib.rs | 5 + crates/application/tests/chat_attachments.rs | 167 +++++++++++++ crates/backend/src/dto.rs | 73 ++++++ crates/backend/src/lib.rs | 107 ++++---- crates/domain/src/chat_attachment.rs | 132 ++++++++++ crates/domain/src/lib.rs | 5 + crates/domain/src/ports.rs | 52 ++++ crates/domain/tests/entities.rs | 40 ++- crates/infrastructure/src/chat_attachments.rs | 178 ++++++++++++++ crates/infrastructure/src/lib.rs | 2 + .../infrastructure/tests/chat_attachments.rs | 105 ++++++++ 16 files changed, 1210 insertions(+), 158 deletions(-) create mode 100644 crates/application/src/chat_attachments.rs create mode 100644 crates/application/tests/chat_attachments.rs create mode 100644 crates/domain/src/chat_attachment.rs create mode 100644 crates/infrastructure/src/chat_attachments.rs create mode 100644 crates/infrastructure/tests/chat_attachments.rs diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 58d172e..0d66f23 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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>, + attachments: Option>, on_reply: Channel, 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 { + 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, -) -> Result, ErrorDto> { - if paths.is_empty() { + legacy_paths: Vec, + attachments: Vec, +) -> Result, 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 diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index edc3b43..2a0d2a2 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -341,6 +341,7 @@ pub fn run() { commands::launch_agent, commands::change_agent_profile, commands::agent_send, + commands::import_chat_attachments, commands::cancel_resume, commands::set_resume_at, commands::interrupt_agent, diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 4b78923..6d09abe 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -6,11 +6,14 @@ //! - non-regression: every `TerminalSessionDto` construction path now serialises a //! `cellKind` and PTY paths keep `"pty"`. -use app_tauri_lib::dto::{CellKind, ReattachChatDto, ReplyChunk, TerminalSessionDto}; +use app_tauri_lib::dto::{ + CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ImportChatAttachmentsRequestDto, + ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, TerminalSessionDto, +}; use application::{LaunchAgentOutput, StructuredSessionDescriptor}; use domain::project::ProjectPath; -use domain::SessionId; use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession}; +use domain::{ChatAttachmentSourceKind, SessionId}; use serde_json::json; use uuid::Uuid; @@ -103,6 +106,84 @@ fn reply_chunk_rejects_snake_case_tag() { assert!(r.is_err(), "snake_case kind must not deserialise"); } +// --------------------------------------------------------------------------- +// Chat attachments — structured input + durable metadata, camelCase +// --------------------------------------------------------------------------- + +#[test] +fn chat_attachment_input_dto_deserialises_camel_case() { + let dto: ChatAttachmentInputDto = serde_json::from_value(json!({ + "path": "/tmp/picked.png", + "mime": "image/png", + "sourceKind": "clipboard" + })) + .unwrap(); + + assert_eq!(dto.path, "/tmp/picked.png"); + assert_eq!(dto.mime.as_deref(), Some("image/png")); + assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard)); +} + +#[test] +fn chat_attachment_dto_serialises_camel_case() { + let dto = ChatAttachmentDto { + id: "attach-1".to_owned(), + filename: "picked.png".to_owned(), + mime: "image/png".to_owned(), + size_bytes: 9, + source_kind: ChatAttachmentSourceKind::LocalFile, + storage_path: "agent-chat/session/attach-1-picked.png".to_owned(), + readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-picked.png" + .to_owned(), + created_at: 42, + }; + + let v = serde_json::to_value(dto).unwrap(); + assert_eq!(v["sizeBytes"], 9); + assert_eq!(v["sourceKind"], "localFile"); + assert_eq!(v["storagePath"], "agent-chat/session/attach-1-picked.png"); + assert!(v.get("readable_path").is_none(), "no snake_case leak"); +} + +#[test] +fn import_chat_attachments_request_response_use_camel_case() { + let request: ImportChatAttachmentsRequestDto = serde_json::from_value(json!({ + "sessionId": "sess-1", + "attachmentPaths": ["/tmp/legacy.txt"], + "attachments": [{ + "path": "/tmp/picked.png", + "mime": "image/png", + "sourceKind": "dragDrop" + }] + })) + .unwrap(); + + assert_eq!(request.session_id, "sess-1"); + assert_eq!(request.attachment_paths, vec!["/tmp/legacy.txt"]); + assert_eq!( + request.attachments[0].source_kind, + Some(ChatAttachmentSourceKind::DragDrop) + ); + + let response = ImportChatAttachmentsResponseDto { + attachments: vec![ChatAttachmentDto { + id: "attach-1".to_owned(), + filename: "picked.png".to_owned(), + mime: "image/png".to_owned(), + size_bytes: 9, + source_kind: ChatAttachmentSourceKind::LocalFile, + storage_path: "agent-chat/session/attach-1-picked.png".to_owned(), + readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-picked.png" + .to_owned(), + created_at: 42, + }], + }; + let v = serde_json::to_value(response).unwrap(); + + assert_eq!(v["attachments"][0]["sizeBytes"], 9); + assert!(v.get("attachment_paths").is_none(), "no snake_case leak"); +} + // --------------------------------------------------------------------------- // ReattachChatDto — typed scrollback, camelCase (zone 5) // --------------------------------------------------------------------------- diff --git a/crates/application/src/chat_attachments.rs b/crates/application/src/chat_attachments.rs new file mode 100644 index 0000000..0c3e78e --- /dev/null +++ b/crates/application/src/chat_attachments.rs @@ -0,0 +1,173 @@ +//! Agent/chat attachment use cases. + +use std::path::Path; +use std::sync::Arc; + +use domain::ports::{ChatAttachmentImport, ChatAttachmentStore}; +use domain::{ + AgentId, ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind, Clock, IdGenerator, + LocalPath, Project, SessionId, +}; + +use crate::error::AppError; + +/// Maximum raw chat attachment size. +pub const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; + +/// Input for [`ImportChatAttachments::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportChatAttachmentsInput { + /// Project owning the chat session. + pub project: Project, + /// Target agent. + pub agent_id: AgentId, + /// Structured/chat session id. + pub session_id: SessionId, + /// Attachments to import. + pub attachments: Vec, +} + +/// One source attachment import request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportChatAttachmentItem { + /// Local source path supplied by the driving adapter. + pub path: String, + /// Optional MIME type supplied by the driving adapter. + pub mime: Option, + /// Origin kind. + pub source_kind: ChatAttachmentSourceKind, +} + +/// Output of [`ImportChatAttachments::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportChatAttachmentsOutput { + /// Imported durable attachments. + pub attachments: Vec, +} + +/// Imports user-supplied files into durable, sandbox-readable chat attachment storage. +pub struct ImportChatAttachments { + store: Arc, + ids: Arc, + clock: Arc, +} + +impl ImportChatAttachments { + /// Builds the use case. + #[must_use] + pub fn new( + store: Arc, + ids: Arc, + clock: Arc, + ) -> Self { + Self { store, ids, clock } + } + + /// Executes chat attachment imports. + pub async fn execute( + &self, + input: ImportChatAttachmentsInput, + ) -> Result { + let mut imported = Vec::with_capacity(input.attachments.len()); + for item in input.attachments { + let filename = filename_from_path(&item.path)?; + validate_attachment_filename(&filename)?; + let mime = sanitize_mime(item.mime.as_deref(), &filename)?; + let id = ChatAttachmentId::new(self.ids.new_uuid().to_string()) + .map_err(|err| AppError::Invalid(err.to_string()))?; + let attachment = self + .store + .import_from_path( + &input.project.root, + input.project.id, + input.agent_id, + input.session_id, + &LocalPath::new(item.path), + ChatAttachmentImport { + id, + filename, + mime, + source_kind: item.source_kind, + created_at: now(&self.clock), + }, + ) + .await?; + imported.push(attachment); + } + Ok(ImportChatAttachmentsOutput { + attachments: imported, + }) + } +} + +fn now(clock: &Arc) -> u64 { + u64::try_from(clock.now_millis()).unwrap_or(0) +} + +fn filename_from_path(path: &str) -> Result { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned())) +} + +fn validate_attachment_filename(filename: &str) -> Result<(), AppError> { + let lowered = filename.to_ascii_lowercase(); + let blocked = [ + "exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app", + "deb", "rpm", + ]; + if lowered.trim().is_empty() + || lowered.contains('/') + || lowered.contains('\\') + || lowered == "." + || lowered == ".." + { + return Err(AppError::Invalid("invalid attachment filename".to_owned())); + } + if lowered + .rsplit_once('.') + .is_some_and(|(_, ext)| blocked.contains(&ext)) + { + return Err(AppError::Invalid( + "executable attachments are not allowed".to_owned(), + )); + } + Ok(()) +} + +fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result { + let mime = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| infer_mime(filename).to_owned()); + if mime.eq_ignore_ascii_case("application/x-msdownload") + || mime.eq_ignore_ascii_case("application/x-sh") + || mime.eq_ignore_ascii_case("application/x-executable") + { + return Err(AppError::Invalid( + "executable attachments are not allowed".to_owned(), + )); + } + Ok(mime) +} + +fn infer_mime(filename: &str) -> &'static str { + match filename + .rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) + .as_deref() + { + Some("txt" | "md" | "log") => "text/plain", + Some("json") => "application/json", + Some("xml") => "application/xml", + Some("jpg" | "jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("pdf") => "application/pdf", + _ => "application/octet-stream", + } +} diff --git a/crates/application/src/error.rs b/crates/application/src/error.rs index 0534ced..72246ca 100644 --- a/crates/application/src/error.rs +++ b/crates/application/src/error.rs @@ -6,8 +6,9 @@ //! with one error shape when building its `ErrorDTO`. use domain::ports::{ - AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError, - ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, StoreError, + AgentSessionError, ChatAttachmentStoreError, EmbedderError, FsError, GitError, MemoryError, + ModelServerError, ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, + StoreError, }; use domain::{AgentId, NodeId}; use domain::{IssueStoreError, SprintStoreError}; @@ -168,6 +169,16 @@ impl From for AppError { } } +impl From for AppError { + fn from(e: ChatAttachmentStoreError) -> Self { + match e { + ChatAttachmentStoreError::NotFound => Self::NotFound("chat attachment".to_owned()), + ChatAttachmentStoreError::Invalid(message) => Self::Invalid(message), + ChatAttachmentStoreError::Store(message) => Self::Store(message), + } + } +} + impl From for AppError { fn from(e: MemoryError) -> Self { match e { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 0f3f09f..7663ef3 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -13,6 +13,7 @@ pub mod agent; pub mod background; +pub mod chat_attachments; pub mod conversation; pub mod device; pub mod diag; @@ -70,6 +71,10 @@ pub use background::{ RetryBackgroundTask, SpawnBackgroundCommand, SpawnBackgroundCommandInput, SpawnBackgroundCommandOutput, }; +pub use chat_attachments::{ + ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput, + ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES, +}; pub use conversation::{ ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn, RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView, diff --git a/crates/application/tests/chat_attachments.rs b/crates/application/tests/chat_attachments.rs new file mode 100644 index 0000000..9c440e6 --- /dev/null +++ b/crates/application/tests/chat_attachments.rs @@ -0,0 +1,167 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use domain::ports::{ + ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, Clock, IdGenerator, +}; +use domain::{ + AgentId, ChatAttachment, ChatAttachmentSourceKind, LocalPath, Project, ProjectId, ProjectPath, + RemoteRef, SessionId, +}; +use uuid::Uuid; + +use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput}; + +#[derive(Default)] +struct FakeChatAttachments { + calls: Mutex>, +} + +#[async_trait] +impl ChatAttachmentStore for FakeChatAttachments { + async fn import_from_path( + &self, + root: &ProjectPath, + project_id: ProjectId, + agent_id: AgentId, + session_id: SessionId, + source: &LocalPath, + import: ChatAttachmentImport, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((source.clone(), import.clone())); + let storage_path = format!( + "agent-chat/{session_id}/{}-{}", + import.id.as_str(), + import.filename + ); + let readable_path = format!( + "{}/.ideai/attachments/agent-chat/{session_id}/{}-{}", + root.as_str(), + import.id.as_str(), + import.filename + ); + Ok(ChatAttachment { + id: import.id, + project_id, + agent_id, + session_id, + filename: import.filename.clone(), + mime: import.mime.clone(), + size_bytes: 12, + source_kind: import.source_kind, + storage_path, + readable_path, + created_at: import.created_at, + }) + } + + async fn list_for_session( + &self, + _root: &ProjectPath, + _session_id: SessionId, + ) -> Result, ChatAttachmentStoreError> { + Ok(Vec::new()) + } +} + +struct FixedClock; + +impl Clock for FixedClock { + fn now_millis(&self) -> i64 { + 1_234 + } +} + +struct FixedIds; + +impl IdGenerator for FixedIds { + fn new_uuid(&self) -> Uuid { + Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap() + } +} + +fn project() -> Project { + Project::new( + ProjectId::from_uuid(Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap()), + "P", + ProjectPath::new("/tmp/idea-chat-attachments-test").unwrap(), + RemoteRef::Local, + 0, + ) + .unwrap() +} + +#[tokio::test] +async fn import_chat_attachments_allocates_metadata_and_uses_store_port() { + let store = Arc::new(FakeChatAttachments::default()); + let usecase = ImportChatAttachments::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock), + ); + let agent_id = + AgentId::from_uuid(Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap()); + let session_id = + SessionId::from_uuid(Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap()); + + let output = usecase + .execute(ImportChatAttachmentsInput { + project: project(), + agent_id, + session_id, + attachments: vec![ImportChatAttachmentItem { + path: "/outside/photo.png".to_owned(), + mime: None, + source_kind: ChatAttachmentSourceKind::Clipboard, + }], + }) + .await + .unwrap(); + + assert_eq!(output.attachments.len(), 1); + let attachment = &output.attachments[0]; + assert_eq!(attachment.filename, "photo.png"); + assert_eq!(attachment.mime, "image/png"); + assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard); + assert_eq!(attachment.created_at, 1_234); + assert!(attachment + .storage_path + .starts_with("agent-chat/cccccccc-cccc-cccc-cccc-cccccccccccc/")); + assert!(attachment + .readable_path + .contains("/.ideai/attachments/agent-chat/")); + + let calls = store.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0.as_str(), "/outside/photo.png"); +} + +#[tokio::test] +async fn import_chat_attachments_rejects_executable_sources_before_store_call() { + let store = Arc::new(FakeChatAttachments::default()); + let usecase = ImportChatAttachments::new( + Arc::clone(&store) as Arc, + Arc::new(FixedIds), + Arc::new(FixedClock), + ); + + let err = usecase + .execute(ImportChatAttachmentsInput { + project: project(), + agent_id: AgentId::new_random(), + session_id: SessionId::new_random(), + attachments: vec![ImportChatAttachmentItem { + path: "/outside/run.sh".to_owned(), + mime: None, + source_kind: ChatAttachmentSourceKind::LocalFile, + }], + }) + .await + .unwrap_err(); + + assert_eq!(err.code(), "INVALID"); + assert!(store.calls.lock().unwrap().is_empty()); +} diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 227e205..9b2b07c 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2966,6 +2966,79 @@ impl From for TerminalSessionDto { // Structured chat sessions (§17 — D4) // --------------------------------------------------------------------------- +/// Structured attachment intent sent by the chat frontend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatAttachmentInputDto { + /// Local source path selected or staged by the driving adapter. + pub path: String, + /// Optional MIME type known by the frontend/OS picker. + #[serde(default)] + pub mime: Option, + /// Origin kind (`localFile`, `clipboard`, `dragDrop`, `other`). + #[serde(default)] + pub source_kind: Option, +} + +/// Request for importing durable chat attachments before or during a structured turn. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportChatAttachmentsRequestDto { + /// Live structured/chat session id. + pub session_id: String, + /// Legacy local source paths, kept for transition compatibility. + #[serde(default)] + pub attachment_paths: Vec, + /// Structured attachment intents. + #[serde(default)] + pub attachments: Vec, +} + +/// Response returned after durable chat attachment import. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportChatAttachmentsResponseDto { + /// Imported durable attachments. + pub attachments: Vec, +} + +/// Durable chat attachment metadata returned by the backend/application layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatAttachmentDto { + /// Stable attachment id. + pub id: String, + /// Original display filename. + pub filename: String, + /// MIME type. + pub mime: String, + /// Size in bytes. + pub size_bytes: u64, + /// Origin kind. + pub source_kind: domain::ChatAttachmentSourceKind, + /// Stored relative path under `.ideai/attachments`. + pub storage_path: String, + /// Absolute managed path readable by the target agent sandbox. + pub readable_path: String, + /// Creation time, epoch milliseconds. + pub created_at: u64, +} + +impl From for ChatAttachmentDto { + fn from(value: domain::ChatAttachment) -> Self { + Self { + id: value.id.as_str().to_owned(), + filename: value.filename, + mime: value.mime, + size_bytes: value.size_bytes, + source_kind: value.source_kind, + storage_path: value.storage_path, + readable_path: value.readable_path, + created_at: value.created_at, + } + } +} + /// One incremental chunk of a structured agent reply, streamed over the chat /// session's adapter-owned channel. The serialised wire twin of a /// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index f66337a..1a47a2a 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -26,45 +26,46 @@ use application::{ DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, - GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, - InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels, - ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, - ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, - MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, - MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal, - OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, - PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments, - PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics, - PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, - ReadMemory, ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, - ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, - RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, - ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, - ResolveAgentSystemPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, - SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, - SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, - SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, - StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, - UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, - UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, - UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, - UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, + InspectConversation, InstallPluginFromArchive, InstallPluginFromDirectory, + JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, + ListAgentsInput, ListClaudeModels, ListCodexModels, ListDevices, ListEmbedderProfiles, + ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, + ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, + ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, + LiveStateProvider, LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, + McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, + OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService, + PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks, + PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess, + PluginToolchainDiagnostics, PluginWorkspaceAccess, ProposeContext, QueryProjectStructure, + ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueAttachment, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, + ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, + ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, + RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, + SaveOpenCodeProviderProfile, SaveProfile, SessionLimitService, SetActiveLayout, + SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentEffort, + UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, + UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, + UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, + UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore, AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner, - BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector, - EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, FileSystem, GitPort, - IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, MemoryRecall, - MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator, + BackgroundTaskStore, ChatAttachmentStore, Clock, DeviceSessionStore, Embedder, + EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus, + FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, McpToolPermissionStore, + MemoryRecall, MemoryStore, ModelArtifactDownloader, PermissionStore, PluginManifestValidator, PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore, ProjectStore, PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore, @@ -89,19 +90,20 @@ use infrastructure::{ BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor, - FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore, - FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, - FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, - FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, - FsPluginStorageStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, - FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, - Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, - HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, - LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, - MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, - ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, - SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, + FsAssistantContextStore, FsBackgroundTaskStore, FsChatAttachmentStore, FsConversationLog, + FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, + FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, + FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, + FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, + FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, + FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository, + HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, + HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, + InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, + LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, + OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader, + ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock, + SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, @@ -1014,6 +1016,8 @@ pub struct BackendCore { pub read_issue_attachment: Arc, /// Mark a ticket attachment as summarized in the carnet. pub mark_issue_attachment_summarized: Arc, + /// Import durable agent/chat attachments. + pub import_chat_attachments: Arc, /// Link two public tickets. pub link_issues: Arc, /// Unlink public tickets. @@ -1696,6 +1700,9 @@ impl BackendCore { let issue_store_port = Arc::clone(&issue_store) as Arc; let issue_allocator = Arc::new(FsIssueNumberAllocator::new()); let issue_allocator_port = Arc::clone(&issue_allocator) as Arc; + let chat_attachment_store = Arc::new(FsChatAttachmentStore::new()); + let chat_attachment_store_port = + Arc::clone(&chat_attachment_store) as Arc; let create_issue = Arc::new(CreateIssue::new( Arc::clone(&issue_store_port), Arc::clone(&issue_allocator_port), @@ -1749,6 +1756,11 @@ impl BackendCore { Arc::clone(&clock) as Arc, Arc::clone(&events_port), )); + let import_chat_attachments = Arc::new(ImportChatAttachments::new( + Arc::clone(&chat_attachment_store_port), + Arc::clone(&ids) as Arc, + Arc::clone(&clock) as Arc, + )); let link_issues = Arc::new(LinkIssues::new( Arc::clone(&issue_store_port), Arc::clone(&clock) as Arc, @@ -2978,6 +2990,7 @@ impl BackendCore { add_issue_attachment, read_issue_attachment, mark_issue_attachment_summarized, + import_chat_attachments, link_issues, unlink_issues, assign_issue_agent, diff --git a/crates/domain/src/chat_attachment.rs b/crates/domain/src/chat_attachment.rs new file mode 100644 index 0000000..7e0b331 --- /dev/null +++ b/crates/domain/src/chat_attachment.rs @@ -0,0 +1,132 @@ +//! Agent/chat attachment domain model. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::ids::{AgentId, ProjectId, SessionId}; + +/// Stable chat attachment identifier. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ChatAttachmentId(String); + +impl ChatAttachmentId { + /// Builds an attachment id. + /// + /// # Errors + /// [`ChatAttachmentError::InvalidId`] when the id is empty or not filename-safe. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(ChatAttachmentError::InvalidId(value)); + } + Ok(Self(value)) + } + + /// Returns the raw id. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Where the attachment originally came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChatAttachmentSourceKind { + /// User-selected local filesystem path. + LocalFile, + /// Clipboard paste, materialized by a driving adapter. + Clipboard, + /// Drag-and-drop, materialized by a driving adapter. + DragDrop, + /// Unknown or future source. + Other, +} + +/// Durable metadata for one agent/chat attachment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatAttachment { + /// Stable attachment id. + pub id: ChatAttachmentId, + /// Project that owns the managed attachment. + pub project_id: ProjectId, + /// Target agent for the chat flow. + pub agent_id: AgentId, + /// Structured/chat session that imported the file. + pub session_id: SessionId, + /// Original display filename. + pub filename: String, + /// MIME type. + pub mime: String, + /// Size in bytes. + pub size_bytes: u64, + /// Origin kind. + pub source_kind: ChatAttachmentSourceKind, + /// Stored relative path under `.ideai/attachments`. + pub storage_path: String, + /// Absolute managed path readable by the target agent sandbox. + pub readable_path: String, + /// Creation time, epoch milliseconds. + pub created_at: u64, +} + +impl ChatAttachment { + /// Validates persisted metadata. + /// + /// # Errors + /// [`ChatAttachmentError`] when an invariant is violated. + pub fn validate(&self) -> Result<(), ChatAttachmentError> { + if self.filename.trim().is_empty() + || self.filename.contains('/') + || self.filename.contains('\\') + || self.filename == "." + || self.filename == ".." + { + return Err(ChatAttachmentError::InvalidFilename(self.filename.clone())); + } + if self.mime.trim().is_empty() { + return Err(ChatAttachmentError::InvalidMime(self.mime.clone())); + } + if self.storage_path.starts_with('/') + || self.storage_path.starts_with('\\') + || self.storage_path.contains("..") + || !self.storage_path.starts_with("agent-chat/") + { + return Err(ChatAttachmentError::InvalidStoragePath( + self.storage_path.clone(), + )); + } + if self.readable_path.trim().is_empty() { + return Err(ChatAttachmentError::InvalidReadablePath( + self.readable_path.clone(), + )); + } + Ok(()) + } +} + +/// Attachment validation errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ChatAttachmentError { + /// Invalid id. + #[error("invalid chat attachment id: {0}")] + InvalidId(String), + /// Invalid filename. + #[error("invalid chat attachment filename: {0}")] + InvalidFilename(String), + /// Invalid MIME. + #[error("invalid chat attachment mime: {0}")] + InvalidMime(String), + /// Invalid relative storage path. + #[error("invalid chat attachment storage path: {0}")] + InvalidStoragePath(String), + /// Invalid readable path. + #[error("invalid chat attachment readable path: {0}")] + InvalidReadablePath(String), +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 490add4..95f4dbf 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -33,6 +33,7 @@ pub mod agent; pub mod agent_tool_policy; pub mod background_task; +pub mod chat_attachment; pub mod conversation; pub mod conversation_log; pub mod device; @@ -101,6 +102,10 @@ pub use background_task::{ BACKGROUND_TASK_TEXT_MAX_BYTES, }; +pub use chat_attachment::{ + ChatAttachment, ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind, +}; + pub use skill::{Skill, SkillKind, SkillRef, SkillScope}; pub use template::{AgentTemplate, TemplateVersion}; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 231b577..92a891c 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy; use crate::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, }; +use crate::chat_attachment::{ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind}; use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice}; use crate::events::DomainEvent; use crate::ids::{ @@ -1066,6 +1067,57 @@ pub struct IssueAttachmentContent { pub bytes: Vec, } +/// Metadata supplied by a driving adapter when importing a chat attachment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatAttachmentImport { + /// Stable id allocated by the application layer. + pub id: ChatAttachmentId, + /// Original display filename. + pub filename: String, + /// MIME type. + pub mime: String, + /// Origin kind. + pub source_kind: ChatAttachmentSourceKind, + /// Creation time, epoch milliseconds. + pub created_at: u64, +} + +/// Chat attachment store errors. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ChatAttachmentStoreError { + /// The requested attachment does not exist. + #[error("chat attachment not found")] + NotFound, + /// The attachment payload is invalid. + #[error("chat attachment invalid: {0}")] + Invalid(String), + /// Store I/O or serialization failed. + #[error("chat attachment store failed: {0}")] + Store(String), +} + +/// Persistence port for project-scoped agent/chat attachments. +#[async_trait] +pub trait ChatAttachmentStore: Send + Sync { + /// Copies a local source file into IdeA-managed durable attachment storage. + async fn import_from_path( + &self, + root: &ProjectPath, + project_id: ProjectId, + agent_id: AgentId, + session_id: SessionId, + source: &LocalPath, + import: ChatAttachmentImport, + ) -> Result; + + /// Lists attachments imported for a structured/chat session. + async fn list_for_session( + &self, + root: &ProjectPath, + session_id: SessionId, + ) -> Result, ChatAttachmentStoreError>; +} + /// Errors from the sprint store. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum SprintStoreError { diff --git a/crates/domain/tests/entities.rs b/crates/domain/tests/entities.rs index 182dc5c..3caf774 100644 --- a/crates/domain/tests/entities.rs +++ b/crates/domain/tests/entities.rs @@ -4,7 +4,8 @@ mod helpers; use domain::{ - Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError, + Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ChatAttachment, + ChatAttachmentError, ChatAttachmentId, ChatAttachmentSourceKind, ContextInjection, DomainError, ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SessionStrategy, Skill, SkillId, SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion, }; @@ -19,6 +20,23 @@ fn template_id() -> TemplateId { TemplateId::from_uuid(Uuid::from_u128(7)) } +fn chat_attachment() -> ChatAttachment { + ChatAttachment { + id: ChatAttachmentId::new("attach-1").unwrap(), + project_id: domain::ProjectId::from_uuid(Uuid::from_u128(1)), + agent_id: domain::AgentId::from_uuid(Uuid::from_u128(2)), + session_id: domain::SessionId::from_uuid(Uuid::from_u128(3)), + filename: "note.txt".to_owned(), + mime: "text/plain".to_owned(), + size_bytes: 12, + source_kind: ChatAttachmentSourceKind::LocalFile, + storage_path: "agent-chat/session/attach-1-note.txt".to_owned(), + readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-note.txt" + .to_owned(), + created_at: 123, + } +} + // --------------------------------------------------------------------------- // ProjectPath // --------------------------------------------------------------------------- @@ -52,6 +70,26 @@ fn project_path_rejects_empty() { assert!(matches!(err, DomainError::EmptyField { .. })); } +// --------------------------------------------------------------------------- +// Chat attachments +// --------------------------------------------------------------------------- + +#[test] +fn chat_attachment_metadata_accepts_agent_chat_storage_path() { + assert!(chat_attachment().validate().is_ok()); +} + +#[test] +fn chat_attachment_metadata_rejects_storage_path_outside_agent_chat_namespace() { + let mut attachment = chat_attachment(); + attachment.storage_path = "tickets/1/file.txt".to_owned(); + + assert!(matches!( + attachment.validate(), + Err(ChatAttachmentError::InvalidStoragePath(_)) + )); +} + // --------------------------------------------------------------------------- // Project (also exercises the Clock/IdGenerator port fakes for determinism) // --------------------------------------------------------------------------- diff --git a/crates/infrastructure/src/chat_attachments.rs b/crates/infrastructure/src/chat_attachments.rs new file mode 100644 index 0000000..1774e90 --- /dev/null +++ b/crates/infrastructure/src/chat_attachments.rs @@ -0,0 +1,178 @@ +//! Filesystem chat attachment store. +//! +//! Project-scoped agent/chat attachments live under +//! `/.ideai/attachments/agent-chat//`. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; + +use domain::ports::{ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError}; +use domain::{AgentId, ChatAttachment, LocalPath, ProjectId, ProjectPath, SessionId}; + +const IDEAI_DIR: &str = ".ideai"; +const ATTACHMENTS_DIR: &str = "attachments"; +const AGENT_CHAT_DIR: &str = "agent-chat"; +const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; + +/// Filesystem-backed chat attachment store. +#[derive(Debug, Clone, Default)] +pub struct FsChatAttachmentStore; + +impl FsChatAttachmentStore { + /// Builds a store. + #[must_use] + pub const fn new() -> Self { + Self + } +} + +fn attachment_root(root: &ProjectPath) -> PathBuf { + PathBuf::from(root.as_str()) + .join(IDEAI_DIR) + .join(ATTACHMENTS_DIR) +} + +fn session_dir(root: &ProjectPath, session_id: SessionId) -> PathBuf { + attachment_root(root) + .join(AGENT_CHAT_DIR) + .join(session_id.to_string()) +} + +fn metadata_path(root: &ProjectPath, session_id: SessionId, id: &str) -> PathBuf { + session_dir(root, session_id).join(format!("{id}.json")) +} + +fn storage_relative(session_id: SessionId, stored_name: &str) -> String { + format!("{AGENT_CHAT_DIR}/{session_id}/{stored_name}") +} + +fn readable_path(root: &ProjectPath, relative: &str) -> String { + attachment_root(root) + .join(relative) + .to_string_lossy() + .into_owned() +} + +fn io_error(err: std::io::Error) -> ChatAttachmentStoreError { + ChatAttachmentStoreError::Store(err.to_string()) +} + +fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> { + if filename.trim().is_empty() + || filename.contains('/') + || filename.contains('\\') + || filename == "." + || filename == ".." + { + return Err(ChatAttachmentStoreError::Invalid( + "invalid chat attachment filename".to_owned(), + )); + } + Ok(()) +} + +async fn write_metadata( + root: &ProjectPath, + session_id: SessionId, + attachment: &ChatAttachment, +) -> Result<(), ChatAttachmentStoreError> { + let path = metadata_path(root, session_id, attachment.id.as_str()); + let bytes = serde_json::to_vec_pretty(attachment) + .map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?; + tokio::fs::write(path, bytes).await.map_err(io_error) +} + +async fn read_metadata(path: &Path) -> Result { + let bytes = tokio::fs::read(path).await.map_err(|err| { + if err.kind() == std::io::ErrorKind::NotFound { + ChatAttachmentStoreError::NotFound + } else { + io_error(err) + } + })?; + let attachment: ChatAttachment = serde_json::from_slice(&bytes) + .map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?; + attachment + .validate() + .map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?; + Ok(attachment) +} + +#[async_trait] +impl ChatAttachmentStore for FsChatAttachmentStore { + async fn import_from_path( + &self, + root: &ProjectPath, + project_id: ProjectId, + agent_id: AgentId, + session_id: SessionId, + source: &LocalPath, + import: ChatAttachmentImport, + ) -> Result { + validate_filename(&import.filename)?; + let source_path = PathBuf::from(source.as_str()); + let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?; + if !meta.is_file() { + return Err(ChatAttachmentStoreError::Invalid( + "chat attachment source must be a file".to_owned(), + )); + } + if meta.len() > CHAT_ATTACHMENT_MAX_BYTES { + return Err(ChatAttachmentStoreError::Invalid(format!( + "chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes" + ))); + } + + let dir = session_dir(root, session_id); + tokio::fs::create_dir_all(&dir).await.map_err(io_error)?; + let stored_name = format!("{}-{}", import.id.as_str(), import.filename); + let dest = dir.join(&stored_name); + tokio::fs::copy(&source_path, &dest) + .await + .map_err(io_error)?; + + let storage_path = storage_relative(session_id, &stored_name); + let attachment = ChatAttachment { + id: import.id, + project_id, + agent_id, + session_id, + filename: import.filename, + mime: import.mime, + size_bytes: meta.len(), + source_kind: import.source_kind, + readable_path: readable_path(root, &storage_path), + storage_path, + created_at: import.created_at, + }; + attachment + .validate() + .map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?; + write_metadata(root, session_id, &attachment).await?; + Ok(attachment) + } + + async fn list_for_session( + &self, + root: &ProjectPath, + session_id: SessionId, + ) -> Result, ChatAttachmentStoreError> { + let dir = session_dir(root, session_id); + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(io_error(err)), + }; + let mut attachments = Vec::new(); + while let Some(entry) = entries.next_entry().await.map_err(io_error)? { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + attachments.push(read_metadata(&path).await?); + } + attachments.sort_by_key(|attachment| attachment.created_at); + Ok(attachments) + } +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 1ecb3d9..e9528b5 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -14,6 +14,7 @@ pub mod assistant; pub mod background_task; +pub mod chat_attachments; pub mod clock; pub mod conversation; pub mod conversation_log; @@ -53,6 +54,7 @@ pub use background_task::{ BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver, BoundedTail, CommandBackgroundRunner, }; +pub use chat_attachments::FsChatAttachmentStore; pub use clock::SystemClock; pub use conversation::InMemoryConversationRegistry; pub use conversation_log::{ diff --git a/crates/infrastructure/tests/chat_attachments.rs b/crates/infrastructure/tests/chat_attachments.rs new file mode 100644 index 0000000..f534c61 --- /dev/null +++ b/crates/infrastructure/tests/chat_attachments.rs @@ -0,0 +1,105 @@ +use domain::ports::{ + ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, IdGenerator, +}; +use domain::{ + AgentId, ChatAttachmentId, ChatAttachmentSourceKind, LocalPath, ProjectId, ProjectPath, + SessionId, +}; +use infrastructure::{FsChatAttachmentStore, UuidGenerator}; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "idea-chat-attachment-{tag}-{}", + UuidGenerator::new().new_uuid() + )) +} + +#[tokio::test] +async fn fs_chat_attachment_store_imports_outside_file_to_project_managed_path() { + let project_root = temp_dir("project"); + let outside_root = temp_dir("outside"); + tokio::fs::create_dir_all(&project_root).await.unwrap(); + tokio::fs::create_dir_all(&outside_root).await.unwrap(); + let source = outside_root.join("picked.png"); + tokio::fs::write(&source, b"png bytes").await.unwrap(); + + let store = FsChatAttachmentStore::new(); + let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(); + let project_id = ProjectId::new_random(); + let agent_id = AgentId::new_random(); + let session_id = SessionId::new_random(); + let attachment_id = ChatAttachmentId::new("attach-1").unwrap(); + + let attachment = store + .import_from_path( + &project_path, + project_id, + agent_id, + session_id, + &LocalPath::new(source.to_string_lossy().into_owned()), + ChatAttachmentImport { + id: attachment_id, + filename: "picked.png".to_owned(), + mime: "image/png".to_owned(), + source_kind: ChatAttachmentSourceKind::LocalFile, + created_at: 42, + }, + ) + .await + .unwrap(); + + assert_eq!(attachment.project_id, project_id); + assert_eq!(attachment.agent_id, agent_id); + assert_eq!(attachment.session_id, session_id); + assert_eq!(attachment.filename, "picked.png"); + assert_eq!(attachment.size_bytes, 9); + assert!(attachment + .storage_path + .starts_with(&format!("agent-chat/{session_id}/"))); + assert!(attachment + .readable_path + .starts_with(&format!("{}/.ideai/attachments/", project_root.display()))); + let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap(); + assert_eq!(stored_bytes, b"png bytes"); + + let listed = store + .list_for_session(&project_path, session_id) + .await + .unwrap(); + assert_eq!(listed, vec![attachment]); + + let _ = tokio::fs::remove_dir_all(project_root).await; + let _ = tokio::fs::remove_dir_all(outside_root).await; +} + +#[tokio::test] +async fn fs_chat_attachment_store_rejects_directories() { + let project_root = temp_dir("project-dir"); + let outside_root = temp_dir("outside-dir"); + tokio::fs::create_dir_all(&project_root).await.unwrap(); + tokio::fs::create_dir_all(&outside_root).await.unwrap(); + + let store = FsChatAttachmentStore::new(); + let err = store + .import_from_path( + &ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(), + ProjectId::new_random(), + AgentId::new_random(), + SessionId::new_random(), + &LocalPath::new(outside_root.to_string_lossy().into_owned()), + ChatAttachmentImport { + id: ChatAttachmentId::new("attach-2").unwrap(), + filename: "outside-dir".to_owned(), + mime: "application/octet-stream".to_owned(), + source_kind: ChatAttachmentSourceKind::LocalFile, + created_at: 42, + }, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ChatAttachmentStoreError::Invalid(_))); + + let _ = tokio::fs::remove_dir_all(project_root).await; + let _ = tokio::fs::remove_dir_all(outside_root).await; +}