run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s
Nightly Build / build (push) Successful in 6m31s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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." }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user