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
This commit is contained in:
Thomas Marchand
2026-01-18 20:11:01 +00:00
parent e2858c9a17
commit e9d6a34b43
3 changed files with 61 additions and 20 deletions

View File

@@ -2243,9 +2243,10 @@ export default function ControlClient() {
// Upload into the workspace-local ./context (symlinked to mission context inside the container). // Upload into the workspace-local ./context (symlinked to mission context inside the container).
const contextPath = "./context/"; 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 mission = viewingMission ?? currentMission;
const workspaceId = mission?.workspace_id; const workspaceId = mission?.workspace_id;
const missionId = mission?.id;
// Use chunked upload for files > 10MB, regular for smaller // Use chunked upload for files > 10MB, regular for smaller
const useChunked = fileToUpload.size > 10 * 1024 * 1024; const useChunked = fileToUpload.size > 10 * 1024 * 1024;
@@ -2253,10 +2254,10 @@ export default function ControlClient() {
const result = useChunked const result = useChunked
? await uploadFileChunked(fileToUpload, contextPath, (progress) => { ? await uploadFileChunked(fileToUpload, contextPath, (progress) => {
setUploadProgress({ fileName: displayName, progress }); setUploadProgress({ fileName: displayName, progress });
}, workspaceId) }, workspaceId, missionId)
: await uploadFile(fileToUpload, contextPath, (progress) => { : await uploadFile(fileToUpload, contextPath, (progress) => {
setUploadProgress({ fileName: displayName, progress }); setUploadProgress({ fileName: displayName, progress });
}, workspaceId); }, workspaceId, missionId);
toast.success(`Uploaded ${result.name}`); toast.success(`Uploaded ${result.name}`);
@@ -2282,11 +2283,12 @@ export default function ControlClient() {
try { try {
const contextPath = "./context/"; 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 mission = viewingMission ?? currentMission;
const workspaceId = mission?.workspace_id; 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}`); toast.success(`Downloaded ${result.name}`);
// Add a message about the download at the beginning (consistent with uploads) // Add a message about the download at the beginning (consistent with uploads)

View File

@@ -1036,7 +1036,8 @@ export function uploadFile(
file: File, file: File,
remotePath: string = "./context/", remotePath: string = "./context/",
onProgress?: (progress: UploadProgress) => void, onProgress?: (progress: UploadProgress) => void,
workspaceId?: string workspaceId?: string,
missionId?: string
): Promise<UploadResult> { ): Promise<UploadResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -1044,6 +1045,9 @@ export function uploadFile(
if (workspaceId) { if (workspaceId) {
params.append("workspace_id", workspaceId); params.append("workspace_id", workspaceId);
} }
if (missionId) {
params.append("mission_id", missionId);
}
const url = apiUrl(`/api/fs/upload?${params}`); const url = apiUrl(`/api/fs/upload?${params}`);
// Track upload progress // Track upload progress
@@ -1103,7 +1107,8 @@ export async function uploadFileChunked(
file: File, file: File,
remotePath: string = "./context/", remotePath: string = "./context/",
onProgress?: (progress: ChunkedUploadProgress) => void, onProgress?: (progress: ChunkedUploadProgress) => void,
workspaceId?: string workspaceId?: string,
missionId?: string
): Promise<UploadResult> { ): Promise<UploadResult> {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = `${file.name}-${file.size}-${Date.now()}`; const uploadId = `${file.name}-${file.size}-${Date.now()}`;
@@ -1114,7 +1119,7 @@ export async function uploadFileChunked(
...p, ...p,
chunkIndex: 0, chunkIndex: 0,
totalChunks: 1, totalChunks: 1,
}) : undefined, workspaceId); }) : undefined, workspaceId, missionId);
} }
let uploadedBytes = 0; let uploadedBytes = 0;
@@ -1152,7 +1157,7 @@ export async function uploadFileChunked(
} }
// Finalize the upload // Finalize the upload
return finalizeChunkedUpload(remotePath, uploadId, file.name, totalChunks, workspaceId); return finalizeChunkedUpload(remotePath, uploadId, file.name, totalChunks, workspaceId, missionId);
} }
async function uploadChunk( async function uploadChunk(
@@ -1192,7 +1197,8 @@ async function finalizeChunkedUpload(
uploadId: string, uploadId: string,
fileName: string, fileName: string,
totalChunks: number, totalChunks: number,
workspaceId?: string workspaceId?: string,
missionId?: string
): Promise<UploadResult> { ): Promise<UploadResult> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
path: remotePath, path: remotePath,
@@ -1203,6 +1209,9 @@ async function finalizeChunkedUpload(
if (workspaceId) { if (workspaceId) {
body.workspace_id = workspaceId; body.workspace_id = workspaceId;
} }
if (missionId) {
body.mission_id = missionId;
}
const res = await apiFetch("/api/fs/upload-finalize", { const res = await apiFetch("/api/fs/upload-finalize", {
method: "POST", method: "POST",
@@ -1222,7 +1231,8 @@ export async function downloadFromUrl(
url: string, url: string,
remotePath: string = "./context/", remotePath: string = "./context/",
fileName?: string, fileName?: string,
workspaceId?: string workspaceId?: string,
missionId?: string
): Promise<UploadResult> { ): Promise<UploadResult> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
url, url,
@@ -1232,6 +1242,9 @@ export async function downloadFromUrl(
if (workspaceId) { if (workspaceId) {
body.workspace_id = workspaceId; body.workspace_id = workspaceId;
} }
if (missionId) {
body.mission_id = missionId;
}
const res = await apiFetch("/api/fs/download-url", { const res = await apiFetch("/api/fs/download-url", {
method: "POST", method: "POST",

View File

@@ -162,10 +162,12 @@ fn content_type_for_path(path: &Path) -> &'static str {
} }
/// Resolve a path relative to a specific workspace. /// 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( async fn resolve_path_for_workspace(
state: &Arc<AppState>, state: &Arc<AppState>,
workspace_id: uuid::Uuid, workspace_id: uuid::Uuid,
path: &str, path: &str,
mission_id: Option<uuid::Uuid>,
) -> Result<PathBuf, (StatusCode, String)> { ) -> Result<PathBuf, (StatusCode, String)> {
let workspace = state let workspace = state
.workspaces .workspaces
@@ -191,12 +193,23 @@ async fn resolve_path_for_workspace(
let resolved = if input.is_absolute() { let resolved = if input.is_absolute() {
input.to_path_buf() input.to_path_buf()
} else if path.starts_with("./context") || path.starts_with("context") { } 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 let suffix = path
.trim_start_matches("./") .trim_start_matches("./")
.trim_start_matches("context/") .trim_start_matches("context/")
.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() { if suffix.is_empty() {
context_path context_path
} else { } else {
@@ -244,14 +257,18 @@ async fn resolve_path_for_workspace(
} }
}; };
// Validate that the resolved path is within the workspace // Validate that the resolved path is within an allowed location
if !canonical.starts_with(&workspace_root) { // 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(( return Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
format!( format!(
"Path traversal attempt: {} is outside workspace {}", "Path traversal attempt: {} is outside allowed directories",
canonical.display(), canonical.display(),
workspace_root.display()
), ),
)); ));
} }
@@ -421,6 +438,8 @@ pub struct PathQuery {
pub path: String, pub path: String,
/// Optional workspace ID to resolve relative paths against /// Optional workspace ID to resolve relative paths against
pub workspace_id: Option<uuid::Uuid>, pub workspace_id: Option<uuid::Uuid>,
/// Optional mission ID for mission-specific context directories
pub mission_id: Option<uuid::Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -556,8 +575,9 @@ pub async fn upload(
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// If workspace_id is provided, resolve path relative to that workspace // 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 { 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 { } else {
resolve_upload_base(&q.path)? resolve_upload_base(&q.path)?
}; };
@@ -709,6 +729,8 @@ pub struct FinalizeUploadRequest {
pub total_chunks: u32, pub total_chunks: u32,
/// Optional workspace ID to resolve relative paths against /// Optional workspace ID to resolve relative paths against
pub workspace_id: Option<uuid::Uuid>, pub workspace_id: Option<uuid::Uuid>,
/// Optional mission ID for mission-specific context directories
pub mission_id: Option<uuid::Uuid>,
} }
// Finalize chunked upload by assembling chunks // Finalize chunked upload by assembling chunks
@@ -717,8 +739,9 @@ pub async fn upload_finalize(
Json(req): Json<FinalizeUploadRequest>, Json(req): Json<FinalizeUploadRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// If workspace_id is provided, resolve path relative to that workspace // 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 { 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 { } else {
resolve_upload_base(&req.path)? resolve_upload_base(&req.path)?
}; };
@@ -812,6 +835,8 @@ pub struct DownloadUrlRequest {
pub file_name: Option<String>, pub file_name: Option<String>,
/// Optional workspace ID to resolve relative paths against /// Optional workspace ID to resolve relative paths against
pub workspace_id: Option<uuid::Uuid>, pub workspace_id: Option<uuid::Uuid>,
/// Optional mission ID for mission-specific context directories
pub mission_id: Option<uuid::Uuid>,
} }
// Download file from URL to server filesystem // Download file from URL to server filesystem
@@ -931,8 +956,9 @@ pub async fn download_from_url(
drop(f); drop(f);
// Move to destination // 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 { 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 { } else {
resolve_upload_base(&req.path)? resolve_upload_base(&req.path)?
}; };