The two webviews (local title bar, remote web UI) become one WebviewWindow on ui/index.html. The window refuses every navigation but the app's page and its sealed frames; the capability still names only main and no remote URL. - server.rs: discovery refuses a server older than 2.0 (protocol 3 and major version 2) and an app API newer than this app knows; the app's own device login for the app scope, the token kept in account.json (0600) and never given to the page; a proxy limited to /api/v2 paths; the SSE stream. - files.rs: list and read the agent's working directory, resolved (links and .. included) and refused outside the agent's folders. - sealed.rs: HTML for the viewer on its own scheme under the server's sealed policy: no bridge, no network, no navigation. - watch.rs + tauri-plugin-notification: a notification when a reply or an agent chat finishes out of sight. - A failed start and panics go to desktop.log; a missing tray no longer stops the app; the DMA-BUF renderer is off under NVIDIA's driver. - The unstable multiwebview feature, the gtk dependency and the theme report are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
247 lines
8.8 KiB
Rust
247 lines
8.8 KiB
Rust
//! The agent's working directory, for the explorer and the viewer: list a folder, read a file.
|
|
//!
|
|
//! Only inside the folders the agent may work in (`lembas remote` roots, as the CLI last said):
|
|
//! the page names a folder and a path inside it, and both are resolved here -- symbolic links and
|
|
//! `..` included -- before anything is opened. A path that ends up outside every root is refused,
|
|
//! whatever the page asked. Reading only: the app never writes in a project, the agent does.
|
|
|
|
use std::fs;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
use serde::Serialize;
|
|
|
|
/// The most of a folder listed at once.
|
|
pub const MAX_ENTRIES: usize = 5000;
|
|
/// The most of a file read for the viewer; a bigger one is offered by name only.
|
|
pub const MAX_READ: u64 = 2 * 1024 * 1024;
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
pub struct Entry {
|
|
pub name: String,
|
|
/// From the folder the page named, with `/` between parts.
|
|
pub path: String,
|
|
/// `dir` or `file` (a link counts as what it points to; a broken one as a file).
|
|
pub kind: &'static str,
|
|
pub size: u64,
|
|
/// Seconds since 1970, when the system says.
|
|
pub modified: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
pub struct Listing {
|
|
pub root: String,
|
|
pub path: String,
|
|
pub entries: Vec<Entry>,
|
|
/// More entries than `MAX_ENTRIES`: the rest are not listed.
|
|
pub more: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
|
pub struct Opened {
|
|
pub root: String,
|
|
pub path: String,
|
|
pub name: String,
|
|
pub size: u64,
|
|
/// The text, when the file is text and small enough.
|
|
pub text: Option<String>,
|
|
/// Why there is no text: `binary` or `too_big`.
|
|
pub withheld: Option<&'static str>,
|
|
}
|
|
|
|
/// `root` (a folder the agent may use, or one inside it) and `path` inside it, resolved, and
|
|
/// refused unless the result is inside one of `roots`. The canonical root and target.
|
|
pub fn resolve(roots: &[String], root: &str, path: &str) -> Result<(PathBuf, PathBuf), String> {
|
|
let base = fs::canonicalize(root).map_err(|e| format!("{root}: {e}"))?;
|
|
let allowed = roots
|
|
.iter()
|
|
.filter_map(|r| fs::canonicalize(r).ok())
|
|
.any(|r| base.starts_with(&r));
|
|
if !allowed {
|
|
return Err(format!(
|
|
"{root} is not a folder the agent may work in. Add it under Settings, Project folders."
|
|
));
|
|
}
|
|
let rel = Path::new(path.trim_start_matches('/'));
|
|
if rel.components().any(|c| matches!(c, Component::Prefix(_))) {
|
|
return Err(format!("Not a path inside {root}: {path}"));
|
|
}
|
|
let target = fs::canonicalize(base.join(rel)).map_err(|e| format!("{path}: {e}"))?;
|
|
if !target.starts_with(&base) {
|
|
return Err(format!("{path} is outside {root}."));
|
|
}
|
|
Ok((base, target))
|
|
}
|
|
|
|
/// `target` from `base`, with `/` between parts ("" for the base itself).
|
|
fn relative(base: &Path, target: &Path) -> String {
|
|
target
|
|
.strip_prefix(base)
|
|
.map(|p| {
|
|
p.components()
|
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join("/")
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn list(roots: &[String], root: &str, path: &str) -> Result<Listing, String> {
|
|
let (base, dir) = resolve(roots, root, path)?;
|
|
if !dir.is_dir() {
|
|
return Err(format!("{path} is not a folder."));
|
|
}
|
|
let mut entries = Vec::new();
|
|
let mut more = false;
|
|
for item in fs::read_dir(&dir).map_err(|e| format!("{path}: {e}"))? {
|
|
let Ok(item) = item else { continue };
|
|
if entries.len() >= MAX_ENTRIES {
|
|
more = true;
|
|
break;
|
|
}
|
|
let name = item.file_name().to_string_lossy().into_owned();
|
|
// A link is shown as what it points to; following it is `resolve`'s to allow or not.
|
|
let meta = fs::metadata(item.path()).or_else(|_| item.metadata());
|
|
let (kind, size, modified) = match meta {
|
|
Ok(m) => (
|
|
if m.is_dir() { "dir" } else { "file" },
|
|
if m.is_dir() { 0 } else { m.len() },
|
|
m.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs()),
|
|
),
|
|
Err(_) => ("file", 0, None),
|
|
};
|
|
let at = relative(&base, &dir);
|
|
entries.push(Entry {
|
|
path: if at.is_empty() {
|
|
name.clone()
|
|
} else {
|
|
format!("{at}/{name}")
|
|
},
|
|
name,
|
|
kind,
|
|
size,
|
|
modified,
|
|
});
|
|
}
|
|
entries.sort_by(|a, b| {
|
|
(a.kind != "dir")
|
|
.cmp(&(b.kind != "dir"))
|
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
});
|
|
Ok(Listing {
|
|
root: base.to_string_lossy().into_owned(),
|
|
path: relative(&base, &dir),
|
|
entries,
|
|
more,
|
|
})
|
|
}
|
|
|
|
/// Text, as the CLI's `show` decides it: no NUL byte, and valid UTF-8.
|
|
fn as_text(data: Vec<u8>) -> Option<String> {
|
|
if data.contains(&0) {
|
|
return None;
|
|
}
|
|
String::from_utf8(data).ok()
|
|
}
|
|
|
|
pub fn read(roots: &[String], root: &str, path: &str) -> Result<Opened, String> {
|
|
let (base, file) = resolve(roots, root, path)?;
|
|
let meta = fs::metadata(&file).map_err(|e| format!("{path}: {e}"))?;
|
|
if !meta.is_file() {
|
|
return Err(format!("{path} is not a file."));
|
|
}
|
|
let name = file
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
let mut opened = Opened {
|
|
root: base.to_string_lossy().into_owned(),
|
|
path: relative(&base, &file),
|
|
name,
|
|
size: meta.len(),
|
|
text: None,
|
|
withheld: None,
|
|
};
|
|
if meta.len() > MAX_READ {
|
|
opened.withheld = Some("too_big");
|
|
return Ok(opened);
|
|
}
|
|
let data = fs::read(&file).map_err(|e| format!("{path}: {e}"))?;
|
|
match as_text(data) {
|
|
Some(t) => opened.text = Some(t),
|
|
None => opened.withheld = Some("binary"),
|
|
}
|
|
Ok(opened)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn scratch(name: &str) -> PathBuf {
|
|
let d = std::env::temp_dir().join(format!("llembas-files-{name}-{}", std::process::id()));
|
|
let _ = fs::remove_dir_all(&d);
|
|
fs::create_dir_all(d.join("project/src")).unwrap();
|
|
fs::create_dir_all(d.join("outside")).unwrap();
|
|
fs::write(d.join("project/README.md"), "# Waybread\n").unwrap();
|
|
fs::write(d.join("project/src/main.rs"), "fn main() {}\n").unwrap();
|
|
fs::write(d.join("project/blob.bin"), [0u8, 1, 2]).unwrap();
|
|
fs::write(d.join("outside/secret.txt"), "no").unwrap();
|
|
d
|
|
}
|
|
|
|
fn roots(d: &Path) -> Vec<String> {
|
|
vec![d.join("project").to_string_lossy().into_owned()]
|
|
}
|
|
|
|
#[test]
|
|
fn a_root_lists_folders_first() {
|
|
let d = scratch("list");
|
|
let root = d.join("project").to_string_lossy().into_owned();
|
|
let l = list(&roots(&d), &root, "").unwrap();
|
|
let names: Vec<_> = l.entries.iter().map(|e| e.name.as_str()).collect();
|
|
assert_eq!(names, vec!["src", "blob.bin", "README.md"]);
|
|
assert_eq!(l.entries[0].kind, "dir");
|
|
let inner = list(&roots(&d), &root, "src").unwrap();
|
|
assert_eq!(inner.entries[0].path, "src/main.rs");
|
|
fs::remove_dir_all(&d).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_outside_the_roots_is_reached() {
|
|
let d = scratch("outside");
|
|
let root = d.join("project").to_string_lossy().into_owned();
|
|
let other = d.join("outside").to_string_lossy().into_owned();
|
|
assert!(list(&roots(&d), &other, "").is_err());
|
|
assert!(read(&roots(&d), &root, "../outside/secret.txt").is_err());
|
|
assert!(read(&roots(&d), &root, "/../outside/secret.txt").is_err());
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::fs::symlink(d.join("outside"), d.join("project/escape")).unwrap();
|
|
assert!(read(&roots(&d), &root, "escape/secret.txt").is_err());
|
|
// A folder inside a root is a root of its own for the pane (an agent chat's folder).
|
|
let sub = d.join("project/src").to_string_lossy().into_owned();
|
|
assert!(list(&roots(&d), &sub, "").is_ok());
|
|
}
|
|
fs::remove_dir_all(&d).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn text_is_read_and_the_rest_is_withheld() {
|
|
let d = scratch("read");
|
|
let root = d.join("project").to_string_lossy().into_owned();
|
|
let md = read(&roots(&d), &root, "README.md").unwrap();
|
|
assert_eq!(md.text.as_deref(), Some("# Waybread\n"));
|
|
assert_eq!(md.path, "README.md");
|
|
let bin = read(&roots(&d), &root, "blob.bin").unwrap();
|
|
assert_eq!(bin.text, None);
|
|
assert_eq!(bin.withheld, Some("binary"));
|
|
assert!(read(&roots(&d), &root, "src").is_err());
|
|
fs::remove_dir_all(&d).unwrap();
|
|
}
|
|
}
|