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).
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)

View File

@@ -1036,7 +1036,8 @@ export function uploadFile(
file: File,
remotePath: string = "./context/",
onProgress?: (progress: UploadProgress) => void,
workspaceId?: string
workspaceId?: string,
missionId?: string
): Promise<UploadResult> {
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<UploadResult> {
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<UploadResult> {
const body: Record<string, unknown> = {
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<UploadResult> {
const body: Record<string, unknown> = {
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",

View File

@@ -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<AppState>,
workspace_id: uuid::Uuid,
path: &str,
mission_id: Option<uuid::Uuid>,
) -> Result<PathBuf, (StatusCode, String)> {
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<uuid::Uuid>,
/// Optional mission ID for mission-specific context directories
pub mission_id: Option<uuid::Uuid>,
}
#[derive(Debug, Deserialize)]
@@ -556,8 +575,9 @@ pub async fn upload(
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, (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<uuid::Uuid>,
/// Optional mission ID for mission-specific context directories
pub mission_id: Option<uuid::Uuid>,
}
// Finalize chunked upload by assembling chunks
@@ -717,8 +739,9 @@ pub async fn upload_finalize(
Json(req): Json<FinalizeUploadRequest>,
) -> Result<Json<serde_json::Value>, (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<String>,
/// Optional workspace ID to resolve relative paths against
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
@@ -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)?
};