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

View File

@ -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,

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -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<ImportChatAttachmentItem>,
}
/// 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<String>,
/// Origin kind.
pub source_kind: ChatAttachmentSourceKind,
}
/// Output of [`ImportChatAttachments::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportChatAttachmentsOutput {
/// Imported durable attachments.
pub attachments: Vec<ChatAttachment>,
}
/// Imports user-supplied files into durable, sandbox-readable chat attachment storage.
pub struct ImportChatAttachments {
store: Arc<dyn ChatAttachmentStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
}
impl ImportChatAttachments {
/// Builds the use case.
#[must_use]
pub fn new(
store: Arc<dyn ChatAttachmentStore>,
ids: Arc<dyn IdGenerator>,
clock: Arc<dyn Clock>,
) -> Self {
Self { store, ids, clock }
}
/// Executes chat attachment imports.
pub async fn execute(
&self,
input: ImportChatAttachmentsInput,
) -> Result<ImportChatAttachmentsOutput, AppError> {
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<dyn Clock>) -> u64 {
u64::try_from(clock.now_millis()).unwrap_or(0)
}
fn filename_from_path(path: &str) -> Result<String, AppError> {
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<String, AppError> {
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",
}
}

View File

@ -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<SprintStoreError> for AppError {
}
}
impl From<ChatAttachmentStoreError> 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<MemoryError> for AppError {
fn from(e: MemoryError) -> Self {
match e {

View File

@ -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,

View File

@ -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<Vec<(LocalPath, ChatAttachmentImport)>>,
}
#[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<ChatAttachment, ChatAttachmentStoreError> {
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<Vec<ChatAttachment>, 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<dyn ChatAttachmentStore>,
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<dyn ChatAttachmentStore>,
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());
}

View File

@ -2966,6 +2966,79 @@ impl From<TerminalSession> 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<String>,
/// Origin kind (`localFile`, `clipboard`, `dragDrop`, `other`).
#[serde(default)]
pub source_kind: Option<domain::ChatAttachmentSourceKind>,
}
/// 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<String>,
/// Structured attachment intents.
#[serde(default)]
pub attachments: Vec<ChatAttachmentInputDto>,
}
/// 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<ChatAttachmentDto>,
}
/// 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<domain::ChatAttachment> 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

View File

@ -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<ReadIssueAttachment>,
/// Mark a ticket attachment as summarized in the carnet.
pub mark_issue_attachment_summarized: Arc<MarkIssueAttachmentSummarized>,
/// Import durable agent/chat attachments.
pub import_chat_attachments: Arc<ImportChatAttachments>,
/// Link two public tickets.
pub link_issues: Arc<LinkIssues>,
/// Unlink public tickets.
@ -1696,6 +1700,9 @@ impl BackendCore {
let issue_store_port = Arc::clone(&issue_store) as Arc<dyn IssueStore>;
let issue_allocator = Arc::new(FsIssueNumberAllocator::new());
let issue_allocator_port = Arc::clone(&issue_allocator) as Arc<dyn IssueNumberAllocator>;
let chat_attachment_store = Arc::new(FsChatAttachmentStore::new());
let chat_attachment_store_port =
Arc::clone(&chat_attachment_store) as Arc<dyn ChatAttachmentStore>;
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<dyn Clock>,
Arc::clone(&events_port),
));
let import_chat_attachments = Arc::new(ImportChatAttachments::new(
Arc::clone(&chat_attachment_store_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&clock) as Arc<dyn Clock>,
));
let link_issues = Arc::new(LinkIssues::new(
Arc::clone(&issue_store_port),
Arc::clone(&clock) as Arc<dyn Clock>,
@ -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,

View File

@ -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<String>) -> Result<Self, ChatAttachmentError> {
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),
}

View File

@ -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};

View File

@ -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<u8>,
}
/// 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<ChatAttachment, ChatAttachmentStoreError>;
/// Lists attachments imported for a structured/chat session.
async fn list_for_session(
&self,
root: &ProjectPath,
session_id: SessionId,
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError>;
}
/// Errors from the sprint store.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SprintStoreError {

View File

@ -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)
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,178 @@
//! Filesystem chat attachment store.
//!
//! Project-scoped agent/chat attachments live under
//! `<root>/.ideai/attachments/agent-chat/<session-id>/`.
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<ChatAttachment, ChatAttachmentStoreError> {
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<ChatAttachment, ChatAttachmentStoreError> {
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<Vec<ChatAttachment>, 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)
}
}

View File

@ -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::{

View File

@ -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;
}