feat: let the fs-tools reach the whole container, and stop rebuilding the system prefix every round
Nightly Build / build (push) Successful in 7m33s

Two changes to what a turn costs and what it can see.

## The system prefix is frozen per conversation

`AgentSystemContext::system_context` is called once per round and reassembled
`base` from disk and SQLite each time, so an agent writing `user-memory/index.md`
in round 3 turned round 4 — seconds later, with the provider cache certainly
warm — into a full miss. `base` is the head of every provider's cache key, so it
is the most expensive string in the request to touch.

`PrefixCache` builds it once per (conversation, agent) and holds it on
`UserLoopRuntime`. The refresh rule is the only free one: rebuild once the
conversation has been idle longer than a provider's cache could survive
(20 min). The clock is idle time of the conversation, not time since a file
changed, and reading restarts it — every get is a request about to go out.

Writes are deliberately not reacted to. The agent's own edits are already in the
context, two messages downstream. A write from elsewhere is invisible until the
TTL: that is precisely where an immediate rebuild costs the most, and the
cheaper freshness path already exists — a `read_file` result appends, and
appending invalidates nothing. The injection header now says so.

Also removes 4 DB queries and 2 file reads per round.

## The security boundary is the container, not the mounted subtree

`read_file /tmp/cv.txt` answered "path escapes your workspace" and the agent
re-read the file with `cat`. It was right to refuse — /tmp exists only inside
the container — but the refusal protected nothing: `execute_cmd` already runs
there with passwordless sudo. The mount is the fast path, not the perimeter.

`resolve_target` now routes an absolute path through `container_to_agent`
first. Landing on a mount takes the host path, which also fixes a real bug:
`/root/x` IS `~/x`, yet every tool rejected it, because `PathBuf::join` with an
absolute tail discards the base and the result then failed the prefix check
(`/root/shared/{X}/…` too). Landing nowhere means container-only, served by the
new `container::exec_fs` over `docker exec`, with paths passed positionally so
a path containing `$(…)` stays data. Membership still holds: `/root/shared/{X}`
for a non-member fails exactly as `shared/{X}` does.

Single-file tools get this without a second implementation: `fs::Shuttle` pulls
the file out, runs the unchanged tool on the copy, and pushes it back if the
bytes changed. `list_files` lists in place, `read_file` reads container paths as
text (a shuttled copy cannot back a MediaRef), and `grep_files` refuses them
with a pointer to `rg` rather than approximating its own semantics. The viewer
follows the same routing, so the user can open what the agent read.

Host containment is untouched and still guards every mounted path — it is the
defence against a symlink planted in the container pointing at the host's /etc,
and the container branch never touches the host filesystem at all.

Verified end-to-end against a live skald-runtime:v3 container: write/read/edit
on /tmp round-trip, /etc/os-release reads, binary and shell-metacharacter paths
survive, and /root/notes.md lands in the host home.
This commit is contained in:
2026-08-04 12:42:04 +01:00
parent 080ea736e4
commit 6cb4ea0ce8
21 changed files with 784 additions and 94 deletions
+29 -3
View File
@@ -198,12 +198,38 @@ pub async fn get_file(
}
let user_fs = ctx.fs.load();
let (abs, agent) = match fs_tools::resolve_view_path(user_fs.as_ref(), &q.path) {
Ok((abs, agent)) => (abs, agent),
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
let (target, agent) = match fs_tools::resolve_view_target(user_fs.as_ref(), &q.path) {
Ok(resolved) => resolved,
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
};
let writable = user_fs.can_write_to(&agent);
// A container-only path (`/tmp/…`) has no host file behind it: the bytes come
// out through the container, so the user sees what the agent read. Served
// read-only — the editor's optimistic locking is an on-disk `mtime`+`len`,
// which has no counterpart here, and without an ETag the frontend keeps the
// file in view mode rather than risking a blind overwrite.
let abs = match target {
fs_tools::FsTarget::Host(abs) => abs,
fs_tools::FsTarget::Container { container, path } => {
return match skald_core::container::exec_fs::read(&container, &path).await {
Ok(bytes) => {
let mut response = bytes.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type_for(&q.path)),
);
if q.force_download {
set_attachment(&mut response, &basename(&q.path));
}
response
}
Err(_) => (StatusCode::NOT_FOUND, format!("File not found: {}", q.path))
.into_response(),
};
}
};
if q.compile_latex && is_latex(&q.path) {
return match state.latex_compiler().compile(&abs).await {
Ok(pdf) => {