scope file ops (ws, api, tools) to per-user context
Nightly Build / build (push) Successful in 6m28s

This commit is contained in:
2026-07-20 23:04:24 +01:00
parent 79a62c0b93
commit b627db761b
7 changed files with 206 additions and 58 deletions
+6 -4
View File
@@ -149,10 +149,12 @@ pub enum ServerEvent {
FileChanged {
path: String,
},
/// Ask the frontend to open a file for the user. Behaves like
/// `window.openFile(path)`: navigates to the file viewer page for markdown /
/// text / images, or opens an HTML file in a new browser tab. Emitted by
/// the future `show_file_to_user` interface tool (not wired yet).
/// Ask the frontend to open a file for the user, via `window.openFile(path)`:
/// the file-viewer page renders every kind (markdown / text / images / SVG /
/// PDF / LaTeX compiled server-side, and HTML live in an origin-isolated
/// iframe). Emitted by the `show_file_to_user` interface tool; `path` is the
/// caller's canonical **agent path** (`~/…`, `shared/{X}/…`, `projects/…`),
/// which the viewer fetches back through `GET /api/file`.
OpenFile {
path: String,
},
+59
View File
@@ -161,6 +161,65 @@ impl UserFs {
let stripped = strip_home_prefix(agent_path);
normalize(&self.container_home.join(stripped))
}
/// Reverse of [`to_container`](Self::to_container) for an already-absolute path:
/// map a **container-absolute** path (`/root/…`, `/root/shared/{X}/…`,
/// `/root/projects/{O}/{S}/…`) back to the agent vocabulary. Shared and project
/// mounts nest *under* `container_home`, so they are matched **first** — otherwise
/// `/root/shared/X` would strip against the home base and mis-route.
///
/// Returns `None` when `abs` lies outside every one of this user's container mounts
/// (i.e. it points outside their view) — the caller rejects it fail-closed. Purely
/// lexical: no membership check, no filesystem access.
pub fn container_to_agent(&self, abs: &Path) -> Option<String> {
let abs = normalize(abs);
for m in &self.shared {
if let Ok(tail) = abs.strip_prefix(&m.container) {
return Some(agent_join(&format!("shared/{}", m.name), tail));
}
}
for m in &self.projects {
if let Ok(tail) = abs.strip_prefix(&m.container) {
return Some(agent_join(&format!("projects/{}/{}", m.owner_username, m.slug), tail));
}
}
abs.strip_prefix(&self.container_home)
.ok()
.map(|tail| agent_join("~", tail))
}
/// Normalize any path arriving from the show-file / file-viewer surface into a
/// **canonical agent path** the UI can display and echo back: a relative or `~/…`
/// path is cleaned and rooted (`report.md` → `~/report.md`, `shared/X/y` and
/// `projects/O/S/y` keep their root); a container-absolute path is reverse-mapped
/// via [`container_to_agent`](Self::container_to_agent).
///
/// Returns `None` only for an absolute path outside every container mount — the
/// caller rejects it fail-closed. Purely lexical (`.`/`..` collapse, `..` clamps at
/// the root); membership + on-disk containment are enforced later, in skald-core.
pub fn to_agent_display(&self, input: &str) -> Option<String> {
let p = Path::new(input);
if p.is_absolute() {
return self.container_to_agent(p);
}
let cleaned = normalize(Path::new(strip_home_prefix(input)));
let cleaned = cleaned.to_string_lossy().replace('\\', "/");
let root = cleaned.split('/').next().unwrap_or("");
if root == "shared" || root == "projects" {
Some(cleaned)
} else if cleaned.is_empty() {
Some("~".to_string())
} else {
Some(format!("~/{cleaned}"))
}
}
}
/// Join an agent-path base (`~`, `shared/X`, `projects/O/S`) with a tail relative to
/// the mount, normalizing separators. An empty tail yields the bare base.
fn agent_join(base: &str, tail: &Path) -> String {
let t = tail.to_string_lossy().replace('\\', "/");
if t.is_empty() { base.to_string() } else { format!("{base}/{t}") }
}
/// Strips a leading `~/`, bare `~`, or `./` so what remains is relative to the home.
+23
View File
@@ -212,6 +212,29 @@ pub(crate) fn resolve_host_path(fs: &UserFs, agent_path: &str) -> Result<PathBuf
Ok(canon)
}
/// Resolve a path arriving from the show-file / file-viewer surface into
/// `(host_abs, agent_display)`, scoped to the caller's workspace.
///
/// Accepts the agent vocabulary (`~/…`, `shared/{X}/…`, `projects/{O}/{S}/…`, bare
/// relative) **and** a container-absolute path (`/root/…`); any path outside the
/// caller's container view is rejected fail-closed. `agent_display` is the canonical
/// path the UI shows and echoes back to `/api/file`, so the tool, the viewer fetch
/// and the watcher all key on the same string. Memory paths (`user-memory/…`,
/// `shared-memory/…`) are virtual notes, not disk files — rejected with a clear error.
///
/// This is the single entry point the server shell uses for `show_file_to_user`,
/// `GET /api/file` and `GET /api/file/watch`; containment (canonicalize +
/// prefix-check, symlink-aware) is handled by [`resolve_host_path`].
pub fn resolve_view_path(fs: &UserFs, input: &str) -> Result<(PathBuf, String)> {
if classify_memory(input).is_some() {
anyhow::bail!("memory notes can't be opened in the file viewer: {input}");
}
let agent = fs.to_agent_display(input)
.ok_or_else(|| anyhow::anyhow!("path is outside your workspace: {input}"))?;
let host = resolve_host_path(fs, &agent)?;
Ok((host, agent))
}
/// Rewrites the `path` argument of a physical fs-tool call to the resolved absolute
/// host path, so the on-disk `execute` (which takes absolute paths as-is) acts on
/// the caller's per-user workspace rather than the process working directory.
+24 -11
View File
@@ -2,22 +2,28 @@ use std::sync::Arc;
use serde_json::{Value, json};
use core_api::user_fs::SharedFs;
use crate::chat_hub::ChatHub;
use crate::events::{GlobalEvent, ServerEvent};
use crate::session::handler::{InterfaceTool, ToolFuture};
use crate::tools::fs;
use crate::tools::tool_names::SHOW_FILE_TO_USER;
/// Build a `show_file_to_user` InterfaceTool bound to a `ChatHub` and a source.
/// Build a `show_file_to_user` InterfaceTool bound to a `ChatHub`, a source and the
/// caller's [`SharedFs`] (their per-user filesystem view).
///
/// Injected only for SPA clients (web copilot + mobile) at the WebSocket entry
/// point, so Telegram — which has its own `send_attachment` — never sees it.
///
/// When called, it emits a `ServerEvent::OpenFile` to the source's connected
/// clients. The frontend routes it: HTML opens in a new browser tab, everything
/// else (Markdown / code / raster images / SVG / PDF / LaTeX — which is compiled
/// to PDF server-side) opens in the file-viewer page.
pub fn make_tool(hub: Arc<ChatHub>, source: String) -> InterfaceTool {
/// The path is resolved through the caller's own workspace (`resolve_view_path`):
/// `~/…`, `shared/{X}/…`, `projects/{O}/{S}/…`, a bare relative path, or a
/// container-absolute `/root/…` — anything outside the container view is refused.
/// It then emits a `ServerEvent::OpenFile` carrying the **canonical agent path**, so
/// the file-viewer page fetches the same file back through `/api/file` (which applies
/// the identical per-user resolution). The frontend renders every kind in the viewer
/// (HTML live in an origin-isolated iframe; LaTeX compiled to PDF server-side).
pub fn make_tool(hub: Arc<ChatHub>, source: String, fs: SharedFs) -> InterfaceTool {
let definition = json!({
"type": "function",
"function": {
@@ -41,7 +47,10 @@ pub fn make_tool(hub: Arc<ChatHub>, source: String) -> InterfaceTool {
"properties": {
"path": {
"type": "string",
"description": "Path of the file to show. Relative to the project root, or absolute."
"description": "Path of the file to show, in your own workspace: relative to your \
home (e.g. `report.md` or `~/report.md`), a `shared/<folder>/…` or \
`projects/<owner>/<slug>/…` path, or an absolute container path \
(`/root/…`). Paths outside your workspace are refused."
}
},
"required": ["path"]
@@ -52,20 +61,24 @@ pub fn make_tool(hub: Arc<ChatHub>, source: String) -> InterfaceTool {
let handler = Arc::new(move |args: Value| -> ToolFuture {
let hub = Arc::clone(&hub);
let source = source.clone();
let fs = fs.clone();
Box::pin(async move {
let path = args["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("show_file_to_user: missing required parameter 'path'"))?;
let abs = fs::resolve(path)?;
// Resolve against the caller's workspace snapshot: gives the host path to
// stat and the canonical agent path the viewer will fetch back.
let user_fs = fs.load();
let (abs, display) = fs::resolve_view_path(user_fs.as_ref(), path)
.map_err(|e| anyhow::anyhow!("show_file_to_user: {e}"))?;
if !abs.exists() {
anyhow::bail!("show_file_to_user: file not found: {path}");
anyhow::bail!("show_file_to_user: file not found: {display}");
}
if abs.is_dir() {
anyhow::bail!("show_file_to_user: '{path}' is a directory, not a file");
anyhow::bail!("show_file_to_user: '{display}' is a directory, not a file");
}
let display = fs::relativize_for_display(path);
hub.emit(GlobalEvent {
source: Some(source),
session_id: None,