Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src-tauri/src/commands/system_apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ pub fn workspace_import_files(
}

/// `report.md` → `report (1).md` → `report (2).md` … until free.
fn destination_candidate(dir: &Path, name: &str, copy_index: u32) -> std::path::PathBuf {
pub(crate) fn destination_candidate(dir: &Path, name: &str, copy_index: u32) -> std::path::PathBuf {
if copy_index == 0 {
return dir.join(name);
}
Expand All @@ -133,7 +133,7 @@ fn destination_candidate(dir: &Path, name: &str, copy_index: u32) -> std::path::
dir.join(format!("{} ({}){}", stem, copy_index, ext))
}

fn copy_to_unique_destination(
pub(crate) fn copy_to_unique_destination(
source: &Path,
dir: &Path,
name: &str,
Expand Down
271 changes: 271 additions & 0 deletions src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2042,6 +2042,192 @@ pub async fn workspace_delete_path(
Ok(())
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceCopyPathRequest {
pub source_workspace_id: String,
/// Path to copy, relative to the SOURCE workspace root.
pub path: String,
pub dest_workspace_id: String,
}

/// Copy a file or folder from one workspace into another (drag-and-drop from
/// the artifacts drawer). Always a copy — never a move, so the source can't be
/// destroyed by a drag. The item lands at the destination workspace ROOT under
/// a collision-free name; folders are copied recursively. Returns the created
/// name (relative to the destination root).
#[tauri::command]
pub async fn workspace_copy_path(
request: WorkspaceCopyPathRequest,
state: State<'_, AppState>,
) -> Result<String, String> {
let source_desc =
resolve_workspace_descriptor(state.inner(), Some(request.source_workspace_id.clone()))?;
let source_root = source_desc
.root_path
.as_ref()
.ok_or_else(|| "The source workspace does not expose a filesystem root".to_string())?;
ensure_agent_workspace_root(source_root)?;

let dest_desc =
resolve_workspace_descriptor(state.inner(), Some(request.dest_workspace_id.clone()))?;
let dest_root = dest_desc
.root_path
.as_ref()
.ok_or_else(|| "The destination workspace does not expose a filesystem root".to_string())?;
ensure_agent_workspace_root(dest_root)?;

let (source, is_dir, name) = resolve_copy_source(source_root, &request.path)?;

// The traversal + copy is unbounded (a folder can be arbitrarily large),
// so run it off the async executor thread.
let dest_root = dest_root.clone();
let name_for_copy = name.clone();
let created = tokio::task::spawn_blocking(move || {
copy_artifact_to_unique_destination(&source, &dest_root, &name_for_copy, is_dir)
})
.await
.map_err(|error| format!("Copy task did not complete: {}", error))??;

Ok(created
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(&name)
.to_string())
}

/// Validate + resolve a copy *source* path relative to `source_root`.
///
/// Rejects: the root itself, traversal/non-normal components, a protected dir
/// (`SKIPPED_ARTIFACT_DIRS`) at ANY depth (not just the first component), and a
/// symlink item. Resolves intermediate symlinks via `canonicalize` and
/// requires the canonical path to stay within the canonical root, so a
/// symlinked parent component can't escape the workspace. Returns
/// `(canonical source, is_dir, display name)`.
fn resolve_copy_source(source_root: &Path, rel: &str) -> Result<(PathBuf, bool, String), String> {
let rel = rel.trim();
if rel.is_empty() {
return Err("Cannot copy the workspace root.".to_string());
}
let relative = Path::new(rel);
for component in relative.components() {
match component {
Component::Normal(name) => {
if name
.to_str()
.map(|n| SKIPPED_ARTIFACT_DIRS.contains(&n))
.unwrap_or(false)
{
return Err(format!("Refusing to copy a protected path: {}", rel));
}
}
Component::CurDir => {}
// `..`, absolute prefixes, root: refuse before touching the fs.
_ => return Err(format!("Path {} is outside the workspace root", rel)),
}
}

let requested = normalize_path(source_root.join(relative));
if !requested.starts_with(source_root) {
return Err(format!("Path {} is outside the workspace root", rel));
}
if requested == *source_root {
return Err("Cannot copy the workspace root.".to_string());
}

let link_meta = fs::symlink_metadata(&requested)
.map_err(|error| format!("Path not found: {}: {}", rel, error))?;
if link_meta.file_type().is_symlink() {
return Err(format!("Refusing to copy a symlink: {}", rel));
}

// Resolve intermediate symlinks and re-check containment.
let canon_root = source_root
.canonicalize()
.map_err(|error| format!("Failed to resolve the source workspace root: {}", error))?;
let canon_source = requested
.canonicalize()
.map_err(|error| format!("Failed to resolve {}: {}", rel, error))?;
if !canon_source.starts_with(&canon_root) {
return Err(format!("Path {} resolves outside the workspace root", rel));
}

let name = requested
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("`{}` has no file name.", rel))?
.to_string();

Ok((canon_source, link_meta.file_type().is_dir(), name))
}

/// Copy `source` (file or dir) into `dest_dir` under a collision-free name
/// (`foo` → `foo (1)` → …). Files reuse the exclusive-create import helper;
/// dirs are created exclusively then filled by [`copy_dir_recursive`].
fn copy_artifact_to_unique_destination(
source: &Path,
dest_dir: &Path,
name: &str,
is_dir: bool,
) -> Result<std::path::PathBuf, String> {
if !is_dir {
return crate::commands::system_apps::copy_to_unique_destination(source, dest_dir, name);
}
for n in 0u32.. {
let candidate = crate::commands::system_apps::destination_candidate(dest_dir, name, n);
match fs::create_dir(&candidate) {
Ok(()) => {
if let Err(error) = copy_dir_recursive(source, &candidate) {
let _ = fs::remove_dir_all(&candidate);
return Err(error);
}
return Ok(candidate);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"Failed to create `{}`: {}",
candidate.display(),
error
));
}
}
}
unreachable!("u32 exhausted finding a unique directory name")
}

/// Recursively copy `src` into the (already-created) `dst`. Symlinks are
/// skipped (avoids loops and escaping the workspace tree), and protected/heavy
/// dirs (`.git`, `node_modules`, `target`, …) are skipped so a folder copy
/// doesn't drag in VCS/build state.
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
for entry in
fs::read_dir(src).map_err(|e| format!("Failed to read `{}`: {}", src.display(), e))?
{
let entry = entry.map_err(|e| format!("Failed to read `{}`: {}", src.display(), e))?;
let file_type = entry
.file_type()
.map_err(|e| format!("Failed to stat `{}`: {}", entry.path().display(), e))?;
if file_type.is_symlink() {
continue;
}
let from = entry.path();
if file_type.is_dir() && should_skip_artifact_dir(&from) {
continue;
}
let to = dst.join(entry.file_name());
if file_type.is_dir() {
fs::create_dir(&to)
.map_err(|e| format!("Failed to create `{}`: {}", to.display(), e))?;
copy_dir_recursive(&from, &to)?;
} else {
fs::copy(&from, &to)
.map_err(|e| format!("Failed to copy `{}`: {}", from.display(), e))?;
}
}
Ok(())
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceStoreImageRequest {
Expand Down Expand Up @@ -3252,4 +3438,89 @@ mod tests {
assert_eq!(viewer_for_path(Path::new("main.rs")), "text");
assert_eq!(viewer_for_path(Path::new("data.json")), "json");
}

#[test]
fn copy_artifact_handles_files_dirs_collisions_and_skips() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();

// A file copies, and a second copy of the same name gets suffixed.
std::fs::write(src.path().join("note.md"), b"hello").unwrap();
let a = copy_artifact_to_unique_destination(
&src.path().join("note.md"),
dst.path(),
"note.md",
false,
)
.unwrap();
assert_eq!(a.file_name().unwrap(), "note.md");
let b = copy_artifact_to_unique_destination(
&src.path().join("note.md"),
dst.path(),
"note.md",
false,
)
.unwrap();
assert_eq!(b.file_name().unwrap(), "note (1).md");
assert_eq!(std::fs::read(dst.path().join("note.md")).unwrap(), b"hello");

// A directory copies recursively, but symlinks and protected dirs
// (.git) are skipped.
let tree = src.path().join("proj");
std::fs::create_dir_all(tree.join("sub")).unwrap();
std::fs::create_dir_all(tree.join(".git")).unwrap();
std::fs::write(tree.join("a.txt"), b"a").unwrap();
std::fs::write(tree.join("sub/b.txt"), b"b").unwrap();
std::fs::write(tree.join(".git/config"), b"x").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("/etc/hostname", tree.join("link")).unwrap();

let out = copy_artifact_to_unique_destination(&tree, dst.path(), "proj", true).unwrap();
assert_eq!(out.file_name().unwrap(), "proj");
assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"a");
assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"b");
assert!(!out.join(".git").exists(), ".git must be skipped");
#[cfg(unix)]
assert!(!out.join("link").exists(), "symlink must be skipped");
}

#[test]
fn resolve_copy_source_guards_traversal_and_protected() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("proj/.git")).unwrap();
std::fs::write(root.path().join("proj/.git/config"), b"x").unwrap();
std::fs::write(root.path().join("proj/a.txt"), b"a").unwrap();

let (src, is_dir, name) = resolve_copy_source(root.path(), "proj/a.txt").unwrap();
assert!(!is_dir);
assert_eq!(name, "a.txt");
assert!(src.ends_with("a.txt"));

// Protected dir at a non-leading component must be rejected (not just
// the first-component delete policy).
assert!(resolve_copy_source(root.path(), "proj/.git/config").is_err());
// Traversal / non-normal components rejected before touching the fs.
assert!(resolve_copy_source(root.path(), "../etc/hosts").is_err());
assert!(resolve_copy_source(root.path(), "proj/../../etc").is_err());
// Empty / root rejected.
assert!(resolve_copy_source(root.path(), " ").is_err());
}

#[cfg(unix)]
#[test]
fn resolve_copy_source_rejects_symlink_item_and_escaping_parent() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret"), b"s").unwrap();

// A top-level symlink item pointing outside is rejected as a symlink.
std::os::unix::fs::symlink(outside.path().join("secret"), root.path().join("link"))
.unwrap();
assert!(resolve_copy_source(root.path(), "link").is_err());

// An intermediate symlinked directory that escapes the root is rejected
// by canonical containment (the final component is a real file).
std::os::unix::fs::symlink(outside.path(), root.path().join("outdir")).unwrap();
assert!(resolve_copy_source(root.path(), "outdir/secret").is_err());
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ pub fn run() {
commands::workspace::workspace_search_artifacts,
commands::workspace::workspace_write_file,
commands::workspace::workspace_delete_path,
commands::workspace::workspace_copy_path,
commands::workspace::workspace_store_image,
commands::workspace::workspace_pick_and_store_image,
commands::workspace::workspace_download_file,
Expand Down
6 changes: 6 additions & 0 deletions src/components/Fleet/WorkspaceRail.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@
border-color: var(--color-primary);
}

.rowDropTarget {
outline: 2px dashed var(--color-primary);
outline-offset: -2px;
background: var(--color-primary-alpha-10, rgba(99, 102, 241, 0.12));
}

.rowSelected:hover {
background: var(--color-primary-alpha-08);
}
Expand Down
43 changes: 42 additions & 1 deletion src/components/Fleet/WorkspaceRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ interface WorkspaceRailProps {
schedulerPaused: boolean;
schedulerPauseBusy: boolean;
onToggleSchedulerPaused: () => void;
/** Drop handler for an artifact dragged from the artifacts drawer onto a
* workspace row — copies it into that workspace. */
onArtifactDrop?: (
destWorkspaceId: string,
drag: { workspaceId: string; path: string; kind: string; name: string }
) => void;
}

const isProcessing = (
Expand Down Expand Up @@ -91,9 +97,11 @@ const WorkspaceRail = ({
runNowBusyId,
forkBusyId,
pauseBusyId,
onArtifactDrop,
}: WorkspaceRailProps) => {
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [dropTargetId, setDropTargetId] = useState<string | null>(null);

// Sort: attention first, then scheduled, then most-recently-updated.
const sorted = useMemo(
Expand Down Expand Up @@ -220,13 +228,46 @@ const WorkspaceRail = ({
return (
<div
key={ws.id}
className={rowClasses}
className={`${rowClasses}${
dropTargetId === ws.id ? ` ${styles.rowDropTarget}` : ''
}`}
onClick={() => onSelect(ws.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') onSelect(ws.id);
}}
onDragOver={(e) => {
if (!onArtifactDrop) return;
if (!e.dataTransfer.types.includes('application/x-clai-artifact')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
if (dropTargetId !== ws.id) setDropTargetId(ws.id);
}}
onDragLeave={() => {
setDropTargetId((prev) => (prev === ws.id ? null : prev));
}}
onDrop={(e) => {
if (!onArtifactDrop) return;
const raw = e.dataTransfer.getData('application/x-clai-artifact');
setDropTargetId(null);
if (!raw) return;
e.preventDefault();
try {
const drag = JSON.parse(raw);
if (
drag &&
typeof drag.path === 'string' &&
typeof drag.workspaceId === 'string' &&
typeof drag.kind === 'string' &&
typeof drag.name === 'string'
) {
onArtifactDrop(ws.id, drag);
}
} catch {
// Ignore malformed / foreign drops.
}
}}
title={collapsed ? ws.title : undefined}
>
<span
Expand Down
Loading
Loading