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:
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)?
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user