Files
Skald-Circle/crates/skald-core/src/container/exec_fs.rs
T
Daniele 55dcb48299
Nightly Build / build (push) Successful in 8m6s
fix(telegram): resolve send_attachment paths in the user's workspace
`send_attachment` handed its `file_path` argument straight to
`InputFile::file`, which resolves against the **server process's** working
directory. Every path the model can actually have — relative to the user's
home, or absolute inside their container — failed the `path.exists()` check,
and the one class that didn't (a name that happens to exist next to the
binary) would have sent the wrong file.

The routing already exists for the fs-tools, so expose it rather than repeat
it: `UserFilesApi` (core-api) reads a path in the agent's own vocabulary and
is obtained from `UserChannelHandle::files()`, so it is scoped to one user by
construction. skald-core implements it over `resolve_view_target` — host
mount read directly, container-only path through `docker exec` — holding the
`SharedFs` cell rather than a snapshot, so a remount lands without a login.

The size cap is checked before the read (a new `exec_fs::size` for the
container branch): the point of a cap is to keep an oversized file out of RAM,
so checking it afterwards would protect nothing. A photo above `sendPhoto`'s
narrower 10 MB ceiling goes out as a document instead of as an API error.
2026-08-10 00:08:16 +01:00

156 lines
5.9 KiB
Rust

//! Filesystem primitives that act **inside** a user's container, for the paths
//! their bind mounts do not cover (`/tmp`, `/etc`, an installed package's files…).
//!
//! The security boundary is the container, not the bind-mounted subtree: an agent
//! already has unrestricted reach in there through `execute_cmd`, which runs with
//! passwordless `sudo`. Tools that stopped at the mounts were therefore not
//! protecting anything — they offered a poorer view of the same sandbox, and the
//! model routinely worked around them by shelling out. These primitives close
//! that gap so the fs-tools see what the shell sees.
//!
//! What does *not* change is host containment. A path that lands on a mount keeps
//! the host fast path and its canonicalize-and-prefix-check, which is what stops a
//! symlink planted in the container from resolving against the **host's** `/etc`.
//! Nothing here ever touches the host filesystem, so there is no host to escape
//! from on this side.
//!
//! Paths are passed to `sh` **positionally** (`$1`), never interpolated into the
//! script, so a path containing quotes or `$(…)` is data and not shell syntax —
//! the same rule `execute_cmd` already follows for its pidfile.
use std::path::Path;
use std::process::Stdio;
use anyhow::{Context, Result, bail};
use tokio::io::AsyncWriteExt;
/// Runs a shell snippet inside `container` with `args` bound to `$1`, `$2`, …
/// Returns raw stdout — callers that expect text decode it themselves, so a
/// binary `cat` is not mangled on the way through.
pub(super) async fn sh(container: &str, script: &str, args: &[&str]) -> Result<Vec<u8>> {
let mut argv: Vec<&str> = vec!["exec", container, "sh", "-c", script, "_"];
argv.extend_from_slice(args);
let out = tokio::process::Command::new("docker")
.args(&argv)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.context("failed to spawn `docker` (is the Docker CLI installed?)")?;
if out.status.success() {
Ok(out.stdout)
} else {
let err = String::from_utf8_lossy(&out.stderr);
bail!("{}", err.trim());
}
}
/// True when the snippet exits 0 — for the `test`-style probes, where a non-zero
/// exit is the answer rather than a failure.
async fn sh_ok(container: &str, script: &str, args: &[&str]) -> bool {
sh(container, script, args).await.is_ok()
}
/// Reads a file from inside the container.
pub async fn read(container: &str, path: &Path) -> Result<Vec<u8>> {
let p = path.to_string_lossy();
sh(container, r#"cat -- "$1""#, &[&p])
.await
.with_context(|| format!("Cannot read file: {p}"))
}
/// Writes a file inside the container, creating its parent directories. The
/// bytes travel on stdin rather than inside the script, so content is never
/// shell-parsed and size is bounded by the pipe, not by `ARG_MAX`.
pub async fn write(container: &str, path: &Path, bytes: &[u8]) -> Result<()> {
let p = path.to_string_lossy();
let mut child = tokio::process::Command::new("docker")
.args([
"exec", "-i", container, "sh", "-c",
r#"mkdir -p -- "$(dirname -- "$1")" && cat > "$1""#, "_", &p,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `docker`")?;
child
.stdin
.take()
.context("docker exec produced no stdin")?
.write_all(bytes)
.await
.with_context(|| format!("Failed to write: {p}"))?;
let out = child.wait_with_output().await.context("docker exec failed")?;
if !out.status.success() {
bail!("Failed to write {p}: {}", String::from_utf8_lossy(&out.stderr).trim());
}
Ok(())
}
/// Byte size of a file inside the container — for the callers that must decide
/// whether to read it *before* pulling it through the pipe. `wc -c` rather than
/// `stat`, so the answer is the same on any of the image's shells.
pub async fn size(container: &str, path: &Path) -> Result<u64> {
let p = path.to_string_lossy();
let raw = sh(container, r#"wc -c < "$1""#, &[&p])
.await
.with_context(|| format!("Cannot stat file: {p}"))?;
String::from_utf8_lossy(&raw)
.trim()
.parse()
.with_context(|| format!("Cannot stat file: {p}"))
}
pub async fn exists(container: &str, path: &Path) -> bool {
sh_ok(container, r#"test -e "$1""#, &[&path.to_string_lossy()]).await
}
pub async fn is_dir(container: &str, path: &Path) -> bool {
sh_ok(container, r#"test -d "$1""#, &[&path.to_string_lossy()]).await
}
/// One entry of a container directory listing.
pub struct Entry {
pub name: String,
pub is_dir: bool,
pub size: u64,
}
/// Lists a directory inside the container, `depth` levels deep (1 = immediate
/// children). Emits `type\tsize\tpath` per line via `find`, which is in the image
/// and needs no parsing of `ls`'s locale-dependent output.
pub async fn list(container: &str, path: &Path, depth: usize) -> Result<Vec<Entry>> {
let p = path.to_string_lossy();
let d = depth.max(1).to_string();
let raw = sh(
container,
r#"find "$1" -mindepth 1 -maxdepth "$2" -printf '%y\t%s\t%p\n' 2>/dev/null || true"#,
&[&p, &d],
)
.await
.with_context(|| format!("Cannot list directory: {p}"))?;
let text = String::from_utf8_lossy(&raw);
let prefix = format!("{}/", p.trim_end_matches('/'));
let mut out = Vec::new();
for line in text.lines() {
let mut f = line.splitn(3, '\t');
let (Some(kind), Some(size), Some(full)) = (f.next(), f.next(), f.next()) else {
continue;
};
out.push(Entry {
name: full.strip_prefix(&prefix).unwrap_or(full).to_string(),
is_dir: kind == "d",
size: size.parse().unwrap_or(0),
});
}
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
Ok(out)
}