From e9d6a34b4386a7e0e408e2c087f4f27a17244bb5 Mon Sep 17 00:00:00 2001 From: Thomas Marchand Date: Sun, 18 Jan 2026 20:11:01 +0000 Subject: [PATCH] fix: pass mission_id to file uploads for mission-specific context directories Files uploaded to ./context/ were going to the workspace context directory instead of the mission-specific context directory at /root/context/{mission_id}/ - Add mission_id parameter to PathQuery, FinalizeUploadRequest, DownloadUrlRequest - Update resolve_path_for_workspace to resolve context paths to mission-specific dirs - Update dashboard uploadFile, uploadFileChunked, finalizeChunkedUpload, downloadFromUrl - Pass mission.id from control-client.tsx when uploading files --- dashboard/src/app/control/control-client.tsx | 12 +++--- dashboard/src/lib/api.ts | 25 ++++++++--- src/api/fs.rs | 44 ++++++++++++++++---- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/dashboard/src/app/control/control-client.tsx b/dashboard/src/app/control/control-client.tsx index ded4460..9ef3ec7 100644 --- a/dashboard/src/app/control/control-client.tsx +++ b/dashboard/src/app/control/control-client.tsx @@ -2243,9 +2243,10 @@ export default function ControlClient() { // Upload into the workspace-local ./context (symlinked to mission context inside the container). const contextPath = "./context/"; - // Get workspace_id from current or viewing mission + // Get workspace_id and mission_id from current or viewing mission const mission = viewingMission ?? currentMission; const workspaceId = mission?.workspace_id; + const missionId = mission?.id; // Use chunked upload for files > 10MB, regular for smaller const useChunked = fileToUpload.size > 10 * 1024 * 1024; @@ -2253,10 +2254,10 @@ export default function ControlClient() { const result = useChunked ? await uploadFileChunked(fileToUpload, contextPath, (progress) => { setUploadProgress({ fileName: displayName, progress }); - }, workspaceId) + }, workspaceId, missionId) : await uploadFile(fileToUpload, contextPath, (progress) => { setUploadProgress({ fileName: displayName, progress }); - }, workspaceId); + }, workspaceId, missionId); toast.success(`Uploaded ${result.name}`); @@ -2282,11 +2283,12 @@ export default function ControlClient() { try { const contextPath = "./context/"; - // Get workspace_id from current or viewing mission + // Get workspace_id and mission_id from current or viewing mission const mission = viewingMission ?? currentMission; const workspaceId = mission?.workspace_id; + const missionId = mission?.id; - const result = await downloadFromUrl(urlInput.trim(), contextPath, undefined, workspaceId); + const result = await downloadFromUrl(urlInput.trim(), contextPath, undefined, workspaceId, missionId); toast.success(`Downloaded ${result.name}`); // Add a message about the download at the beginning (consistent with uploads) diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index c1bd57d..e5bf25c 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -1036,7 +1036,8 @@ export function uploadFile( file: File, remotePath: string = "./context/", onProgress?: (progress: UploadProgress) => void, - workspaceId?: string + workspaceId?: string, + missionId?: string ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -1044,6 +1045,9 @@ export function uploadFile( if (workspaceId) { params.append("workspace_id", workspaceId); } + if (missionId) { + params.append("mission_id", missionId); + } const url = apiUrl(`/api/fs/upload?${params}`); // Track upload progress @@ -1103,7 +1107,8 @@ export async function uploadFileChunked( file: File, remotePath: string = "./context/", onProgress?: (progress: ChunkedUploadProgress) => void, - workspaceId?: string + workspaceId?: string, + missionId?: string ): Promise { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const uploadId = `${file.name}-${file.size}-${Date.now()}`; @@ -1114,7 +1119,7 @@ export async function uploadFileChunked( ...p, chunkIndex: 0, totalChunks: 1, - }) : undefined, workspaceId); + }) : undefined, workspaceId, missionId); } let uploadedBytes = 0; @@ -1152,7 +1157,7 @@ export async function uploadFileChunked( } // Finalize the upload - return finalizeChunkedUpload(remotePath, uploadId, file.name, totalChunks, workspaceId); + return finalizeChunkedUpload(remotePath, uploadId, file.name, totalChunks, workspaceId, missionId); } async function uploadChunk( @@ -1192,7 +1197,8 @@ async function finalizeChunkedUpload( uploadId: string, fileName: string, totalChunks: number, - workspaceId?: string + workspaceId?: string, + missionId?: string ): Promise { const body: Record = { path: remotePath, @@ -1203,6 +1209,9 @@ async function finalizeChunkedUpload( if (workspaceId) { body.workspace_id = workspaceId; } + if (missionId) { + body.mission_id = missionId; + } const res = await apiFetch("/api/fs/upload-finalize", { method: "POST", @@ -1222,7 +1231,8 @@ export async function downloadFromUrl( url: string, remotePath: string = "./context/", fileName?: string, - workspaceId?: string + workspaceId?: string, + missionId?: string ): Promise { const body: Record = { url, @@ -1232,6 +1242,9 @@ export async function downloadFromUrl( if (workspaceId) { body.workspace_id = workspaceId; } + if (missionId) { + body.mission_id = missionId; + } const res = await apiFetch("/api/fs/download-url", { method: "POST", diff --git a/src/api/fs.rs b/src/api/fs.rs index e5daf7c..36ac19e 100644 --- a/src/api/fs.rs +++ b/src/api/fs.rs @@ -162,10 +162,12 @@ fn content_type_for_path(path: &Path) -> &'static str { } /// Resolve a path relative to a specific workspace. +/// If mission_id is provided and path is a context path, resolves to mission-specific context. async fn resolve_path_for_workspace( state: &Arc, workspace_id: uuid::Uuid, path: &str, + mission_id: Option, ) -> Result { let workspace = state .workspaces @@ -191,12 +193,23 @@ async fn resolve_path_for_workspace( let resolved = if input.is_absolute() { input.to_path_buf() } else if path.starts_with("./context") || path.starts_with("context") { - // For "context" paths, use the workspace's context directory + // For "context" paths, use the mission-specific context directory if mission_id provided let suffix = path .trim_start_matches("./") .trim_start_matches("context/") .trim_start_matches("context"); - let context_path = workspace_root.join("context"); + + // If mission_id is provided, use mission-specific context directory + // This ensures uploaded files go to the right place for the agent to find them + let context_path = if let Some(mid) = mission_id { + // Mission context is at /root/context/{mission_id} (or workspace equivalent) + // For host workspaces, the global context root is typically at working_dir/context + let context_root = state.config.working_dir.join("context"); + context_root.join(mid.to_string()) + } else { + workspace_root.join("context") + }; + if suffix.is_empty() { context_path } else { @@ -244,14 +257,18 @@ async fn resolve_path_for_workspace( } }; - // Validate that the resolved path is within the workspace - if !canonical.starts_with(&workspace_root) { + // Validate that the resolved path is within an allowed location + // This can be either the workspace root or the global context directory for missions + let context_root = state.config.working_dir.join("context"); + let in_workspace = canonical.starts_with(&workspace_root); + let in_context = mission_id.is_some() && canonical.starts_with(&context_root); + + if !in_workspace && !in_context { return Err(( StatusCode::FORBIDDEN, format!( - "Path traversal attempt: {} is outside workspace {}", + "Path traversal attempt: {} is outside allowed directories", canonical.display(), - workspace_root.display() ), )); } @@ -421,6 +438,8 @@ pub struct PathQuery { pub path: String, /// Optional workspace ID to resolve relative paths against pub workspace_id: Option, + /// Optional mission ID for mission-specific context directories + pub mission_id: Option, } #[derive(Debug, Deserialize)] @@ -556,8 +575,9 @@ pub async fn upload( mut multipart: Multipart, ) -> Result, (StatusCode, String)> { // If workspace_id is provided, resolve path relative to that workspace + // If mission_id is also provided, context paths resolve to mission-specific directory let base = if let Some(workspace_id) = q.workspace_id { - resolve_path_for_workspace(&state, workspace_id, &q.path).await? + resolve_path_for_workspace(&state, workspace_id, &q.path, q.mission_id).await? } else { resolve_upload_base(&q.path)? }; @@ -709,6 +729,8 @@ pub struct FinalizeUploadRequest { pub total_chunks: u32, /// Optional workspace ID to resolve relative paths against pub workspace_id: Option, + /// Optional mission ID for mission-specific context directories + pub mission_id: Option, } // Finalize chunked upload by assembling chunks @@ -717,8 +739,9 @@ pub async fn upload_finalize( Json(req): Json, ) -> Result, (StatusCode, String)> { // If workspace_id is provided, resolve path relative to that workspace + // If mission_id is also provided, context paths resolve to mission-specific directory let base = if let Some(workspace_id) = req.workspace_id { - resolve_path_for_workspace(&state, workspace_id, &req.path).await? + resolve_path_for_workspace(&state, workspace_id, &req.path, req.mission_id).await? } else { resolve_upload_base(&req.path)? }; @@ -812,6 +835,8 @@ pub struct DownloadUrlRequest { pub file_name: Option, /// Optional workspace ID to resolve relative paths against pub workspace_id: Option, + /// Optional mission ID for mission-specific context directories + pub mission_id: Option, } // Download file from URL to server filesystem @@ -931,8 +956,9 @@ pub async fn download_from_url( drop(f); // Move to destination + // If mission_id is provided, context paths resolve to mission-specific directory let base = if let Some(workspace_id) = req.workspace_id { - resolve_path_for_workspace(&state, workspace_id, &req.path).await? + resolve_path_for_workspace(&state, workspace_id, &req.path, req.mission_id).await? } else { resolve_upload_base(&req.path)? };