run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s

This commit is contained in:
2026-07-21 10:47:12 +01:00
parent 1db34b22ec
commit c8e4cb4384
20 changed files with 395 additions and 217 deletions
@@ -21,8 +21,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
sudo \
util-linux \
&& rm -rf /var/lib/apt/lists/*
# The container runs as the host process's uid:gid (blueprint §6 UID coherence), so
# in-container work and the host fs-tools share ownership on the bind mounts. That
# user is not root, so a blanket passwordless sudo restores install capability
# (`sudo apt-get install …`, `sudo npm i -g …`) inside the user's own sandbox — no
# security boundary is crossed (the isolation is the mount set, not the uid; the
# container was already full-root before). `util-linux` provides `setsid`, used to
# make `execute_cmd` killable as a process group.
RUN echo 'ALL ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/skald-nopasswd \
&& chmod 0440 /etc/sudoers.d/skald-nopasswd
WORKDIR /root
# The container is long-lived: created once, started at boot, exec'd into per
+97 -8
View File
@@ -27,8 +27,12 @@ use core_api::user_fs::{ProjectMount, SharedMount, UserFs};
use crate::db;
/// Our runtime image tag. Built once from the embedded [`Dockerfile`].
const IMAGE_TAG: &str = "skald-runtime";
/// Our runtime image tag. Built once from the embedded [`Dockerfile`]. The version
/// suffix is the image cache-buster: [`ContainerManager::ensure_image`] rebuilds only
/// when the tag is absent, so **bump it whenever the [`Dockerfile`] changes** (e.g.
/// `v2` added `sudo` + a NOPASSWD sudoers for the non-root container user). Old tags
/// linger as orphaned images (harmless).
const IMAGE_TAG: &str = "skald-runtime:v2";
/// The embedded Dockerfile — the source of truth, so the image can be built with
/// no files shipped alongside the binary (binary-first).
@@ -53,6 +57,19 @@ pub fn container_name(user_id: &str) -> String {
format!("skald-{user_id}")
}
/// The host process's own `(uid, gid)`, or `None` on non-unix. We run each container
/// as this uid:gid (blueprint §6 UID coherence) so files created inside the container
/// and by the host-side fs-tools share ownership on the bind mounts. On non-unix we
/// fall back to the image default (root) and skip `--user`.
#[cfg(unix)]
fn host_uid_gid() -> Option<(u32, u32)> {
Some((unsafe { libc::getuid() }, unsafe { libc::getgid() }))
}
#[cfg(not(unix))]
fn host_uid_gid() -> Option<(u32, u32)> {
None
}
/// Builds the [`UserFs`] view for a user: private home + the shared folders they
/// belong to, plus the container those mount into. Host paths are absolute
/// (anchored at the process working directory), as Docker bind mounts require.
@@ -159,36 +176,62 @@ impl ContainerManager {
Ok(())
}
/// Ensures the user's container exists and is running. Creates the host
/// directories, the container (if missing) with the right bind mounts, and
/// starts it (if stopped). Idempotent — a no-op when already running.
/// Ensures the user's container exists, runs as the host uid:gid, and is started.
/// Creates the host directories, the container (if missing) with the right bind
/// mounts + `--user`, and starts it (if stopped). Self-healing: a container whose
/// `--user` no longer matches the host uid:gid (e.g. an old root container from a
/// previous binary) is torn down and recreated. Idempotent — a no-op when a
/// matching container is already running.
pub async fn ensure(&self, user_id: &str) -> Result<()> {
let fs = build_user_fs(&self.system, user_id).await?;
// Host directories must exist before the mount, or Docker creates them
// root-owned with surprising modes.
// root-owned with surprising modes. Created by the host process, so they are
// owned by the host uid:gid the container runs as — the mounts are writable.
for (host, _container, _w) in fs.mounts() {
std::fs::create_dir_all(&host)
.with_context(|| format!("failed to create host dir {}", host.display()))?;
}
let name = &fs.container_name;
let want_user = host_uid_gid().map(|(uid, gid)| format!("{uid}:{gid}"));
match container_state(name).await {
ContainerState::Running => return Ok(()),
ContainerState::Stopped => {
// Reuse only if it runs as the expected user; otherwise recreate below.
ContainerState::Running if user_matches(name, &want_user).await => return Ok(()),
ContainerState::Stopped if user_matches(name, &want_user).await => {
docker(&["start", name]).await.context("docker start failed")?;
return Ok(());
}
ContainerState::Absent => {}
// Present but with a stale `--user` (e.g. an old root container): tear it
// down. The container holds no durable state — everything is in the bind
// mounts — so a recreate is safe.
_ => {
let _ = docker(&["rm", "-f", name]).await;
}
}
let mut args: Vec<String> = vec![
"create".into(),
// `--init` runs tini as pid 1 so orphaned/killed processes are reaped —
// otherwise `execute_cmd`'s /stop reaper (and any command that leaves
// orphans) would accumulate zombies under the idle `sleep infinity`.
"--init".into(),
"--name".into(),
name.clone(),
"--workdir".into(),
fs.container_home.to_string_lossy().into_owned(),
];
// Run as the host uid:gid for bind-mount ownership coherence (§6). HOME is set
// explicitly because the passwd entry that resolves this uid is injected only
// *after* create (see below), so Docker would otherwise default HOME to "/".
if let Some(user) = &want_user {
args.push("--user".into());
args.push(user.clone());
args.push("-e".into());
args.push(format!("HOME={}", fs.container_home.to_string_lossy()));
}
for (host, container, writable) in fs.mounts() {
let mut spec = format!("{}:{}", host.display(), container.display());
if !writable {
@@ -204,6 +247,14 @@ impl ContainerManager {
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
docker(&argv).await.context("docker create failed")?;
docker(&["start", name]).await.context("docker start failed")?;
// Give the non-root container user a passwd/group entry so `sudo` (NOPASSWD,
// baked into the image) can resolve it. Persists in the container's writable
// layer for its lifetime; re-done on recreate. Best-effort.
if let Some((uid, gid)) = host_uid_gid() {
ensure_container_user(name, uid, gid).await;
}
tracing::info!(user = %user_id, container = %name, "user container created and started");
Ok(())
}
@@ -279,6 +330,44 @@ async fn container_state(name: &str) -> ContainerState {
}
}
/// Reads a container's configured `--user` (`docker inspect .Config.User`). Empty for a
/// container created without `--user` (i.e. root).
async fn container_user(name: &str) -> String {
docker(&["inspect", "-f", "{{.Config.User}}", name])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Whether a container's `--user` matches what we want. `want == None` (non-unix, no
/// `--user` requested) matches anything so we never churn a container needlessly.
async fn user_matches(name: &str, want: &Option<String>) -> bool {
match want {
None => true,
Some(w) => &container_user(name).await == w,
}
}
/// Gives the container's runtime `uid`/`gid` a passwd + shadow (+ group) entry, so
/// tools that resolve the invoking user work despite the arbitrary numeric uid — and
/// so `sudo` succeeds (without a shadow entry PAM's account phase fails with "account
/// validation failure" even under NOPASSWD). The shadow password is `*` (login
/// disabled, account valid); the group is added only when its gid is otherwise unused.
/// Runs as root inside the container (`-u 0`, which overrides the container's `--user`),
/// idempotent (keyed on the passwd entry), best-effort.
async fn ensure_container_user(name: &str, uid: u32, gid: u32) {
let script = format!(
"if ! getent passwd {uid} >/dev/null 2>&1; then \
getent group {gid} >/dev/null 2>&1 || echo 'skald:x:{gid}:' >> /etc/group; \
echo 'skald:x:{uid}:{gid}:skald:/root:/bin/sh' >> /etc/passwd; \
echo 'skald:*:19000:0:99999:7:::' >> /etc/shadow; \
fi"
);
if let Err(e) = docker(&["exec", "-u", "0", name, "sh", "-c", &script]).await {
tracing::warn!(container = %name, error = %e, "failed to inject container passwd entry (sudo may not resolve the user)");
}
}
/// Runs `docker <args>`, returning trimmed stdout on success or an error carrying
/// stderr. `stdin` is closed so a build never blocks waiting for input.
async fn docker(args: &[&str]) -> Result<String> {
+54 -18
View File
@@ -1,34 +1,70 @@
use crate::db::projects::Project;
use crate::run_context::RunContext;
/// Builds the runtime `RunContext` for working on `project`, layering the project's
/// working directory + a context header over an optional pre-resolved `base` RC (which
/// carries static config set at creation time, e.g. `security_group`).
/// A project member's display info for the system-prompt block.
///
/// `working_directory` is the **agent path** `projects/{owner_username}/{slug}` — the
/// same namespace the fs-tools and `execute_cmd` route through (the host/container
/// mapping is handled by `UserFs`). Writes there are auto-allowed by the seeded
/// `projects/*` approval rule and physically gated by the per-member read-only mount,
/// so no host-path `allow_fs_writes` grant is needed (that was the old single-user
/// model, which predated per-user containers).
pub fn build_runtime_run_context(
/// The display name is what the user usually goes by (fallback to the username);
/// the username is the unique handle. Both are shown so the agent can refer to a
/// member either way the user does in conversation.
pub struct ProjectMemberView {
pub display_name: String,
pub username: String,
}
/// Builds the runtime `RunContext` for working on `project`, layering a project
/// context block over an optional pre-resolved `base` RC (which carries static
/// config set at creation time, e.g. `security_group`).
///
/// The session working directory is **always** the user's home (`~`); project
/// files are referenced by their absolute agent path `projects/{owner}/{slug}`,
/// which `UserFs` routes to the per-member bind mount. This keeps the working
/// directory stable across sessions (so MCP servers running in the container see
/// a consistent cwd) and avoids silent path rewriting inside tool calls.
///
/// Writes under `projects/*` are auto-allowed by the seeded approval rule and
/// physically gated by the per-member read-only mount, so no host-path
/// `allow_fs_writes` grant is needed.
pub fn build_project_run_context(
project: &Project,
owner_username: &str,
members: &[ProjectMemberView],
base: Option<RunContext>,
) -> RunContext {
let mut rc = base.unwrap_or_default();
rc.working_directory = Some(format!("projects/{owner_username}/{}", project.slug));
let project_path = format!("projects/{owner_username}/{}", project.slug);
rc.project_root = Some(project_path.clone());
let project_header = if project.description.is_empty() {
format!("You are working on project \"{}\".", project.name)
let mut block = vec![
format!("You are working on project \"{}\".", project.name),
format!("Project folder: {project_path}"),
];
if !project.description.is_empty() {
block.insert(1, format!("Description: {}", project.description));
}
// Sharing line: list members other than the owner, or note the project is private.
// The owner is implicit (they are the user the agent is talking to), so they are
// excluded from the list. Display name first, username in parentheses.
let others: Vec<String> = members
.iter()
.filter(|m| m.username != owner_username)
.map(|m| {
if m.display_name.is_empty() || m.display_name == m.username {
m.username.clone()
} else {
format!("{} ({})", m.display_name, m.username)
}
})
.collect();
let sharing = if others.is_empty() {
"Shared with: not shared with anyone yet.".to_string()
} else {
format!(
"You are working on project \"{}\". Description: {}",
project.name, project.description
)
format!("Shared with: {}.", others.join(", "))
};
let mut injected = vec![project_header];
block.push(sharing);
// Prepend the project block to any existing system_prompt fragments.
let mut injected = block;
injected.extend(std::mem::take(&mut rc.system_prompt));
rc.system_prompt = injected;
+20 -71
View File
@@ -1,4 +1,3 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Result, bail};
@@ -21,9 +20,13 @@ pub struct RunContext {
/// `docs/`, `skills/`, and everything in `allow_fs_writes`, which is readable too).
#[serde(default)]
pub allow_fs_reads: Vec<String>,
/// Working directory for tool calls. None means Skald's own process cwd.
/// Project root (agent path `projects/{owner}/{slug}`) when this is a project
/// session, `None` otherwise. The session working directory is always the user's
/// home (`~`); the agent references project files via this absolute agent path,
/// which `UserFs` routes to the per-member bind mount. Used to resolve
/// `__PROJECT_ROOT__` placeholders in an agent's `inject_memory` paths.
#[serde(default)]
pub working_directory: Option<String>,
pub project_root: Option<String>,
}
impl RunContext {
@@ -51,24 +54,14 @@ impl RunContext {
Some(self.system_prompt.join("\n\n"))
}
/// Effective working directory for this session.
/// Returns the configured path if set and non-empty, otherwise Skald's process cwd.
pub fn effective_working_dir(&self) -> PathBuf {
self.working_directory
.as_deref()
.filter(|d| !d.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
/// True if writing to `path` is pre-authorized by this RunContext.
/// Entries in `allow_fs_writes` are resolved against `effective_working_dir`,
/// so relative entries like `"data"` are treated as relative to the session WD.
/// Entries in `allow_fs_writes` are resolved against Skald's process cwd,
/// so relative entries like `"data"` are treated as relative to the process cwd.
/// Paths are canonicalized first (resolving `..`/symlinks), then matched as
/// exact file OR recursive directory prefix.
pub fn is_write_allowed(&self, path: &str) -> bool {
if self.allow_fs_writes.is_empty() { return false; }
let wd = self.effective_working_dir();
let wd = std::env::current_dir().unwrap_or_default();
let canon = canonicalize_for_policy(path, &wd);
self.allow_fs_writes.iter().any(|entry| {
path_under(&canon, &canonicalize_for_policy(entry, &wd))
@@ -76,20 +69,20 @@ impl RunContext {
}
/// True if reading `path` is pre-authorized by this RunContext.
/// Read access is granted (no approval prompt) for: the working directory itself,
/// its `docs/` and `skills/` subtrees (always-safe baseline), any `allow_fs_reads`
/// entry, and anything writable (write implies read). All paths are canonicalized
/// first so `..`/symlink escapes cannot widen the grant.
/// Read access is granted (no approval prompt) for: the process working directory
/// itself, its `docs/` and `skills/` subtrees (always-safe baseline), any
/// `allow_fs_reads` entry, and anything writable (write implies read). All paths
/// are canonicalized first so `..`/symlink escapes cannot widen the grant.
///
/// Note: this only relaxes a `Require` decision to `Allow` — an explicit `Deny`
/// rule (e.g. on `secrets/`) still wins, because the approval engine is consulted
/// first and `Deny` is never overridden by this fast-path.
pub fn is_read_allowed(&self, path: &str) -> bool {
let wd = self.effective_working_dir();
let wd = std::env::current_dir().unwrap_or_default();
let canon = canonicalize_for_policy(path, &wd);
let mut roots: Vec<std::path::PathBuf> = vec![
canonicalize_for_policy(".", &wd), // working directory itself
canonicalize_for_policy(".", &wd), // process working directory
canonicalize_for_policy("docs", &wd),
canonicalize_for_policy("skills", &wd),
];
@@ -116,7 +109,7 @@ pub enum RunContextDecision {
/// role's effective set ([`crate::db::roles::role_allows_group`]); anything else
/// is [`RunContextDecision::Forbidden`].
/// - **fs escalation**: for a non-admin every other `RunContext` field
/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is
/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `project_root`) is
/// **discarded** — the client can set the permission group, nothing more. A rich
/// run-context (a project's) is resolved server-side, never through this path.
///
@@ -303,57 +296,14 @@ mod tests {
dir
}
fn rc_with_wd(wd: &PathBuf) -> RunContext {
RunContext {
working_directory: Some(wd.to_string_lossy().into_owned()),
..Default::default()
}
}
#[test]
fn read_allows_working_dir_docs_skills() {
let wd = unique_tmp();
for sub in ["docs", "skills", "sub", "secrets"] {
std::fs::create_dir_all(wd.join(sub)).unwrap();
std::fs::write(wd.join(sub).join("f.txt"), "x").unwrap();
}
std::fs::write(wd.join("root.txt"), "x").unwrap();
let rc = rc_with_wd(&wd);
assert!(rc.is_read_allowed("root.txt"));
assert!(rc.is_read_allowed("docs/f.txt"));
assert!(rc.is_read_allowed("skills/f.txt"));
assert!(rc.is_read_allowed("sub/f.txt"));
// secrets/ is under the WD, so the fast-path allows it — the `secrets/` *deny rule*
// (consulted before this fast-path in the gate) is what actually blocks it.
assert!(rc.is_read_allowed("secrets/f.txt"));
std::fs::remove_dir_all(&wd).ok();
}
#[test]
fn read_denies_outside_working_dir() {
let wd = unique_tmp();
let outside = unique_tmp(); // sibling temp dir, not under wd
std::fs::write(outside.join("f.txt"), "x").unwrap();
let rc = rc_with_wd(&wd);
assert!(!rc.is_read_allowed(outside.join("f.txt").to_str().unwrap()));
std::fs::remove_dir_all(&wd).ok();
std::fs::remove_dir_all(&outside).ok();
}
#[test]
fn read_allows_write_paths_and_extra_reads() {
let wd = unique_tmp();
let writable = unique_tmp();
let readable = unique_tmp();
std::fs::write(writable.join("w.txt"), "x").unwrap();
std::fs::write(readable.join("r.txt"), "x").unwrap();
let rc = RunContext {
working_directory: Some(wd.to_string_lossy().into_owned()),
allow_fs_writes: vec![writable.to_string_lossy().into_owned()],
allow_fs_reads: vec![readable.to_string_lossy().into_owned()],
..Default::default()
@@ -365,7 +315,6 @@ mod tests {
assert!(rc.is_read_allowed(readable.join("r.txt").to_str().unwrap()));
assert!(!rc.is_write_allowed(readable.join("r.txt").to_str().unwrap()));
std::fs::remove_dir_all(&wd).ok();
std::fs::remove_dir_all(&writable).ok();
std::fs::remove_dir_all(&readable).ok();
}
@@ -408,15 +357,15 @@ mod tests {
std::fs::create_dir_all(wd.join("data")).unwrap();
std::fs::create_dir_all(wd.join("secrets")).unwrap();
let data_dir = wd.join("data").to_string_lossy().into_owned();
let rc = RunContext {
working_directory: Some(wd.to_string_lossy().into_owned()),
allow_fs_writes: vec!["data".to_string()],
allow_fs_writes: vec![data_dir],
..Default::default()
};
// Writing into data/ is allowed...
assert!(rc.is_write_allowed("data/new.txt"));
assert!(rc.is_write_allowed(wd.join("data").join("new.txt").to_str().unwrap()));
// ...but data/../secrets/x escapes the grant and must NOT be allowed.
assert!(!rc.is_write_allowed("data/../secrets/x.txt"));
assert!(!rc.is_write_allowed(wd.join("data").join("..").join("secrets").join("x.txt").to_str().unwrap()));
std::fs::remove_dir_all(&wd).ok();
}
@@ -1,9 +1,10 @@
//! Working-directory argument rewriting and the per-tool-call dispatch router.
//! Per-tool-call dispatch router.
//!
//! Extracted from `run_agent_turn`: `effective_args` applies the RunContext working
//! directory to a call's arguments, and `execute_tool_call` routes an approved call
//! to the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path).
//! Extracted from `run_agent_turn`: `execute_tool_call` routes an approved call to
//! the right executor (special non-cancellable paths + the unified cancellable
//! `ToolExecution` path). The session working directory is always the user's home
//! (`~`); tool calls receive their arguments unchanged, and the agent references
//! project files via the absolute agent path `projects/{owner}/{slug}/…`.
use serde_json::Value;
use tokio::sync::mpsc;
@@ -39,28 +40,6 @@ pub(super) enum DispatchResult {
}
impl ChatSessionHandler {
/// Applies the RunContext working directory to a tool call's arguments:
/// resolves a relative `path` against the effective WD and injects `workdir`
/// for `execute_cmd`. The caller keeps the original `arguments` for the
/// `ToolStart` event / DB logging; this returns the copy used for execution.
pub(super) async fn effective_args(&self, tool_name: &str, args: &Value) -> Value {
let mut effective = args.clone();
let wd = self.run_context.read().await
.as_ref()
.map(|rc| rc.effective_working_dir());
if let Some(wd) = wd {
if let Some(path) = effective["path"].as_str()
&& !std::path::Path::new(path).is_absolute()
{
effective["path"] = Value::String(wd.join(path).to_string_lossy().into_owned());
}
if tool_name == tn::EXECUTE_CMD && effective.get("workdir").is_none() {
effective["workdir"] = Value::String(wd.to_string_lossy().into_owned());
}
}
effective
}
/// Routes one already-approved tool call to the right executor. Covers the
/// special, non-cancellable paths (sub-agent, scratchpad, todos, clarification,
/// the `task_completed` stub) and the unified cancellable `ToolExecution` path
@@ -31,9 +31,9 @@ enum CallFlow {
/// Outcome of gating + dispatching one call inside a concurrent sub-agent batch,
/// carried from the concurrent phase to the ordered recording phase.
enum GatedExec {
/// Gate passed; the sub-agent produced an outcome to record. `effective` is the
/// working-dir-resolved args used for recording (FileChanged / logging).
Done { effective: serde_json::Value, outcome: ExecutionOutcome },
/// Gate passed; the sub-agent produced an outcome to record. `arguments` is
/// the call's args (used for FileChanged / logging).
Done { arguments: serde_json::Value, outcome: ExecutionOutcome },
/// Approval gate rejected the call — already marked/emitted by the gate; skip it.
Rejected,
/// The turn must end now: the clarification WS channel closed (dispatch returned
@@ -234,12 +234,12 @@ impl ChatSessionHandler {
self.tools.target_path(&call.name, &call.arguments),
).await;
// Resolve relative paths / inject workdir from the RunContext.
// `call.arguments` (originals) were used for the ToolStart event and DB
// logging above; `effective_args` is used from here on.
let effective_args = self.effective_args(&call.name, &call.arguments).await;
// Tool calls receive their arguments unchanged — the session working
// directory is always the user's home (`~`), and the agent references
// project files via their absolute agent path. `call.arguments` is both
// logged and executed.
match self.run_approval_gate(tool_call_id, &call.name, &effective_args, &config.agent_id, em).await? {
match self.run_approval_gate(tool_call_id, &call.name, &call.arguments, &config.agent_id, em).await? {
GateOutcome::Proceed => {}
GateOutcome::Rejected => return Ok(CallFlow::Continue),
GateOutcome::ChannelClosed => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
@@ -263,14 +263,14 @@ impl ChatSessionHandler {
// clarification WS channel closed — end the turn and leave the tool
// `pending` for resume to re-ask.
let outcome = match self.execute_tool_call(
stack_id, config, tool_call_id, &call.name, &effective_args, token, tx,
stack_id, config, tool_call_id, &call.name, &call.arguments, token, tx,
).await {
DispatchResult::Outcome(o) => o,
DispatchResult::AbortPending => return Ok(CallFlow::End(TurnOutcome::Cancelled)),
};
match self.record_tool_outcome(
tool_call_id, &call.name, &effective_args, outcome, em, Some(all_tool_calls),
tool_call_id, &call.name, &call.arguments, outcome, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => Ok(CallFlow::Continue),
RecordFlow::Abort => Ok(CallFlow::End(TurnOutcome::Cancelled)),
@@ -340,14 +340,13 @@ impl ChatSessionHandler {
{
let mut stream = stream::iter(jobs)
.map(|(idx, tool_call_id, name, arguments)| async move {
let effective = self.effective_args(&name, &arguments).await;
let gated = match self.run_approval_gate(
tool_call_id, &name, &effective, &config.agent_id, em,
tool_call_id, &name, &arguments, &config.agent_id, em,
).await {
Ok(GateOutcome::Proceed) => match self.execute_tool_call(
stack_id, config, tool_call_id, &name, &effective, token, tx,
stack_id, config, tool_call_id, &name, &arguments, token, tx,
).await {
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { effective, outcome }),
DispatchResult::Outcome(outcome) => Ok(GatedExec::Done { arguments, outcome }),
DispatchResult::AbortPending => Ok(GatedExec::AbortTurn),
},
Ok(GateOutcome::Rejected) => Ok(GatedExec::Rejected),
@@ -370,9 +369,9 @@ impl ChatSessionHandler {
// The gate already marked the row rejected and emitted the event.
GatedExec::Rejected => {}
GatedExec::AbortTurn => abort = true,
GatedExec::Done { effective, outcome } => {
GatedExec::Done { arguments, outcome } => {
match self.record_tool_outcome(
*tool_call_id, &call.name, &effective, outcome, em, Some(all_tool_calls),
*tool_call_id, &call.name, &arguments, outcome, em, Some(all_tool_calls),
).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => abort = true,
@@ -47,9 +47,11 @@ pub struct MessageBuilder {
pub max_history_messages: usize,
pub max_tool_result_chars: Option<usize>,
pub compactor: Option<Arc<ContextCompactor>>,
/// Effective working directory for this session. When set (e.g. from a project
/// RunContext), it overrides the process cwd in the date/time/OS/WD tail block.
pub working_directory: Option<std::path::PathBuf>,
/// Project root (agent path `projects/{owner}/{slug}`) when this is a project
/// session — used to resolve `__PROJECT_ROOT__` placeholders in `inject_memory`
/// paths. `None` for non-project sessions, in which case an `inject_memory`
/// entry that references `__PROJECT_ROOT__` is skipped (with a warning).
pub project_root: Option<String>,
}
impl MessageBuilder {
@@ -117,9 +119,7 @@ impl MessageBuilder {
// ── Skills index ──────────────────────────────────────────────────────
// Injected for every agent unless it opts out (`inject_skills: false`).
// Reuses the memory-path resolution so the shown path is relative when the
// index is under the session WD, absolute otherwise (it lives under Skald's
// own cwd, so it shows as absolute inside project sessions). Skipped silently
// Reuses the memory-path resolver for display consistency. Skipped silently
// when no skills are installed.
if meta.inject_skills {
let (abs, display) = self.resolve_memory_path(SKILLS_INDEX_PATH);
@@ -398,14 +398,11 @@ impl MessageBuilder {
None => format!("Current date and time: {formatted}"),
};
let cwd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
.display()
.to_string();
let cwd = "~";
Some(format!(
"{date_line}\nOperating system: {}\nWorking directory: {cwd}\n\
Filesystem tools and execute_cmd use this working directory for relative paths — \
no need to `cd` into it first.",
Filesystem tools and execute_cmd resolve relative paths against your home directory.",
os_description()
))
} else {
@@ -455,17 +452,18 @@ impl MessageBuilder {
/// Builds the MCP list section that replaces the `__MCP_LIST__` sentinel.
/// Resolves an `inject_memory` entry to `(absolute path to read, path to show)`.
///
/// `$WD` expands to the session's effective working directory (RunContext WD, or the
/// process cwd when unset). The shown path is **relative to that working directory
/// when the file lives under it, absolute otherwise** — so when the agent references
/// it back via `edit_file`/`write_file`, the loop's working-directory injection
/// (which rewrites relative paths against the WD) resolves to the very same file.
/// `__PROJECT_ROOT__` expands to the session's project root (the agent path
/// `projects/{owner}/{slug}`, set on the RunContext for project sessions) —
/// e.g. `"__PROJECT_ROOT__/SKALD.md"` loads a project-local diary. The shown
/// path is the agent path itself, which the loop's filesystem routing
/// resolves back to the same file when the agent references it via
/// `edit_file`/`write_file`.
/// Loads an `inject_memory` entry, returning `(content, display_path)`.
///
/// Virtual memory paths are read from SQLite: `user-memory/…` from the owner
/// `pool`, `shared-memory/…` from the `shared_pool` (`system.db`). Everything
/// else (`data/…`, `$WD/…`) is an ordinary disk read. A missing note / file
/// yields `None`, rendered as "(file not created yet)".
/// else (`data/…`, `__PROJECT_ROOT__/…`, an absolute path) is an ordinary disk
/// read. A missing note / file yields `None`, rendered as "(file not created yet)".
async fn load_inject_memory(&self, mem_path: &str) -> (Option<String>, String) {
use crate::tools::fs::{classify_memory, MemScope};
if let Some(m) = classify_memory(mem_path) {
@@ -482,15 +480,22 @@ impl MessageBuilder {
}
fn resolve_memory_path(&self, mem_path: &str) -> (std::path::PathBuf, String) {
let wd = self.working_directory.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let expanded = mem_path.replace("$WD", &wd.display().to_string());
let abs = crate::tools::fs::resolve(&expanded)
.unwrap_or_else(|_| std::path::PathBuf::from(&expanded));
let display = match abs.strip_prefix(&wd) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => abs.to_string_lossy().into_owned(),
let display = if mem_path.contains("__PROJECT_ROOT__") {
match &self.project_root {
Some(root) => mem_path.replace("__PROJECT_ROOT__", root),
None => {
tracing::warn!(
mem_path,
"inject_memory entry references __PROJECT_ROOT__ but this session has no project root; skipping"
);
return (std::path::PathBuf::from(mem_path), mem_path.to_string());
}
}
} else {
mem_path.to_string()
};
let abs = crate::tools::fs::resolve(&display)
.unwrap_or_else(|_| std::path::PathBuf::from(&display));
(abs, display)
}
@@ -24,9 +24,9 @@ impl ChatSessionHandler {
cache_hints: bool,
capabilities: &[String],
) -> anyhow::Result<Vec<Value>> {
let effective_wd = self.run_context.read().await
let project_root = self.run_context.read().await
.as_ref()
.map(|rc| rc.effective_working_dir());
.and_then(|rc| rc.project_root.clone());
let builder = MessageBuilder {
pool: Arc::clone(&self.db),
shared_pool: Arc::clone(&self.shared_pool),
@@ -37,7 +37,7 @@ impl ChatSessionHandler {
max_history_messages: self.max_history_messages,
max_tool_result_chars: self.max_tool_result_chars,
compactor: self.compactor.clone(),
working_directory: effective_wd,
project_root,
};
// `pool` is passed in from the caller (always `&self.db`) but we take
// ownership via Arc::clone above so the signature stays backward-compatible.
@@ -326,11 +326,9 @@ impl ChatSessionHandler {
// sub-agent tools (`execute_task` mode=sync, `execute_subtask`,
// `run_subtask`) through the recursive interception in `dispatch.rs`;
// `build_execution` alone does not know them and would fail with
// "Unknown tool: execute_task". Apply the RunContext working dir exactly
// like the live loop.
let effective_args = self.effective_args(&tc.name, &args).await;
// "Unknown tool: execute_task". Args are passed through unchanged.
let outcome = match self.execute_tool_call(
stack_id, config, tc.id, &tc.name, &effective_args, token, tx,
stack_id, config, tc.id, &tc.name, &args, token, tx,
).await {
super::dispatch::DispatchResult::Outcome(o) => o,
// Clarification WS channel closed mid-resume — leave the tool pending
@@ -339,7 +337,7 @@ impl ChatSessionHandler {
};
// resume passes `None`: it does not accumulate ToolCallEvents nor re-emit
// FileChanged (only a live turn does). A /stop mid-resume returns Abort.
match self.record_tool_outcome(tc.id, &tc.name, &effective_args, outcome, &em, None).await? {
match self.record_tool_outcome(tc.id, &tc.name, &args, outcome, &em, None).await? {
RecordFlow::Continue => {}
RecordFlow::Abort => return Ok(true),
}
+107 -19
View File
@@ -24,6 +24,7 @@ impl Tool for ExecuteCmd {
fn description(&self) -> &str {
"Execute a shell command (sh -c) inside your sandbox container (python + node available). \
Reserve this for: builds, installs, git, tests, scripts, processes, network, package managers. \
Runs as a non-root user; prefix system-package or global installs with `sudo` (e.g. `sudo apt-get install …`). \
Do NOT use cat/head/tail to read files — use read_file instead. \
Do NOT use grep/rg/find to search — use grep_files instead. \
Do NOT use ls to list directories — use list_files instead. \
@@ -33,10 +34,6 @@ impl Tool for ExecuteCmd {
}
fn parameters_schema(&self) -> Value {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
json!({
"type": "object",
"properties": {
@@ -46,10 +43,8 @@ impl Tool for ExecuteCmd {
},
"workdir": {
"type": "string",
"description": format!(
"Working directory for the command (absolute path). \
Omit to use the project root (currently: {cwd})."
)
"description": "Working directory for the command (an agent path like `projects/{owner}/{slug}` or `~`). \
Omit to use your home directory (`~`)."
},
"timeout": {
"type": "integer",
@@ -115,29 +110,46 @@ impl Tool for ExecuteCmd {
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS);
// Robust /stop: run the command in its own session/process-group whose
// leader pid is recorded in a container-side pidfile. On /stop (the work
// future dropped) or on a timeout, the `KillReaper` drop-guard reaps that
// group via a second `docker exec … kill` — `kill_on_drop` alone only kills
// the local `docker exec` client, not the tree Docker started *inside* the
// container.
let pidfile = format!("/tmp/skald-exec-{}.pgid", uuid::Uuid::new_v4());
let wrapper = format!("echo $$ > {pidfile}; trap 'rm -f {pidfile}' EXIT; {command}");
Box::new(SimpleExecution::new(Box::pin(async move {
Ok(ToolResult::Text(run_in_container(&container, &workdir, &command, timeout_secs).await?))
let guard = KillReaper::new(container.clone(), pidfile.clone());
let out = run_in_container(&container, &workdir, &wrapper, &command, timeout_secs).await?;
guard.disarm();
Ok(ToolResult::Text(out))
})))
}
}
/// Runs a command inside a user's container: `docker exec -w <wd> <container> sh -c <cmd>`.
/// Shares the capture/timeout machinery with the host path.
/// Runs a wrapped command inside a user's container:
/// `docker exec -w <wd> <container> setsid -w sh -c <script>`. Shares the
/// capture/timeout machinery with the host path; `label` is the original user
/// command, used only for logging and the timeout message.
///
/// ⚠️ Cancellation caveat: dropping the `docker exec` client on /stop kills that
/// client process, but Docker does not guarantee the process it started *inside*
/// the container dies with it. For long-running in-container work a robust stop
/// would track the PID and `docker exec … kill`; that is a follow-up.
/// `setsid -w` runs the command in its own session/process-group and propagates its
/// exit status; the caller's wrapper records the group-leader pid in a pidfile so a
/// [`KillReaper`] can `docker exec … kill` the whole group on /stop or timeout.
/// `kill_on_drop(true)` still tears down the local `docker exec` client at once, but
/// Docker does not propagate that to the in-container tree — which is why the reaper
/// exists.
async fn run_in_container(
container: &str,
workdir: &std::path::Path,
command: &str,
script: &str,
label: &str,
timeout_secs: u64,
) -> Result<String> {
tracing::info!(
container = %container,
workdir = %workdir.display(),
command = %command,
command = %label,
timeout_secs,
"execute_cmd: running command in container"
);
@@ -146,13 +158,89 @@ async fn run_in_container(
cmd.arg("exec")
.arg("-w").arg(workdir)
.arg(container)
.arg("sh").arg("-c").arg(command)
.arg("setsid").arg("-w")
.arg("sh").arg("-c").arg(script)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true);
capture(cmd, timeout_secs, command).await
capture(cmd, timeout_secs, label).await
}
/// Drop-guard that reaps the in-container process group of an `execute_cmd` when the
/// work future is dropped before completing — i.e. on /stop, or after `run_in_container`
/// returns a timeout/spawn error (the `?` early-returns while the guard is still armed).
/// Disarmed on a clean exit, where the group is already gone. Best-effort: `Drop` spawns
/// a detached `docker exec … kill`; if no tokio runtime is current (shutdown) it is skipped.
struct KillReaper {
container: String,
pidfile: String,
armed: bool,
}
impl KillReaper {
fn new(container: String, pidfile: String) -> Self {
Self { container, pidfile, armed: true }
}
/// The command completed on its own — nothing left to reap.
fn disarm(mut self) {
self.armed = false;
}
}
impl Drop for KillReaper {
fn drop(&mut self) {
if !self.armed {
return;
}
let container = self.container.clone();
let pidfile = self.pidfile.clone();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move { reap_container_group(&container, &pidfile).await });
}
}
}
/// Reaper script: kills every process whose process-group equals the leader pid stored
/// in the pidfile (passed as `$1`), TERM then KILL after a grace, then removes the
/// pidfile. It walks `/proc` and signals members by **positive pid** rather than
/// `kill -<pgid>` because the container's `sh` (dash) mishandles a negative pgid
/// argument. The pidfile is a positional arg (`$1`), not string-interpolated, so an
/// arbitrary path is injection-safe and the script needs no brace-escaping. Killed
/// children are reaped by the container's `--init` (tini); without it they would linger
/// as harmless zombies.
const REAP_SCRIPT: &str = r#"
P=$(cat "$1" 2>/dev/null)
if [ -z "$P" ]; then rm -f "$1"; exit 0; fi
kids=""; ldr=""
for d in /proc/[0-9]*; do
pid=$(basename "$d")
st=$(cat "$d/stat" 2>/dev/null) || continue
pg=$(printf "%s" "$st" | sed "s/.*) //" | cut -d" " -f3)
if [ "$pg" = "$P" ]; then
if [ "$pid" = "$P" ]; then ldr=$pid; else kids="$kids $pid"; fi
fi
done
for pid in $kids $ldr; do kill -TERM "$pid" 2>/dev/null; done
sleep 2
for pid in $kids $ldr; do kill -KILL "$pid" 2>/dev/null; done
rm -f "$1"
"#;
/// Kills the process group recorded in `pidfile` inside `container` (see [`REAP_SCRIPT`])
/// and removes the pidfile. Runs as the container's user — the same uid that owns the
/// group — so no privilege is needed. A dead or absent group is a harmless no-op.
async fn reap_container_group(container: &str, pidfile: &str) {
let _ = tokio::process::Command::new("docker")
.arg("exec").arg(container)
.arg("sh").arg("-c").arg(REAP_SCRIPT).arg("skald-reap").arg(pidfile)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
/// Parse + run a shell command from tool arguments, as an awaitable future.
+2 -2
View File
@@ -119,7 +119,7 @@ impl Tool for EditFile {
fn description(&self) -> &str {
"Replace a substring in a file with new text. \
Use instead of sed/awk in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Relative paths are resolved from your home directory (`~`); absolute paths (starting with /) are used as-is. \
By default `old` must be unique — include enough surrounding context to make it so. \
Always call read_file first and copy text exactly as shown after '| ' (the ' N | ' prefix is NOT part of the file). \
Set replace_all=true to replace every occurrence instead of requiring uniqueness."
@@ -129,7 +129,7 @@ impl Tool for EditFile {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"path": { "type": "string", "description": "File path. Relative to `~` (your home), or absolute." },
"old": { "type": "string", "description": "Text to find and replace. Must be unique in the file unless replace_all=true." },
"new": { "type": "string", "description": "Replacement text. Pass empty string to delete the matched text." },
"replace_all": {
@@ -52,14 +52,14 @@ impl Tool for InsertAtLine {
fn description(&self) -> &str {
"Insert new text immediately before or after a specific line number in a file. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is."
Relative paths are resolved from your home directory (`~`); absolute paths (starting with /) are used as-is."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"path": { "type": "string", "description": "File path. Relative to `~` (your home), or absolute." },
"line": { "type": "integer", "minimum": 1, "description": "1-based line number." },
"content": { "type": "string", "description": "Text to insert. May span multiple lines." },
"placement": {
+2 -2
View File
@@ -30,7 +30,7 @@ impl Tool for ListFiles {
fn description(&self) -> &str {
"List files and directories under a path. \
Use instead of ls/find in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Relative paths are resolved from your home directory (`~`); absolute paths (starting with /) are used as-is. \
Skips .git, target, node_modules, .cache. \
Returns a JSON array of paths relative to the requested directory. \
Use depth=1 for immediate contents only, depth=2-3 for moderate exploration. \
@@ -43,7 +43,7 @@ impl Tool for ListFiles {
"properties": {
"path": {
"type": "string",
"description": "Directory to list. Defaults to project root if omitted."
"description": "Directory to list. Defaults to `~` (your home) if omitted."
},
"depth": {
"type": "integer",
+1 -1
View File
@@ -66,7 +66,7 @@ impl Tool for ReadFile {
"properties": {
"path": {
"type": "string",
"description": "File path. Relative to project root, or absolute (e.g. /etc/hosts)."
"description": "File path. Relative to `~` (your home), or absolute (e.g. /etc/hosts)."
},
"start_line": {
"type": "integer",
@@ -59,7 +59,7 @@ impl Tool for ReplaceLines {
fn description(&self) -> &str {
"Replace a range of lines in a file with new text. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Relative paths are resolved from your home directory (`~`); absolute paths (starting with /) are used as-is. \
Use the 1-based line numbers shown by read_file. `from_line` and `to_line` are inclusive."
}
@@ -67,7 +67,7 @@ impl Tool for ReplaceLines {
json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path. Relative to project root, or absolute." },
"path": { "type": "string", "description": "File path. Relative to `~` (your home), or absolute." },
"from_line": { "type": "integer", "description": "First line to replace (1-based, inclusive)." },
"to_line": { "type": "integer", "description": "Last line to replace (1-based, inclusive)." },
"new": { "type": "string", "description": "Replacement text." }
+2 -2
View File
@@ -26,7 +26,7 @@ impl Tool for WriteFile {
fn description(&self) -> &str {
"Create a new file or fully overwrite an existing one. \
Use instead of echo/cat heredoc in the terminal. \
Relative paths are resolved from the project root; absolute paths (starting with /) are used as-is. \
Relative paths are resolved from your home directory (`~`); absolute paths (starting with /) are used as-is. \
OVERWRITES the entire file — for targeted edits to an existing file use edit_file instead. \
Write Markdown under user-memory/ (private to you) or shared-memory/ (shared with everyone) to save a durable note in your memory instead of on disk."
}
@@ -37,7 +37,7 @@ impl Tool for WriteFile {
"properties": {
"path": {
"type": "string",
"description": "File path. Relative to project root, or absolute."
"description": "File path. Relative to `~` (your home), or absolute."
},
"content": {
"type": "string",