mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
#1163: file.list lists a folder for agents, held to the read root
An agent driving VectorCraft over the confined MCP server had no way to
see which files it was given, so it guessed names. `file.list {path}`
lists a folder's files (with their sizes) and folders by name, held to
the automation read root like every read; links are listed as links and
not followed. At most 5000 entries, with `truncated` saying when there
were more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6d07135456
commit
339b01f48b
@@ -87,6 +87,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
cmd!(
|
||||
query "file.list",
|
||||
"List Folder",
|
||||
[],
|
||||
None,
|
||||
"{path} the files and folders in folder `path`, by name: what an agent may open there (held to the automation read root when one is in force) → {path, entries: [{name, kind: file|folder|link, size? (files, bytes)}], truncated (more than 5000 entries)} (#1163)",
|
||||
always,
|
||||
file_list
|
||||
),
|
||||
cmd!(
|
||||
query "document.serialize",
|
||||
"Serialize Document",
|
||||
@@ -783,6 +792,48 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The most entries `file.list` returns.
|
||||
const LIST_MAX: usize = 5000;
|
||||
|
||||
fn file_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| bad("file.list", "missing `path`"))?;
|
||||
let (entries, truncated) = list_dir(path, LIST_MAX)?;
|
||||
Ok(json!({ "path": absolute_path(path), "entries": entries, "truncated": truncated }))
|
||||
}
|
||||
|
||||
/// The entries of folder `path`, sorted by name: at most `max`, and whether there were more. A
|
||||
/// link isn't followed: its target may lie outside the automation roots.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn list_dir(path: &str, max: usize) -> Result<(Vec<Value>, bool)> {
|
||||
may_read(path)?;
|
||||
let dir = std::fs::read_dir(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut entries: Vec<(String, Value)> = vec![];
|
||||
let mut truncated = false;
|
||||
for e in dir.flatten() {
|
||||
if entries.len() == max {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let Ok(t) = e.file_type() else { continue };
|
||||
let entry = if t.is_symlink() {
|
||||
json!({ "name": name, "kind": "link" })
|
||||
} else if t.is_dir() {
|
||||
json!({ "name": name, "kind": "folder" })
|
||||
} else {
|
||||
json!({ "name": name, "kind": "file", "size": e.metadata().map(|m| m.len()).ok() })
|
||||
};
|
||||
entries.push((name, entry));
|
||||
}
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok((entries.into_iter().map(|(_, v)| v).collect(), truncated))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
|
||||
Err(EngineError::Other("no file system here".into()))
|
||||
}
|
||||
|
||||
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
|
||||
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1670,3 +1670,32 @@ fn path_commands_reject_bad_anchors_before_modifying_geometry() {
|
||||
s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2);
|
||||
}
|
||||
|
||||
/// #1163: `file.list` lists a folder's files and folders by name, held to the automation read root
|
||||
/// when one is in force.
|
||||
#[test]
|
||||
fn file_list_lists_a_folder_within_the_read_root() {
|
||||
let base = std::env::temp_dir().join(format!("vc-file-list-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/b.svg"), "<svg/>").unwrap();
|
||||
std::fs::write(base.join("in/a.pdf"), "1234").unwrap();
|
||||
let path = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.list", &json!({"path": path("in")})).unwrap();
|
||||
assert_eq!(
|
||||
r["entries"],
|
||||
json!([{"name": "a.pdf", "kind": "file", "size": 4}, {"name": "b.svg", "kind": "file", "size": 6}, {"name": "sub", "kind": "folder"}])
|
||||
);
|
||||
assert_eq!(r["truncated"], false);
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
crate::file_access::confine(Some(&roots), || {
|
||||
assert!(s.execute("file.list", &json!({"path": path("in/sub")})).is_ok());
|
||||
assert!(s.execute("file.list", &json!({"path": path("outside")})).is_err(), "outside the read root");
|
||||
});
|
||||
assert!(s.execute("file.list", &json!({"path": path("in/b.svg")})).is_err(), "not a folder");
|
||||
assert!(s.execute("file.list", &json!({})).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user