feat(skills): rebuild the skill system for the multi-user model
Nightly Build / build (push) Successful in 8m6s
Nightly Build / build (push) Successful in 8m6s
Per blueprint/skill-project.md: the old single-namespace, hand-maintained
index is gone, replaced by a read-only, two-scope tree whose index is a
runtime function of its content.
- skills/ index generated at runtime (crates/skald-core/src/skills/:
inventory, install, validate, watch), injected through the new
<!-- SKILLS_LIST --> placeholder in AGENT.md (agents/common/skills.md);
meta.json inject_skills flag removed. 11 chat/task agents carry the
include, the 4 system agents do not.
- Two trees, both read-only in both directions: skills/shared/{id} (the
group's) and skills/{username}/{id} (one member's own, on the stable
userid). The root is closed too: UserFs::SkillMounts + RouteError (alias
probe, plain-denied paths, no home fallback) and a per-user
.skills-root/{userid} container mount with the two scope mounts nested
inside, plus the fifth self-heal axis (skills_mounted).
- Agent verbs: skill_register/skill_delete (Config group, global scope
behind the new skill.manage capability), fetch_repo for public repos,
list_items(type="skills"); reads are plain read_file on the printed
path. Seeded @fs_read skills/* allow.
- Freshness: a digest-gated watcher on the two trees emits
SystemEvent::SkillsChanged, whose subscriber rebuilds the frozen prompt
prefix via Skald::invalidate_prompt_prefix; in-process writers invalidate
directly.
- The build ships no skills: the three bundled skills (ics2json,
mcp-builder, skill-creator) and skills/index.md are removed, skills/ is
instance data (gitignored, not packaged, no longer pruned by update.sh).
- Docs: skills.md, agents.md, shared-folders.md added; docs/index.md and
agents/README.md updated.
This commit is contained in:
@@ -105,6 +105,21 @@ pub enum SystemEvent {
|
||||
catalog_name: String,
|
||||
},
|
||||
|
||||
// ── Skills (blueprint skill-project §8) ───────────────────────────────────
|
||||
/// A skills tree changed on disk **in a way the index feels** — a skill was
|
||||
/// added, removed or re-described by someone editing files by hand on the
|
||||
/// box. Emitted by the freshness watcher after its digest gate: a change
|
||||
/// that leaves the index byte-identical (a script, a reference document)
|
||||
/// announces nothing, because the frozen system prefix citing that skill
|
||||
/// has not aged. The in-process writers (`skill_register`/`skill_delete`)
|
||||
/// never emit this — they invalidate directly.
|
||||
///
|
||||
/// Pure reconciliation, the contract this bus already promises: a lost
|
||||
/// event costs a stale skill index for the prefix TTL, never a wrong one.
|
||||
SkillsChanged {
|
||||
scope: SkillScope,
|
||||
},
|
||||
|
||||
// ── Reports (blueprint §13) ───────────────────────────────────────────────
|
||||
/// A background agent filed a report. Announced by whoever wrote the row,
|
||||
/// never delivered by it: *who* should hear about a report — the people
|
||||
@@ -125,6 +140,19 @@ pub enum SystemEvent {
|
||||
|
||||
// ── Bus ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which skills tree a [`SystemEvent::SkillsChanged`] is about.
|
||||
///
|
||||
/// Distinct from the `"mine" | "global"` vocabulary of the skill tools: this
|
||||
/// names a *place on disk*, and a change to the group's tree concerns every
|
||||
/// member's prompt while a change to one member's tree concerns only theirs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SkillScope {
|
||||
/// `{WD}/skills` — the group's tree, in every member's index.
|
||||
Global,
|
||||
/// `{WD}/skills-users/{userid}` — one member's own tree.
|
||||
User(String),
|
||||
}
|
||||
|
||||
pub struct SystemEventBus {
|
||||
tx: broadcast::Sender<SystemEvent>,
|
||||
}
|
||||
|
||||
+365
-19
@@ -10,6 +10,7 @@
|
||||
//! | `shared/{X}/…` | host `{WD}/shared/{X}`, mount `{home}/shared/{X}` |
|
||||
//! | `projects/{O}/{S}`| host `{WD}/projects/{owner_userid}/{S}`, mount `{home}/projects/{O}/{S}` (O = owner username) |
|
||||
//! | `~/docs/…`, `docs/…` | host `{WD}/docs` (read-only, same for every user), mount `{container_home}/docs` |
|
||||
//! | `skills/…` | the read-only skills tree — see [`SkillMounts`] |
|
||||
//! | `~/…`, relative | host `{WD}/homes/{userid}`, mount `{container_home}`|
|
||||
//!
|
||||
//! `UserFs` is a **pure value type** with no filesystem access: it carries the
|
||||
@@ -28,6 +29,15 @@ use std::sync::{Arc, RwLock};
|
||||
/// root) so the two anchors can never drift.
|
||||
pub const UPLOADS_SUBDIR: &str = "uploads";
|
||||
|
||||
/// The single top-level agent path under which every skill lives. Reserved: a
|
||||
/// path starting with this segment never falls back to the home, whatever
|
||||
/// follows it (see [`UserFs::host_base_and_tail`]).
|
||||
pub const SKILLS_ROOT: &str = "skills";
|
||||
|
||||
/// The scope segment of the group-wide skills, `skills/shared/<id>`. The other
|
||||
/// scope segment is the owner's own username, which is data, not a constant.
|
||||
pub const SKILLS_SHARED_SCOPE: &str = "shared";
|
||||
|
||||
/// One shared folder mounted into a user's container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedMount {
|
||||
@@ -60,6 +70,86 @@ pub struct ProjectMount {
|
||||
pub can_write: bool,
|
||||
}
|
||||
|
||||
/// The skills tree of one user: a single agent root, `skills/`, with two scope
|
||||
/// subtrees below it — `skills/shared/<id>` (the group's, curated) and
|
||||
/// `skills/<username>/<id>` (this member's own). The agent path carries the
|
||||
/// **username** while the host path keys on the stable **userid**, exactly as
|
||||
/// `projects/{owner_username}/{slug}` already does.
|
||||
///
|
||||
/// **Everything here is read-only for the agent, in both directions**: `:ro` bind
|
||||
/// mounts in the container and [`UserFs::can_write_to`] false host-side. These are
|
||||
/// not working folders — they hold installed artefacts, and the only door in is the
|
||||
/// registration tool.
|
||||
///
|
||||
/// The three host paths are one field rather than three `Option`s because they
|
||||
/// cannot exist apart. Docker refuses to create a mountpoint inside a `:ro` bind
|
||||
/// mount (`mkdirat … read-only file system`, at container create), so the two scope
|
||||
/// mounts nest inside the root mount only if `shared/` and `<username>/` already
|
||||
/// exist **in the root mount's own source directory**. That forces the root to be
|
||||
/// per-user (the username segment differs) and forces it to be materialized
|
||||
/// together with the scopes it carries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SkillMounts {
|
||||
/// Host dir mounted at `{container_home}/skills` (`{WD}/.skills-root/{userid}`).
|
||||
/// Holds the signpost README plus the two empty scope mountpoints, and nothing
|
||||
/// else: its job is to make the space *between* the scopes read-only too, so an
|
||||
/// invented scope segment fails loudly instead of landing somewhere unread.
|
||||
pub root_host: PathBuf,
|
||||
/// Host dir behind `skills/shared/…` (`{WD}/skills`), the same for every user.
|
||||
pub shared_host: PathBuf,
|
||||
/// Host dir behind `skills/{own_username}/…` (`{WD}/skills-users/{userid}`).
|
||||
pub own_host: PathBuf,
|
||||
/// The owner's username — the agent-visible segment of their own scope.
|
||||
pub own_username: String,
|
||||
}
|
||||
|
||||
impl SkillMounts {
|
||||
/// The container path of the root mount, given the home mount point.
|
||||
pub fn container_root(&self, container_home: &Path) -> PathBuf {
|
||||
container_home.join(SKILLS_ROOT)
|
||||
}
|
||||
|
||||
/// The container paths of the two scope mounts, which nest inside the root.
|
||||
pub fn container_scopes(&self, container_home: &Path) -> [PathBuf; 2] {
|
||||
let root = self.container_root(container_home);
|
||||
[root.join(SKILLS_SHARED_SCOPE), root.join(&self.own_username)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an agent path does not resolve to a host location.
|
||||
///
|
||||
/// This exists because the wrong doors under `skills/` each need to say something
|
||||
/// different, and a bare `None` could only ever produce one sentence. Saying the
|
||||
/// right one matters more here than elsewhere: the whole root is read-only, so a
|
||||
/// model that guesses a scope gets a refusal, and a refusal that does not name the
|
||||
/// right path is answered with `sudo`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RouteError {
|
||||
/// Not reachable, and this is the message to show the model.
|
||||
Denied(String),
|
||||
/// `skills/<id>/<tail>` where `<id>` is neither `shared` nor the owner's
|
||||
/// username — so it may be the tolerant bare-id alias, the shortest spelling
|
||||
/// and therefore the one a model produces on its own.
|
||||
///
|
||||
/// Resolving it means knowing which of the two trees actually holds `<id>`,
|
||||
/// i.e. touching the filesystem, which this pure value type must not do. The
|
||||
/// caller (skald-core's `resolve_host_path`) probes and either resolves it or
|
||||
/// reports — including the ambiguous case, which fails loudly listing both
|
||||
/// full paths rather than letting either tree win in silence.
|
||||
SkillAlias { id: String, tail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RouteError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RouteError::Denied(msg) => f.write_str(msg),
|
||||
RouteError::SkillAlias { id, .. } => {
|
||||
write!(f, "no skill named `{id}`")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The filesystem view of one user: their private home plus the shared folders
|
||||
/// they belong to, and the container those are mounted into.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -79,6 +169,10 @@ pub struct UserFs {
|
||||
/// every user. `None` when unset (inert placeholders, unit tests that don't
|
||||
/// touch it) — `docs/…` then resolves like any other unmounted path.
|
||||
pub docs_host: Option<PathBuf>,
|
||||
/// The read-only skills tree (see [`SkillMounts`]). `None` for the inert
|
||||
/// placeholders and unit tests that don't touch it — `skills/…` is then
|
||||
/// refused outright, never routed to the home.
|
||||
pub skills: Option<SkillMounts>,
|
||||
}
|
||||
|
||||
impl UserFs {
|
||||
@@ -99,9 +193,18 @@ impl UserFs {
|
||||
shared,
|
||||
projects,
|
||||
docs_host,
|
||||
skills: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the skills tree. A builder step rather than an eighth constructor
|
||||
/// argument: only the real per-user build has one, and every inert or test
|
||||
/// `UserFs` is honestly skill-less.
|
||||
pub fn with_skills(mut self, skills: SkillMounts) -> Self {
|
||||
self.skills = Some(skills);
|
||||
self
|
||||
}
|
||||
|
||||
/// Look up a shared mount by its folder name.
|
||||
pub fn shared_mount(&self, name: &str) -> Option<&SharedMount> {
|
||||
self.shared.iter().find(|m| m.name == name)
|
||||
@@ -116,9 +219,17 @@ impl UserFs {
|
||||
|
||||
/// Whether the user may **write** at this agent path: their home → always;
|
||||
/// a shared-folder or project mount → the membership's `can_write` flag;
|
||||
/// `docs/…` → never (read-only). A `shared/`/`projects/` mount the user is
|
||||
/// not a member of → false (fail-closed, same as the read side). Purely
|
||||
/// lexical: memory paths never reach here (classified earlier).
|
||||
/// `docs/…` and **anything under `skills/`** → never (read-only). A
|
||||
/// `shared/`/`projects/` mount the user is not a member of → false
|
||||
/// (fail-closed, same as the read side). Purely lexical: memory paths never
|
||||
/// reach here (classified earlier).
|
||||
///
|
||||
/// The `skills` arm covers the **whole root**, not the two known scopes, and
|
||||
/// that width is the point: the fallthrough below answers `true`, so a scope
|
||||
/// segment the model invented (`skills/pippo/SKILL.md`) would otherwise be
|
||||
/// writable — and would land in a physical directory under the home that no
|
||||
/// indexer ever reads. That is the memory-signpost failure exactly, and it is
|
||||
/// closed here and, for the shell's half, by the root `:ro` mount.
|
||||
pub fn can_write_to(&self, agent_path: &str) -> bool {
|
||||
let stripped = strip_home_prefix(agent_path);
|
||||
let mut parts = stripped.splitn(2, ['/', '\\']);
|
||||
@@ -136,11 +247,19 @@ impl UserFs {
|
||||
self.project_mount(owner, slug).map(|m| m.can_write).unwrap_or(false)
|
||||
}
|
||||
Some("docs") => false,
|
||||
// The entire skills root, `self.skills` set or not: the name is
|
||||
// reserved, so a context without the mounts must refuse rather than
|
||||
// silently offer a home directory of the same name.
|
||||
Some(SKILLS_ROOT) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bind mounts for `docker create`: `(host, container, writable)`, home first.
|
||||
///
|
||||
/// Emitted in **destination-depth order**, which the skills tree is the first to
|
||||
/// actually need: its two scope mounts nest inside its root mount, and the root
|
||||
/// must be in place before them.
|
||||
pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> {
|
||||
let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)];
|
||||
for m in &self.shared {
|
||||
@@ -152,19 +271,25 @@ impl UserFs {
|
||||
if let Some(docs) = &self.docs_host {
|
||||
out.push((docs.clone(), self.container_home.join("docs"), false));
|
||||
}
|
||||
if let Some(sk) = &self.skills {
|
||||
let [shared, own] = sk.container_scopes(&self.container_home);
|
||||
out.push((sk.root_host.clone(), sk.container_root(&self.container_home), false));
|
||||
out.push((sk.shared_host.clone(), shared, false));
|
||||
out.push((sk.own_host.clone(), own, false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The host base a physical agent path resolves against, and the tail relative
|
||||
/// to it — **without** touching the filesystem. `shared/{X}/…` resolves against
|
||||
/// the shared mount's host dir (only if the user is a member); everything else
|
||||
/// resolves against the private home. Returns `None` when the path names a
|
||||
/// `shared/` folder the user does not belong to. The caller (skald-core) then
|
||||
/// joins + canonicalizes + prefix-checks against the returned base.
|
||||
/// the shared mount's host dir (only if the user is a member); `skills/…`
|
||||
/// against the skills tree; everything else against the private home. The
|
||||
/// caller (skald-core) then joins + canonicalizes + prefix-checks against the
|
||||
/// returned base.
|
||||
///
|
||||
/// Memory paths (`user-memory/…`, `shared-memory/…`) must be classified and
|
||||
/// routed to SQLite *before* calling this — they are not physical paths.
|
||||
pub fn host_base_and_tail<'a>(&self, agent_path: &'a str) -> Option<(PathBuf, String)> {
|
||||
pub fn host_base_and_tail(&self, agent_path: &str) -> Result<(PathBuf, String), RouteError> {
|
||||
let stripped = strip_home_prefix(agent_path);
|
||||
let mut parts = stripped.splitn(2, ['/', '\\']);
|
||||
match parts.next() {
|
||||
@@ -173,8 +298,12 @@ impl UserFs {
|
||||
let mut seg = rest.splitn(2, ['/', '\\']);
|
||||
let name = seg.next().unwrap_or("");
|
||||
let tail = seg.next().unwrap_or("");
|
||||
let mount = self.shared_mount(name)?;
|
||||
Some((mount.host.clone(), tail.to_string()))
|
||||
let mount = self.shared_mount(name).ok_or_else(|| {
|
||||
RouteError::Denied(format!(
|
||||
"no such shared folder, or you are not a member: {agent_path}"
|
||||
))
|
||||
})?;
|
||||
Ok((mount.host.clone(), tail.to_string()))
|
||||
}
|
||||
Some("projects") => {
|
||||
// Two segments: `projects/{owner_username}/{slug}/{tail…}`.
|
||||
@@ -183,15 +312,87 @@ impl UserFs {
|
||||
let owner = seg.next().unwrap_or("");
|
||||
let slug = seg.next().unwrap_or("");
|
||||
let tail = seg.next().unwrap_or("");
|
||||
let mount = self.project_mount(owner, slug)?;
|
||||
Some((mount.host.clone(), tail.to_string()))
|
||||
let mount = self.project_mount(owner, slug).ok_or_else(|| {
|
||||
RouteError::Denied(format!(
|
||||
"no such project, or you are not a member: {agent_path}"
|
||||
))
|
||||
})?;
|
||||
Ok((mount.host.clone(), tail.to_string()))
|
||||
}
|
||||
Some("docs") => {
|
||||
let host = self.docs_host.clone()?;
|
||||
let host = self.docs_host.clone().ok_or_else(|| {
|
||||
RouteError::Denied(format!("docs are not available here: {agent_path}"))
|
||||
})?;
|
||||
let tail = parts.next().unwrap_or("");
|
||||
Some((host, tail.to_string()))
|
||||
Ok((host, tail.to_string()))
|
||||
}
|
||||
_ => Some((self.home_host.clone(), stripped.to_string())),
|
||||
Some(SKILLS_ROOT) => self.route_skills(agent_path, parts.next().unwrap_or("")),
|
||||
_ => Ok((self.home_host.clone(), stripped.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes everything under the reserved `skills/` root. Split out because it is
|
||||
/// the one branch that must never fall through to the home: `skills/` names a
|
||||
/// tree the user cannot write to and only partly owns, so the answer to an
|
||||
/// unrecognised second segment is an error — never a home path that quietly
|
||||
/// accepts a write nobody will ever read back.
|
||||
fn route_skills(&self, agent_path: &str, rest: &str) -> Result<(PathBuf, String), RouteError> {
|
||||
let Some(sk) = &self.skills else {
|
||||
return Err(RouteError::Denied(format!(
|
||||
"skills are not available in this context: {agent_path}"
|
||||
)));
|
||||
};
|
||||
let mut seg = rest.splitn(2, ['/', '\\']);
|
||||
let scope = seg.next().unwrap_or("");
|
||||
let tail = seg.next().unwrap_or("");
|
||||
if scope.is_empty() {
|
||||
// `skills` / `skills/` itself: the root mount, which holds the signpost.
|
||||
return Ok((sk.root_host.clone(), String::new()));
|
||||
}
|
||||
if scope == SKILLS_SHARED_SCOPE {
|
||||
return Ok((sk.shared_host.clone(), tail.to_string()));
|
||||
}
|
||||
if scope == sk.own_username {
|
||||
return Ok((sk.own_host.clone(), tail.to_string()));
|
||||
}
|
||||
Err(RouteError::SkillAlias { id: scope.to_string(), tail: tail.to_string() })
|
||||
}
|
||||
|
||||
/// The two scope trees a bare `skills/<id>` alias may resolve in, as
|
||||
/// `(agent path of the candidate, host path to probe)`. Pure: the caller checks
|
||||
/// which of them exist. Ordered shared-then-own only so the ambiguity message
|
||||
/// reads the same every time — neither wins.
|
||||
pub fn skill_alias_candidates(&self, id: &str) -> Vec<(String, PathBuf)> {
|
||||
let Some(sk) = &self.skills else { return Vec::new() };
|
||||
vec![
|
||||
(
|
||||
format!("{SKILLS_ROOT}/{SKILLS_SHARED_SCOPE}/{id}"),
|
||||
sk.shared_host.join(id),
|
||||
),
|
||||
(
|
||||
format!("{SKILLS_ROOT}/{}/{id}", sk.own_username),
|
||||
sk.own_host.join(id),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The message for a `skills/<seg>/…` that is neither a known scope nor an
|
||||
/// installed skill id.
|
||||
///
|
||||
/// One sentence covers all three wrong doors — an invented scope, a typo'd id,
|
||||
/// and another member's tree — because `UserFs` knows only its owner's username
|
||||
/// and cannot tell a stranger's name from nonsense. Naming what *is* reachable,
|
||||
/// including the fact that other members' skills are not, answers the question
|
||||
/// behind each of them without pretending to know which one was asked.
|
||||
pub fn skill_route_hint(&self, id: &str) -> String {
|
||||
match &self.skills {
|
||||
Some(sk) => format!(
|
||||
"no skill named `{id}`. Skills live in `{SKILLS_ROOT}/{SKILLS_SHARED_SCOPE}/<id>/` \
|
||||
(the group's) and `{SKILLS_ROOT}/{}/<id>/` (yours); other members' skills are \
|
||||
not accessible, and `{SKILLS_ROOT}/` has no other subfolders.",
|
||||
sk.own_username
|
||||
),
|
||||
None => format!("skills are not available in this context: {SKILLS_ROOT}/{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,9 +412,12 @@ impl UserFs {
|
||||
|
||||
/// Reverse of [`to_container`](Self::to_container) for an already-absolute path:
|
||||
/// map a **container-absolute** path (`/root/…`, `/root/shared/{X}/…`,
|
||||
/// `/root/projects/{O}/{S}/…`) back to the agent vocabulary. Shared and project
|
||||
/// mounts nest *under* `container_home`, so they are matched **first** — otherwise
|
||||
/// `/root/shared/X` would strip against the home base and mis-route.
|
||||
/// `/root/projects/{O}/{S}/…`, `/root/skills/…`) back to the agent vocabulary.
|
||||
/// Shared, project and skill mounts nest *under* `container_home`, so they are
|
||||
/// matched **first** — otherwise `/root/shared/X` would strip against the home
|
||||
/// base and come back as `~/shared/X`, a spelling that routes correctly but is
|
||||
/// not the canonical one the viewer keys on. Within the skills tree the two
|
||||
/// scopes are matched before the root, which is their prefix.
|
||||
///
|
||||
/// Returns `None` when `abs` lies outside every one of this user's container mounts
|
||||
/// (i.e. it points outside their view) — the caller rejects it fail-closed. Purely
|
||||
@@ -230,6 +434,18 @@ impl UserFs {
|
||||
return Some(agent_join(&format!("projects/{}/{}", m.owner_username, m.slug), tail));
|
||||
}
|
||||
}
|
||||
if let Some(sk) = &self.skills {
|
||||
let [shared, own] = sk.container_scopes(&self.container_home);
|
||||
if let Ok(tail) = abs.strip_prefix(&shared) {
|
||||
return Some(agent_join(&format!("{SKILLS_ROOT}/{SKILLS_SHARED_SCOPE}"), tail));
|
||||
}
|
||||
if let Ok(tail) = abs.strip_prefix(&own) {
|
||||
return Some(agent_join(&format!("{SKILLS_ROOT}/{}", sk.own_username), tail));
|
||||
}
|
||||
if let Ok(tail) = abs.strip_prefix(sk.container_root(&self.container_home)) {
|
||||
return Some(agent_join(SKILLS_ROOT, tail));
|
||||
}
|
||||
}
|
||||
abs.strip_prefix(&self.container_home)
|
||||
.ok()
|
||||
.map(|tail| agent_join("~", tail))
|
||||
@@ -252,7 +468,7 @@ impl UserFs {
|
||||
let cleaned = normalize(Path::new(strip_home_prefix(input)));
|
||||
let cleaned = cleaned.to_string_lossy().replace('\\', "/");
|
||||
let root = cleaned.split('/').next().unwrap_or("");
|
||||
if root == "shared" || root == "projects" {
|
||||
if root == "shared" || root == "projects" || root == SKILLS_ROOT {
|
||||
Some(cleaned)
|
||||
} else if cleaned.is_empty() {
|
||||
Some("~".to_string())
|
||||
@@ -326,3 +542,133 @@ fn normalize(p: &Path) -> PathBuf {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fs_with_skills() -> UserFs {
|
||||
UserFs::new(
|
||||
"u1",
|
||||
PathBuf::from("/wd/homes/u1"),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.with_skills(SkillMounts {
|
||||
root_host: PathBuf::from("/wd/.skills-root/u1"),
|
||||
shared_host: PathBuf::from("/wd/skills"),
|
||||
own_host: PathBuf::from("/wd/skills-users/u1"),
|
||||
own_username: "daniele".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The two scopes route to their own host trees, whichever way the agent spells
|
||||
/// the home prefix.
|
||||
#[test]
|
||||
fn skill_scopes_route_to_their_trees() {
|
||||
let fs = fs_with_skills();
|
||||
for spelling in ["skills/shared/ics/SKILL.md", "~/skills/shared/ics/SKILL.md", "./skills/shared/ics/SKILL.md"] {
|
||||
assert_eq!(
|
||||
fs.host_base_and_tail(spelling).unwrap(),
|
||||
(PathBuf::from("/wd/skills"), "ics/SKILL.md".to_string()),
|
||||
"{spelling}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fs.host_base_and_tail("skills/daniele/spesa/run.py").unwrap(),
|
||||
(PathBuf::from("/wd/skills-users/u1"), "spesa/run.py".to_string())
|
||||
);
|
||||
// The root itself is the signpost mount, not the home.
|
||||
assert_eq!(
|
||||
fs.host_base_and_tail("skills").unwrap(),
|
||||
(PathBuf::from("/wd/.skills-root/u1"), String::new())
|
||||
);
|
||||
}
|
||||
|
||||
/// An invented scope segment must never fall back to the home — that fallback is
|
||||
/// what turns `skills/pippo/SKILL.md` into a real file under `homes/u1/` that no
|
||||
/// indexer ever reads. It comes back as an alias candidate for the caller to
|
||||
/// probe, and there is no third answer.
|
||||
#[test]
|
||||
fn an_unknown_scope_never_falls_back_to_the_home() {
|
||||
let fs = fs_with_skills();
|
||||
match fs.host_base_and_tail("skills/pippo/SKILL.md") {
|
||||
Err(RouteError::SkillAlias { id, tail }) => {
|
||||
assert_eq!(id, "pippo");
|
||||
assert_eq!(tail, "SKILL.md");
|
||||
}
|
||||
other => panic!("expected an alias probe, got {other:?}"),
|
||||
}
|
||||
// Another member's tree lands in the same branch, and the hint says so.
|
||||
match fs.host_base_and_tail("skills/serena/x/SKILL.md") {
|
||||
Err(RouteError::SkillAlias { id, .. }) => {
|
||||
let hint = fs.skill_route_hint(&id);
|
||||
assert!(hint.contains("other members' skills are not accessible"), "{hint}");
|
||||
assert!(hint.contains("skills/daniele/<id>/"), "{hint}");
|
||||
}
|
||||
other => panic!("expected an alias probe, got {other:?}"),
|
||||
}
|
||||
// Without a skills tree at all the root is still reserved, never the home.
|
||||
let bare = UserFs::new("u1", PathBuf::from("/wd/homes/u1"), "c", PathBuf::from("/root"), vec![], vec![], None);
|
||||
assert!(bare.host_base_and_tail("skills/shared/x").is_err());
|
||||
}
|
||||
|
||||
/// The whole root is read-only, including the space between the two scopes and
|
||||
/// including a context that has no skills tree at all.
|
||||
#[test]
|
||||
fn nothing_under_the_skills_root_is_writable() {
|
||||
let fs = fs_with_skills();
|
||||
for p in [
|
||||
"skills",
|
||||
"skills/README.md",
|
||||
"skills/shared/ics/SKILL.md",
|
||||
"skills/daniele/spesa/SKILL.md",
|
||||
"skills/pippo/SKILL.md",
|
||||
"~/skills/pippo/SKILL.md",
|
||||
] {
|
||||
assert!(!fs.can_write_to(p), "{p} should be read-only");
|
||||
}
|
||||
// The home around it is unaffected.
|
||||
assert!(fs.can_write_to("~/notes.md"));
|
||||
assert!(fs.can_write_to("skillset/notes.md"));
|
||||
|
||||
let bare = UserFs::new("u1", PathBuf::from("/wd/homes/u1"), "c", PathBuf::from("/root"), vec![], vec![], None);
|
||||
assert!(!bare.can_write_to("skills/anything"));
|
||||
}
|
||||
|
||||
/// The scope mounts nest inside the root mount, so they must be matched first —
|
||||
/// otherwise the root (their own prefix) claims them, and the home claims all
|
||||
/// three.
|
||||
#[test]
|
||||
fn container_paths_map_back_to_the_scope_that_owns_them() {
|
||||
let fs = fs_with_skills();
|
||||
assert_eq!(fs.container_to_agent(Path::new("/root/skills/shared/ics/SKILL.md")).unwrap(), "skills/shared/ics/SKILL.md");
|
||||
assert_eq!(fs.container_to_agent(Path::new("/root/skills/daniele/spesa")).unwrap(), "skills/daniele/spesa");
|
||||
assert_eq!(fs.container_to_agent(Path::new("/root/skills/README.md")).unwrap(), "skills/README.md");
|
||||
assert_eq!(fs.container_to_agent(Path::new("/root/skills")).unwrap(), "skills");
|
||||
assert_eq!(fs.container_to_agent(Path::new("/root/notes.md")).unwrap(), "~/notes.md");
|
||||
// And the display form keeps the skills root rather than re-rooting on `~`.
|
||||
assert_eq!(fs.to_agent_display("skills/shared/ics").unwrap(), "skills/shared/ics");
|
||||
assert_eq!(fs.to_agent_display("~/skills/shared/ics").unwrap(), "skills/shared/ics");
|
||||
}
|
||||
|
||||
/// Docker cannot create a mountpoint inside a `:ro` mount, so the root has to be
|
||||
/// mounted before the two scopes that nest in it — and all three read-only.
|
||||
#[test]
|
||||
fn skill_mounts_are_read_only_and_root_first() {
|
||||
let fs = fs_with_skills();
|
||||
let mounts = fs.mounts();
|
||||
let skills: Vec<_> = mounts
|
||||
.iter()
|
||||
.filter(|(_, container, _)| container.starts_with("/root/skills"))
|
||||
.collect();
|
||||
assert_eq!(skills.len(), 3);
|
||||
assert_eq!(skills[0].1, PathBuf::from("/root/skills"));
|
||||
assert!(skills.iter().all(|(_, _, writable)| !writable), "{skills:?}");
|
||||
assert!(skills.iter().any(|(_, c, _)| c == Path::new("/root/skills/shared")));
|
||||
assert!(skills.iter().any(|(_, c, _)| c == Path::new("/root/skills/daniele")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +64,6 @@ struct RawMeta {
|
||||
/// Required: declares the agent's role. A `meta.json` without `type` fails to load.
|
||||
#[serde(rename = "type")]
|
||||
agent_type: AgentType,
|
||||
#[serde(default = "default_true")]
|
||||
inject_skills: bool,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
@@ -113,12 +111,6 @@ pub struct AgentMeta {
|
||||
/// runnable as a task root; `chat` and `system` are excluded from those paths.
|
||||
#[serde(rename = "type")]
|
||||
pub agent_type: AgentType,
|
||||
/// When true (the default, including when the key is absent), the skills index
|
||||
/// (`skills/index.md`) is injected into this agent's system prompt so it can
|
||||
/// discover and use installed skills. Set false for background agents that don't
|
||||
/// need them (e.g. event triage) to save tokens.
|
||||
#[serde(default = "default_true")]
|
||||
pub inject_skills: bool,
|
||||
/// Path to the agent's icon image file (relative to the agent's directory).
|
||||
/// Defaults to None if no icon is configured.
|
||||
#[serde(default)]
|
||||
@@ -208,7 +200,6 @@ pub fn discover() -> Result<Vec<AgentMeta>> {
|
||||
client: raw.client,
|
||||
strength: raw.strength,
|
||||
agent_type: raw.agent_type,
|
||||
inject_skills: raw.inject_skills,
|
||||
icon: raw.icon,
|
||||
allow_tools: raw.allow_tools,
|
||||
};
|
||||
@@ -241,7 +232,6 @@ pub fn load_meta(agent_id: &str) -> Result<AgentMeta> {
|
||||
client: raw.client,
|
||||
strength: raw.strength,
|
||||
agent_type: raw.agent_type,
|
||||
inject_skills: raw.inject_skills,
|
||||
icon: raw.icon,
|
||||
allow_tools: raw.allow_tools,
|
||||
})
|
||||
@@ -353,4 +343,61 @@ mod tests {
|
||||
}
|
||||
assert!(checked > 0, "no agent meta.json found under {}", root.display());
|
||||
}
|
||||
|
||||
/// The skills index is opt-in through `<!-- SKILLS_LIST -->` (normally the
|
||||
/// `common/skills.md` include), so the decision "who sees the skills" is now
|
||||
/// eleven lines in eleven files rather than one default in the code — and a
|
||||
/// line in a file rots in silence. This is what stops it.
|
||||
///
|
||||
/// The rule it holds is the one from the design: whoever **does the work**
|
||||
/// gets the index, so `chat` and `task` agents both do (in a delegation the
|
||||
/// worker is the child; an index injected only in the parent would leave it
|
||||
/// knowing a procedure exists and handing the job to someone who cannot read
|
||||
/// it). A `system` agent never does: its turns are unattended, its approvals
|
||||
/// auto-denied, and some run with no tools at all — an imperative "you MUST
|
||||
/// read its SKILL.md with read_file" would name a tool that isn't there.
|
||||
///
|
||||
/// Reads the **repo's** `agents/`, not the cwd one, which under `cargo test`
|
||||
/// holds the projection fixtures.
|
||||
#[test]
|
||||
fn every_agent_that_does_the_work_carries_the_skills_include() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join(AGENTS_DIR);
|
||||
let dir = std::fs::read_dir(&root)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display()));
|
||||
|
||||
let mut with = 0;
|
||||
let mut without = 0;
|
||||
for entry in dir {
|
||||
let path = entry.expect("readable dir entry").path();
|
||||
let Some(id) = path.file_name().and_then(|n| n.to_str()) else { continue };
|
||||
if !path.is_dir() || id == "common" {
|
||||
continue;
|
||||
}
|
||||
let (meta_path, prompt_path) = (path.join("meta.json"), path.join("AGENT.md"));
|
||||
if !meta_path.exists() || !prompt_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let raw: RawMeta = serde_json::from_str(
|
||||
&std::fs::read_to_string(&meta_path).expect("readable meta.json"),
|
||||
)
|
||||
.expect("valid meta.json");
|
||||
let prompt = std::fs::read_to_string(&prompt_path).expect("readable AGENT.md");
|
||||
let has = prompt.contains("<!-- INCLUDE: common/skills.md -->")
|
||||
|| prompt.contains("<!-- SKILLS_LIST -->");
|
||||
|
||||
match raw.agent_type {
|
||||
AgentType::System => {
|
||||
assert!(!has, "system agent `{id}` must not be given the skills index");
|
||||
without += 1;
|
||||
}
|
||||
AgentType::Chat | AgentType::Task => {
|
||||
assert!(has, "agent `{id}` is missing `<!-- INCLUDE: common/skills.md -->`");
|
||||
with += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(with > 0 && without > 0, "roster looks wrong: {with} with, {without} without");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,9 @@ impl ApprovalManager {
|
||||
/// is evaluated first: the audit trail must always be writable, and `append_file` is
|
||||
/// the one write tool that cannot shorten a file.
|
||||
/// - `data/*` → **allow** (scratch/data workspace).
|
||||
/// - `skills/*` → reads **allow** (`@fs_read`): the trust decision on a skill is
|
||||
/// taken at installation, not at each read. There is no write counterpart —
|
||||
/// the whole tree is read-only in both directions (blueprint §9).
|
||||
/// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`,
|
||||
/// not `path`), so it needs a tool-scoped rule rather than a path pattern.
|
||||
///
|
||||
@@ -381,6 +384,15 @@ impl ApprovalManager {
|
||||
("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/", 5),
|
||||
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/", 5),
|
||||
("@fs_any", Some("data/*"), "allow", "auto-allow data/", 5),
|
||||
// The skills tree (blueprint §7.2): reading a skill must never raise a
|
||||
// card. The trust decision was taken when it was *installed* — the
|
||||
// `skill_register` card — exactly as a connector is trusted at
|
||||
// activation and not at each call. Read-only is enforced by the mount
|
||||
// and by `UserFs::can_write_to`, so there is no write rule to pair
|
||||
// with this one; today `RunContext::is_read_allowed` would already
|
||||
// allow it, and this row is what keeps that true if the working
|
||||
// directory ever narrows (the binary-first direction).
|
||||
("@fs_read", Some("skills/*"), "allow", "auto-allow read skills/", 5),
|
||||
// Project folders (`projects/{owner}/{slug}`, blueprint §6): reads + writes
|
||||
// frictionless, matching the working-project UX. A read-only member's mount
|
||||
// is `:ro`, so a write physically fails regardless of this allow.
|
||||
@@ -1285,15 +1297,16 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(legacy, 0, "legacy fs rules should be removed by migration");
|
||||
|
||||
// …and replaced by exactly the five @fs_* token rows (shared-memory has two:
|
||||
// read-allow and write-require; plus user-memory, data, and projects).
|
||||
// …and replaced by exactly the six @fs_* token rows (shared-memory has two:
|
||||
// read-allow and write-require; plus user-memory, data, projects, and the
|
||||
// read-only skills tree).
|
||||
let fs_rows: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
|
||||
)
|
||||
.fetch_one(db.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fs_rows, 5, "user-memory + shared-memory(r/w) + data + projects @fs_* rules should be seeded");
|
||||
assert_eq!(fs_rows, 6, "user-memory + shared-memory(r/w) + data + projects + skills @fs_* rules should be seeded");
|
||||
|
||||
// Gate decisions through the real check() path.
|
||||
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
|
||||
@@ -1306,6 +1319,12 @@ mod tests {
|
||||
// shared-memory: reads allowed, writes require approval.
|
||||
assert!(matches!(decide(&mgr, "read_file", "shared-memory/casa.md").await, GateResult::Allow));
|
||||
assert!(matches!(decide(&mgr, "write_file", "shared-memory/casa.md").await, GateResult::Require));
|
||||
// Reading a skill never raises a card: the trust decision was taken when it
|
||||
// was installed. A write does not need a rule — the tree is read-only in
|
||||
// both directions — so it simply falls through to the catch-all.
|
||||
assert!(matches!(decide(&mgr, "read_file", "skills/shared/ics/SKILL.md").await, GateResult::Allow));
|
||||
assert!(matches!(decide(&mgr, "list_files", "skills/daniele").await, GateResult::Allow));
|
||||
assert!(matches!(decide(&mgr, "write_file", "skills/shared/ics/SKILL.md").await, GateResult::Require));
|
||||
assert!(matches!(decide(&mgr, "edit_file", "shared-memory/casa.md").await, GateResult::Require));
|
||||
// The shared audit log is the one exception, and only for `append_file` — the
|
||||
// one write tool that cannot shorten a file. Its lower priority number must
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! user is created and started at application boot; `execute_cmd` and — later —
|
||||
//! the user's stateful MCP servers run inside it, against the user's bind-mounted
|
||||
//! home (`{WD}/homes/{userid}` → `/root`) plus the shared folders they belong to,
|
||||
//! plus the read-only `{WD}/docs` bundle mounted at `/root/docs` for every user and
|
||||
//! plus the read-only `{WD}/docs` bundle mounted at `/root/docs` for every user,
|
||||
//! the read-only memory **signposts** at `/root/{user,shared}-memory` (see
|
||||
//! [`signpost_mounts`]).
|
||||
//! [`signpost_mounts`]) and the read-only skills tree at `/root/skills` (see
|
||||
//! [`ensure_skills_root`]).
|
||||
//!
|
||||
//! Docker is a **hard requirement**: [`ContainerManager::check_docker`] fails
|
||||
//! construction if the daemon is unreachable, and the shell exits at boot.
|
||||
@@ -28,7 +29,7 @@ use std::time::Duration;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use core_api::user_fs::{ProjectMount, SharedMount, UserFs};
|
||||
use core_api::user_fs::{ProjectMount, SharedMount, SkillMounts, UserFs};
|
||||
|
||||
use crate::db;
|
||||
use crate::tools::fs as fs_tools;
|
||||
@@ -60,6 +61,18 @@ pub const DOCS_DIR: &str = "docs";
|
||||
/// Subdirectory of the working directory holding the memory **signposts** — see
|
||||
/// [`signpost_mounts`]. Dot-prefixed: it is internal plumbing, not a user folder.
|
||||
pub const SIGNPOST_DIR: &str = ".memory-signpost";
|
||||
/// Subdirectory of the working directory holding the **group's** skills
|
||||
/// (`{WD}/skills/<id>`), mounted read-only at `{container_home}/skills/shared`.
|
||||
pub const SKILLS_DIR: &str = "skills";
|
||||
/// Subdirectory of the working directory holding each member's **own** skills
|
||||
/// (`{WD}/skills-users/{userid}/<id>`). Outside the home on purpose: a skill is an
|
||||
/// installed artefact, not a working file, so it must not show up in a home listing
|
||||
/// nor vanish with a cleanup of one — and keeping the two scopes side by side means
|
||||
/// the code that manages them handles one shape of path, not two.
|
||||
pub const SKILLS_USERS_DIR: &str = "skills-users";
|
||||
/// Subdirectory of the working directory holding each member's skills-root mount —
|
||||
/// see [`ensure_skills_root`]. Dot-prefixed like [`SIGNPOST_DIR`]: plumbing.
|
||||
pub const SKILLS_ROOT_DIR: &str = ".skills-root";
|
||||
/// Home mount point inside the container.
|
||||
pub const CONTAINER_HOME: &str = "/root";
|
||||
/// Grace window `docker stop` gives in-container processes (SIGTERM → SIGKILL)
|
||||
@@ -103,6 +116,11 @@ pub async fn build_user_fs(system: &SqlitePool, user_id: &str) -> Result<UserFs>
|
||||
let home_host = wd.join(HOMES_DIR).join(user_id);
|
||||
let container_home = PathBuf::from(CONTAINER_HOME);
|
||||
|
||||
// The skills tree needs the owner's **username**, because that is the agent-visible
|
||||
// segment of their own scope (`skills/{username}/<id>`), while the host path keys on
|
||||
// the stable userid — the same split `projects/{owner_username}/{slug}` already makes.
|
||||
let username = db::users::get(system, user_id).await?.map(|u| u.username);
|
||||
|
||||
let memberships = db::shared_folders::list_for_user(system, user_id).await?;
|
||||
let shared = memberships
|
||||
.into_iter()
|
||||
@@ -133,7 +151,28 @@ pub async fn build_user_fs(system: &SqlitePool, user_id: &str) -> Result<UserFs>
|
||||
|
||||
let docs_host = Some(wd.join(DOCS_DIR));
|
||||
|
||||
Ok(UserFs::new(user_id, home_host, container_name(user_id), container_home, shared, projects, docs_host))
|
||||
let fs = UserFs::new(user_id, home_host, container_name(user_id), container_home, shared, projects, docs_host);
|
||||
match username {
|
||||
Some(own_username) => Ok(fs.with_skills(SkillMounts {
|
||||
root_host: skills_root_host(&wd, user_id),
|
||||
shared_host: wd.join(SKILLS_DIR),
|
||||
own_host: wd.join(SKILLS_USERS_DIR).join(user_id),
|
||||
own_username,
|
||||
})),
|
||||
// No directory row: nothing to name the own scope with, so the tree stays
|
||||
// absent rather than half-built. `skills/…` then refuses outright, which is
|
||||
// the honest answer — and the only caller that can reach this is one asking
|
||||
// for a user who does not exist.
|
||||
None => {
|
||||
tracing::warn!(user = %user_id, "no user row: building a UserFs without the skills tree");
|
||||
Ok(fs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host directory backing a user's skills-**root** mount.
|
||||
pub fn skills_root_host(wd: &Path, user_id: &str) -> PathBuf {
|
||||
wd.join(SKILLS_ROOT_DIR).join(user_id)
|
||||
}
|
||||
|
||||
// ── Memory signposts ──────────────────────────────────────────────────────────
|
||||
@@ -241,6 +280,91 @@ fn ensure_signposts(wd: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── The skills root ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// `skills/` is a read-only tree with two scopes below it — `skills/shared/<id>`
|
||||
// (the group's) and `skills/{username}/<id>` (the member's own). Mounting only
|
||||
// those two would leave the space *between* them open, and that gap is where a
|
||||
// model writes: it invents a scope segment, `mkdir -p ~/skills/pippo` succeeds
|
||||
// inside the writable home mount, and the folder appears right next to the two
|
||||
// read-only ones as if it had worked. That is the memory-signpost failure again,
|
||||
// so the answer is the same — the root itself is a read-only mount.
|
||||
//
|
||||
// Its source directory is per-**user** and not one instance-wide dir, for a reason
|
||||
// Docker decides rather than us: a bind mount cannot create its own mountpoint
|
||||
// inside a `:ro` mount (`mkdirat … read-only file system`, at container create), so
|
||||
// `shared/` and `{username}/` must already exist in the root's source — and one of
|
||||
// those two names is the member's.
|
||||
//
|
||||
// The root also carries the README, which makes the sign and the lock the same
|
||||
// object: they cannot drift apart, because there is only one of them.
|
||||
|
||||
/// The signpost text at `skills/README.md`. In English, like everything the agent
|
||||
/// reads. It explains the *shape* of the tree and where the door is, because with
|
||||
/// the whole root read-only the first `echo > skills/mine/x/SKILL.md` returns
|
||||
/// "read-only file system" — an error, not an instruction, and a model answers an
|
||||
/// error by reaching for `sudo` (which cannot help: `:ro` needs `CAP_SYS_ADMIN` to
|
||||
/// undo, and the container has none).
|
||||
const SKILLS_ROOT_SIGNPOST: &str = "\
|
||||
# Skills
|
||||
|
||||
Two subfolders, and they are the only two:
|
||||
|
||||
shared/ skills installed for the whole group
|
||||
<username>/ your own skills (only yours are here — other members' are not visible)
|
||||
|
||||
Each skill is a folder with a `SKILL.md` inside it, plus whatever scripts and
|
||||
reference files that file mentions. Read one with `read_file`; run its scripts with
|
||||
`execute_cmd`, setting `workdir` to the skill's own folder.
|
||||
|
||||
**This whole tree is read-only**, including this directory. You cannot create a
|
||||
skill by writing here, and `sudo` will not change that. A skill is written somewhere
|
||||
you can write — your home, a project — and then *installed* from there:
|
||||
|
||||
activate_tools([\"config\"]) then
|
||||
skill_register(scope, path) scope: \"mine\" or \"global\"
|
||||
|
||||
Read `docs/skills.md` before writing one; it holds the authoring contract.
|
||||
|
||||
Anything a skill needs to write (caches, state, dependencies) goes in your home or
|
||||
`/tmp`, never next to the skill.
|
||||
";
|
||||
|
||||
/// Creates a user's skills-root mount source and (re)writes its contents: the
|
||||
/// README plus the two empty directories the scope mounts land on. Unconditional,
|
||||
/// like [`ensure_signposts`] — a few hundred bytes at every container `ensure`, so
|
||||
/// an edited text reaches existing installations with no migration step.
|
||||
///
|
||||
/// It also **prunes** any other entry: after a rename the previous username would
|
||||
/// otherwise stay behind as an empty directory and show up in `ls skills/` as a
|
||||
/// scope that leads nowhere.
|
||||
fn ensure_skills_root(wd: &Path, user_id: &str, own_username: &str) -> Result<()> {
|
||||
let root = skills_root_host(wd, user_id);
|
||||
std::fs::create_dir_all(&root)
|
||||
.with_context(|| format!("failed to create skills root {}", root.display()))?;
|
||||
std::fs::write(root.join(SIGNPOST_README), SKILLS_ROOT_SIGNPOST)
|
||||
.with_context(|| format!("failed to write skills signpost in {}", root.display()))?;
|
||||
|
||||
let keep = [core_api::user_fs::SKILLS_SHARED_SCOPE, own_username];
|
||||
for name in keep {
|
||||
std::fs::create_dir_all(root.join(name))
|
||||
.with_context(|| format!("failed to create skills mountpoint {name}"))?;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&root) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name == SIGNPOST_README || keep.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
// Only ever an empty leftover mountpoint: the real content lives in the
|
||||
// trees these directories are mounted *from*, never in here.
|
||||
let _ = std::fs::remove_dir(entry.path());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Owns the container lifecycle: the docker availability check, the runtime image,
|
||||
/// and per-user create/start/stop/remove. Cheap to clone (holds an `Arc` pool).
|
||||
#[derive(Clone)]
|
||||
@@ -327,6 +451,11 @@ impl ContainerManager {
|
||||
.with_context(|| format!("failed to create host dir {}", host.display()))?;
|
||||
}
|
||||
ensure_signposts(&wd)?;
|
||||
// After the mount dirs, because the two scope mountpoints it creates live
|
||||
// *inside* the root dir the loop above just made.
|
||||
if let Some(sk) = &fs.skills {
|
||||
ensure_skills_root(&wd, user_id, &sk.own_username)?;
|
||||
}
|
||||
|
||||
let name = &fs.container_name;
|
||||
let want_user = host_uid_gid().map(|(uid, gid)| format!("{uid}:{gid}"));
|
||||
@@ -334,8 +463,8 @@ impl ContainerManager {
|
||||
match container_state(name).await {
|
||||
// Reuse only if it runs as the expected user AND has tini as PID 1;
|
||||
// otherwise recreate below.
|
||||
ContainerState::Running if reusable(name, &want_user).await => return Ok(()),
|
||||
ContainerState::Stopped if reusable(name, &want_user).await => {
|
||||
ContainerState::Running if reusable(name, &want_user, &fs).await => return Ok(()),
|
||||
ContainerState::Stopped if reusable(name, &want_user, &fs).await => {
|
||||
docker(&["start", name]).await.context("docker start failed")?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -547,14 +676,35 @@ async fn signposts_mounted(name: &str) -> bool {
|
||||
.all(|(_, container)| dests.iter().any(|d| Path::new(d) == container))
|
||||
}
|
||||
|
||||
/// Whether a container carries all three skills mounts (root + the two scopes).
|
||||
/// The fifth self-heal axis, and an [`IMAGE_TAG`] bump for the same reason as the
|
||||
/// signposts: the image is unchanged, so a bump would make every installation
|
||||
/// rebuild it just to fix a mount. Without this check an existing container keeps a
|
||||
/// writable `~/skills` — a directory the shell can create folders in that no reader
|
||||
/// ever visits. Unreadable inspect ⇒ `true`, so a docker hiccup never churns a
|
||||
/// working container.
|
||||
async fn skills_mounted(name: &str, fs: &UserFs) -> bool {
|
||||
let Some(sk) = &fs.skills else { return true };
|
||||
let Ok(out) = docker(&["inspect", "-f", "{{range .Mounts}}{{println .Destination}}{{end}}", name]).await
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let dests: Vec<&str> = out.lines().map(str::trim).collect();
|
||||
let [shared, own] = sk.container_scopes(&fs.container_home);
|
||||
[sk.container_root(&fs.container_home), shared, own]
|
||||
.iter()
|
||||
.all(|want| dests.iter().any(|d| Path::new(d) == want))
|
||||
}
|
||||
|
||||
/// Whether an existing container can be reused as-is: right `--user` (§6 UID coherence),
|
||||
/// `--init` (fast, clean `docker stop`), the current image **and** the memory signpost
|
||||
/// mounts. A mismatch on any of the four recreates it.
|
||||
async fn reusable(name: &str, want_user: &Option<String>) -> bool {
|
||||
/// `--init` (fast, clean `docker stop`), the current image, the memory signpost mounts
|
||||
/// **and** the skills mounts. A mismatch on any of the five recreates it.
|
||||
async fn reusable(name: &str, want_user: &Option<String>, fs: &UserFs) -> bool {
|
||||
user_matches(name, want_user).await
|
||||
&& init_matches(name).await
|
||||
&& image_matches(name).await
|
||||
&& signposts_mounted(name).await
|
||||
&& skills_mounted(name, fs).await
|
||||
}
|
||||
|
||||
/// Gives the container's runtime `uid`/`gid` a passwd + shadow (+ group) entry, so
|
||||
@@ -610,3 +760,39 @@ async fn docker_ok(args: &[&str]) -> bool {
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The root mount's source has to carry the two scope mountpoints, because
|
||||
/// Docker cannot create them itself inside a `:ro` mount — and it must carry
|
||||
/// *only* those, or a stale one left by a rename shows up in `ls skills/` as a
|
||||
/// scope that leads nowhere.
|
||||
#[test]
|
||||
fn skills_root_holds_the_signpost_and_exactly_two_mountpoints() {
|
||||
let wd = std::env::temp_dir().join(format!("skald-skroot-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&wd);
|
||||
let root = skills_root_host(&wd, "u1");
|
||||
|
||||
ensure_skills_root(&wd, "u1", "daniele").unwrap();
|
||||
assert!(root.join(SIGNPOST_README).is_file());
|
||||
assert!(root.join("shared").is_dir());
|
||||
assert!(root.join("daniele").is_dir());
|
||||
|
||||
// Idempotent, and a leftover scope directory is pruned on the next pass.
|
||||
std::fs::create_dir_all(root.join("stale")).unwrap();
|
||||
ensure_skills_root(&wd, "u1", "daniele").unwrap();
|
||||
assert!(!root.join("stale").exists(), "a stale mountpoint survived");
|
||||
|
||||
let mut names: Vec<String> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["README.md", "daniele", "shared"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&wd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ pub const MANAGE_SHARED_FOLDERS: &str = "folders.manage";
|
||||
/// pattern as [`MANAGE_SHARED_FOLDERS`].
|
||||
pub const MANAGE_PLUGINS: &str = "plugin.manage";
|
||||
|
||||
/// Install or delete a skill in the **group's** tree — `skill_register`/
|
||||
/// `skill_delete` with `scope: "global"` (blueprint §7.3/§9). One's own scope
|
||||
/// needs no capability: it is the caller's, always.
|
||||
///
|
||||
/// Deliberately **not** in [`DEFAULT_USER_CAPABILITIES`], unlike the two
|
||||
/// self-service MCP ones, and the asymmetry is the point: a global skill is text
|
||||
/// that enters every member's prompt and is read there as an instruction, so it
|
||||
/// is closer to curating the catalog than to activating a connector for oneself.
|
||||
/// `admin` therefore holds it implicitly (via [`has`]) and opening it to another
|
||||
/// role later is a single [`grant`], no code change — the same shape as
|
||||
/// [`MANAGE_SHARED_FOLDERS`] and [`MANAGE_PLUGINS`].
|
||||
pub const MANAGE_SKILLS: &str = "skill.manage";
|
||||
|
||||
/// The default capabilities of an ordinary (non-admin) user role.
|
||||
pub const DEFAULT_USER_CAPABILITIES: &[&str] = &[REGISTER_REMOTE, REGISTER_LOCAL_FROM_CATALOG];
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ pub mod secrets;
|
||||
pub mod service_manager;
|
||||
pub mod session;
|
||||
pub mod setup;
|
||||
pub mod skills;
|
||||
pub mod system_agents;
|
||||
pub mod event_triage;
|
||||
pub mod tool_catalog;
|
||||
|
||||
@@ -196,7 +196,7 @@ impl SkaldToolActivator {
|
||||
tool_prefix: None,
|
||||
tool_count: self.config_defs.len(),
|
||||
description: Some(
|
||||
"Built-in system-configuration tools: connectors, plugins, scheduled jobs, secrets."
|
||||
"Built-in system-configuration tools: connectors, plugins, scheduled jobs, secrets, installing and deleting skills."
|
||||
.into(),
|
||||
),
|
||||
message: format!("Tools are in context for {} from the next round.", self.scope_label()),
|
||||
|
||||
@@ -137,6 +137,11 @@ impl AgentCatalog for SkaldAgentCatalog {
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
// A sub-agent sees the same skills its parent does: in a delegation
|
||||
// the one doing the work is the child, so an index injected only in
|
||||
// the parent would leave it knowing a procedure exists and handing
|
||||
// the job to someone who cannot read it.
|
||||
fs: self.fs.clone(),
|
||||
project_root: scope.project_root.clone(),
|
||||
// The scratchpad is the session's blackboard: a sub-agent reads and
|
||||
// writes the SAME one as its parent.
|
||||
|
||||
@@ -123,6 +123,29 @@ impl ApprovalGate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the review card for a pending `skill_register`: the destination's
|
||||
/// agent path, the installed body if this replaces one, and the candidate's
|
||||
/// own `SKILL.md`.
|
||||
///
|
||||
/// Resolved through the caller's own `UserFs`, like every other path the gate
|
||||
/// touches, so a source that lives only in the container (`/tmp/…`) yields
|
||||
/// `None` here and a spoken refusal from the tool.
|
||||
async fn skill_registration_preview(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<(String, Option<String>, String)> {
|
||||
use crate::skills::{Scope, install};
|
||||
use crate::tools::fs::{FsTarget, resolve_target};
|
||||
|
||||
let scope = Scope::parse(args["scope"].as_str()?).ok()?;
|
||||
let fs = self.fs.as_ref()?.load();
|
||||
let host = match resolve_target(&fs, args["path"].as_str()?).ok()? {
|
||||
FsTarget::Host(p) => p,
|
||||
FsTarget::Container { .. } => return None,
|
||||
};
|
||||
install::preview(&fs, scope, &host)
|
||||
}
|
||||
|
||||
/// Emits the approval event for the tool kind: `PendingWrite` (via
|
||||
/// `LoopEvent::Host`) for file-write tools and `execute_cmd`,
|
||||
/// `ApprovalRequired` otherwise (port of `emit_approval_event`).
|
||||
@@ -150,6 +173,31 @@ impl ApprovalGate {
|
||||
})));
|
||||
return;
|
||||
}
|
||||
} else if name == tn::SKILL_REGISTER {
|
||||
// The review moment of the whole design (blueprint §9.1): for the
|
||||
// group's scope this is the *only* time a person reads a text that
|
||||
// will enter everybody's prompt. So the card carries the candidate's
|
||||
// `SKILL.md` in full — not its name, not a summary — with a header
|
||||
// naming the scope, the file list and whether it replaces something;
|
||||
// on a replacement the installed body goes in as `old_content`, and
|
||||
// the existing diff renderer turns the card into a review of what
|
||||
// actually changes. Reusing `pending_write` is what makes that free:
|
||||
// no new event, no new frontend, exactly as `execute_cmd` below.
|
||||
if let Some(preview) = self.skill_registration_preview(&call.args).await {
|
||||
let (path, old_content, new_content) = preview;
|
||||
events.emit(call.frame, call.parent_frame, LoopEvent::Host(serde_json::json!({
|
||||
"type": "pending_write",
|
||||
"request_id": request_id,
|
||||
"tool_call_id": call.id.get(),
|
||||
"path": path,
|
||||
"old_content": old_content,
|
||||
"new_content": new_content,
|
||||
})));
|
||||
return;
|
||||
}
|
||||
// Unreadable or invalid source: fall through to the plain card. The
|
||||
// tool refuses it a moment later with a message that says why, and a
|
||||
// half-built preview would only make the refusal look like a bug.
|
||||
} else if name == tn::EXECUTE_CMD {
|
||||
let cmd = call.args["command"].as_str().unwrap_or("");
|
||||
events.emit(call.frame, call.parent_frame, LoopEvent::Host(serde_json::json!({
|
||||
|
||||
@@ -102,6 +102,25 @@ impl PrefixCache {
|
||||
entries.retain(|_, e| e.last_used.elapsed() < self.ttl);
|
||||
entries.insert(key, Entry { base, last_used: Instant::now() });
|
||||
}
|
||||
|
||||
/// Drops every frozen prefix, so the next round of every conversation
|
||||
/// rebuilds one.
|
||||
///
|
||||
/// The single exception to "writes are deliberately not reacted to" above,
|
||||
/// and it is narrow on purpose. The rule holds for a file the prompt merely
|
||||
/// *injects*: the agent that edited it already has the new text two messages
|
||||
/// downstream, and a rebuild would repeat what it just said. It does not hold
|
||||
/// for the **skills index**, which is not content but a *catalogue*: an admin
|
||||
/// who installs a skill and immediately asks for it would be told for twenty
|
||||
/// minutes that it does not exist — the prefix is not stale, it is wrong.
|
||||
///
|
||||
/// Coarse by design. A per-key flush would need to know which conversations
|
||||
/// carry an agent whose prompt includes the index, which is a question about
|
||||
/// eleven `AGENT.md` files; installing a skill is rare enough that rebuilding
|
||||
/// a handful of prefixes once is cheaper than keeping that answer correct.
|
||||
pub fn clear(&self) {
|
||||
self.entries.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PrefixCache {
|
||||
|
||||
@@ -224,6 +224,14 @@ impl UserLoopRuntime {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// Drops this user's frozen system prefixes — see [`PrefixCache::clear`].
|
||||
/// Called when the **skills index** they would carry has changed, which is
|
||||
/// the one case where waiting for the idle window would have the model deny
|
||||
/// that something exists.
|
||||
pub fn invalidate_prefixes(&self) {
|
||||
self.prefix_cache.clear();
|
||||
}
|
||||
|
||||
/// Where this user's LLM traffic is logged: metadata in the registry
|
||||
/// (attributed to them), payloads in their own encrypted pool.
|
||||
pub fn log_target(&self) -> RequestLogTarget {
|
||||
@@ -251,6 +259,7 @@ impl UserLoopRuntime {
|
||||
shared_pool: self.shared_pool.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
mcp: self.mcp.clone(),
|
||||
fs: self.fs.clone(),
|
||||
project_root: scope.project_root.clone(),
|
||||
scratchpad_sid: scope.scratchpad_sid,
|
||||
datetime: self.config.datetime.clone(),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! | layer | wire position |
|
||||
//! |---|---|
|
||||
//! | AGENT.md + `inject_memory` + skills index + `extra_system` + substitutions | `base` — the cacheable prefix |
|
||||
//! | AGENT.md + `inject_memory` + `extra_system` + substitutions | `base` — the cacheable prefix |
|
||||
//! | session scratchpad | `extra_static` — a system message before the conversation |
|
||||
//! | Honcho memory / per-turn overrides, then the date/time block | `dynamic_tail` — joined into the trailing system message |
|
||||
//! | trailing reminder | `tail_reminder` |
|
||||
@@ -13,16 +13,13 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_loop::context::{SystemContext, SystemContextSource, TurnInfo};
|
||||
use core_api::user_fs::SharedFs;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::loop_adapters::prefix_cache::PrefixCache;
|
||||
use crate::mcp::McpProvider;
|
||||
|
||||
/// Registry of installed skills, relative to Skald's process cwd. Injected
|
||||
/// into agents that have `inject_skills` enabled (the default).
|
||||
const SKILLS_INDEX_PATH: &str = "skills/index.md";
|
||||
|
||||
/// The static system content of one agent, resolved per turn.
|
||||
pub struct AgentSystemContext {
|
||||
pub agent_id: String,
|
||||
@@ -39,6 +36,12 @@ pub struct AgentSystemContext {
|
||||
pub shared_pool: Arc<SqlitePool>,
|
||||
pub user_id: String,
|
||||
pub mcp: Arc<dyn McpProvider>,
|
||||
/// The caller's filesystem view — read here for one thing only, the skills
|
||||
/// index: `UserFs` already *is* the answer to "which skills can this user
|
||||
/// see", so reading the two trees off it avoids a second source of truth.
|
||||
/// The swappable cell rather than a snapshot, so a §6 remount is picked up
|
||||
/// at the next prefix rebuild.
|
||||
pub fs: SharedFs,
|
||||
/// Project root for `__PROJECT_ROOT__` expansion in `inject_memory`.
|
||||
pub project_root: Option<String>,
|
||||
/// Scratchpad scope: the session's own id, or the parent's for an async
|
||||
@@ -145,18 +148,6 @@ impl AgentSystemContext {
|
||||
}
|
||||
}
|
||||
|
||||
// Skills index — injected unless the agent opts out. Skipped silently
|
||||
// when no skills are installed.
|
||||
if meta.inject_skills {
|
||||
let (abs, display) = self.resolve_memory_path(SKILLS_INDEX_PATH);
|
||||
if let Ok(c) = tokio::fs::read_to_string(&abs).await {
|
||||
static_content.push_str(&format!(
|
||||
"\n\n---\nInstalled skills you can use (read the linked `SKILL.md` before running a skill):\n\
|
||||
\n<skills_index path=\"{display}\">\n{c}\n</skills_index>\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extra) = &self.extra_static {
|
||||
static_content.push_str("\n\n---\n");
|
||||
static_content.push_str(extra);
|
||||
@@ -165,6 +156,17 @@ impl AgentSystemContext {
|
||||
if static_content.contains("__MCP_LIST__") {
|
||||
static_content = static_content.replace("__MCP_LIST__", &self.render_mcp_list());
|
||||
}
|
||||
// The sentinel *is* the knob: an agent gets the skills index iff its
|
||||
// `AGENT.md` carries `<!-- SKILLS_LIST -->` (normally through
|
||||
// `common/skills.md`). There is no `meta.json` flag — two mechanisms for
|
||||
// one question is one too many, and the system agents opt out simply by
|
||||
// not including the fragment.
|
||||
if static_content.contains("__SKILLS_LIST__") {
|
||||
static_content = static_content.replace(
|
||||
"__SKILLS_LIST__",
|
||||
&crate::skills::render_index(&self.fs.load()),
|
||||
);
|
||||
}
|
||||
if static_content.contains("__SHARED_FOLDERS__") {
|
||||
static_content = static_content.replace(
|
||||
"__SHARED_FOLDERS__",
|
||||
@@ -533,6 +535,160 @@ fn resolve_harness_tag(content: String) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── The skills index, as it reaches (or does not reach) the prompt ───────
|
||||
//
|
||||
// These exercise `build_base` rather than the renderer, because the failure
|
||||
// they exist for is a wiring one: a sentinel with no substitution behind it
|
||||
// survives **textually** into the system prompt, and `build_base` replaces
|
||||
// only the keys it knows about.
|
||||
|
||||
/// One `agents/<id>/` with the prompt a case needs. Separate from the
|
||||
/// projection testkit's fixture, which is frozen for the snapshots.
|
||||
struct PromptFixture {
|
||||
id: String,
|
||||
dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl PromptFixture {
|
||||
fn new(prompt: &str) -> Self {
|
||||
let id = format!(
|
||||
"skills-prompt-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
);
|
||||
let dir = std::path::Path::new("agents").join(&id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("AGENT.md"), prompt).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("meta.json"),
|
||||
r#"{"name":"Fixture","description":"skills injection","type":"task"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
Self { id, dir }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PromptFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `{WD}` with a group-wide skill in it, plus the matching `UserFs`.
|
||||
struct SkillsTree {
|
||||
root: std::path::PathBuf,
|
||||
fs: core_api::user_fs::UserFs,
|
||||
}
|
||||
|
||||
impl SkillsTree {
|
||||
fn new(skills: &[(&str, &str)]) -> Self {
|
||||
use core_api::user_fs::{SkillMounts, UserFs};
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"skald-index-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let shared = root.join("skills");
|
||||
let own = root.join("skills-users").join("u1");
|
||||
std::fs::create_dir_all(&shared).unwrap();
|
||||
std::fs::create_dir_all(&own).unwrap();
|
||||
for (id, description) in skills {
|
||||
let dir = shared.join(id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("SKILL.md"),
|
||||
format!("---\nname: {id}\ndescription: {description}\n---\n\nBody.\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let fs = UserFs::new(
|
||||
"u1",
|
||||
root.join("homes").join("u1"),
|
||||
"skald-u1",
|
||||
std::path::PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.with_skills(SkillMounts {
|
||||
root_host: root.join(".skills-root").join("u1"),
|
||||
shared_host: shared,
|
||||
own_host: own,
|
||||
own_username: "daniele".into(),
|
||||
});
|
||||
Self { root, fs }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SkillsTree {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
async fn base_of(agent_id: &str, fs: core_api::user_fs::UserFs) -> String {
|
||||
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
|
||||
AgentSystemContext {
|
||||
agent_id: agent_id.to_string(),
|
||||
extra_static: None,
|
||||
extra_dynamic: None,
|
||||
tail_reminder: None,
|
||||
substitutions: HashMap::new(),
|
||||
pool: pool.clone(),
|
||||
shared_pool: pool,
|
||||
user_id: "u1".into(),
|
||||
mcp: crate::loop_adapters::testkit::mcp(),
|
||||
fs: SharedFs::new(fs),
|
||||
project_root: None,
|
||||
scratchpad_sid: 1,
|
||||
datetime: DatetimeConfig { enabled: false, timezone: None },
|
||||
prefix_cache: Arc::new(crate::loop_adapters::prefix_cache::PrefixCache::new()),
|
||||
}
|
||||
.build_base()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The sentinel is the knob: a prompt carrying it gets the index, and the
|
||||
/// sentinel itself never survives into the prompt.
|
||||
#[tokio::test]
|
||||
async fn a_prompt_with_the_sentinel_gets_the_index() {
|
||||
let agent = PromptFixture::new("You are a fixture.\n\n<!-- SKILLS_LIST -->\n");
|
||||
let tree = SkillsTree::new(&[("ics-import", "Import an iCalendar feed.")]);
|
||||
|
||||
let base = base_of(&agent.id, tree.fs.clone()).await;
|
||||
assert!(base.contains("## Skills (mandatory)"), "{base}");
|
||||
assert!(base.contains("skills/shared/ics-import/SKILL.md"), "{base}");
|
||||
assert!(base.contains("Import an iCalendar feed."), "{base}");
|
||||
assert!(!base.contains("__SKILLS_LIST__"), "sentinel survived: {base}");
|
||||
}
|
||||
|
||||
/// A prompt without the sentinel is byte-identical to what it was before the
|
||||
/// feature existed — which is how the four `type: system` agents opt out.
|
||||
#[tokio::test]
|
||||
async fn a_prompt_without_the_sentinel_is_untouched() {
|
||||
let agent = PromptFixture::new("You are a fixture.\n");
|
||||
let tree = SkillsTree::new(&[("ics-import", "Import an iCalendar feed.")]);
|
||||
|
||||
let base = base_of(&agent.id, tree.fs.clone()).await;
|
||||
assert_eq!(base, "You are a fixture.\n");
|
||||
}
|
||||
|
||||
/// Nothing installed ⇒ the sentinel resolves to **nothing**: no header, no
|
||||
/// orphan sentence promising a list that isn't there. That promise is what
|
||||
/// once had the model inventing a discovery tool for the MCP section.
|
||||
#[tokio::test]
|
||||
async fn with_no_skills_the_sentinel_resolves_to_nothing() {
|
||||
let agent = PromptFixture::new("Before.\n\n<!-- SKILLS_LIST -->\n\nAfter.\n");
|
||||
let tree = SkillsTree::new(&[]);
|
||||
|
||||
let base = base_of(&agent.id, tree.fs.clone()).await;
|
||||
assert_eq!(base, "Before.\n\n\n\nAfter.\n");
|
||||
assert!(!base.contains("__SKILLS_LIST__"), "{base}");
|
||||
assert!(!base.to_lowercase().contains("skill"), "{base}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harness_tag_resolves_to_canonical_tag() {
|
||||
// Every occurrence of the sentinel is replaced with the tag emitted by
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
//! builder, the snapshots outlived it.
|
||||
//!
|
||||
//! Everything volatile is neutralized here rather than scrubbed afterwards:
|
||||
//! the datetime block is disabled, the agent opts out of the skills index, and
|
||||
//! the fixture's own identifiers never reach the wire.
|
||||
//! the datetime block is disabled, the fixture's prompt carries no
|
||||
//! `<!-- SKILLS_LIST -->` (so no index is rendered into it), and the fixture's
|
||||
//! own identifiers never reach the wire.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
@@ -27,7 +28,7 @@ use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use core_api::message_meta::{Attachment, MessageMetadata};
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::{SharedFs, UserFs};
|
||||
|
||||
use crate::config::DatetimeConfig;
|
||||
use crate::llm::DtlMode;
|
||||
@@ -85,7 +86,6 @@ impl AgentFixture {
|
||||
"name": "Parity fixture",
|
||||
"description": "projection parity",
|
||||
"type": "task",
|
||||
"inject_skills": false,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
@@ -221,6 +221,17 @@ pub async fn project(db: &Db, agent: &AgentFixture, case: &Case) -> Vec<Value> {
|
||||
shared_pool: db.pool.clone(),
|
||||
user_id: "u1".into(),
|
||||
mcp: mcp(),
|
||||
// Skill-less by construction: the fixture's prompt has no sentinel, so
|
||||
// nothing here is ever rendered from it.
|
||||
fs: SharedFs::new(UserFs::new(
|
||||
"u1",
|
||||
PathBuf::from("/wd/homes/u1"),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)),
|
||||
project_root: None,
|
||||
scratchpad_sid: 1,
|
||||
datetime: datetime(),
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct RunContext {
|
||||
#[serde(default)]
|
||||
pub allow_fs_writes: Vec<String>,
|
||||
/// Extra directories/files granted read-only access (beyond the working directory,
|
||||
/// `docs/`, `skills/`, and everything in `allow_fs_writes`, which is readable too).
|
||||
/// `docs/`, and everything in `allow_fs_writes`, which is readable too).
|
||||
#[serde(default)]
|
||||
pub allow_fs_reads: Vec<String>,
|
||||
/// Project root (agent path `projects/{owner}/{slug}`) when this is a project
|
||||
@@ -70,7 +70,7 @@ impl RunContext {
|
||||
|
||||
/// True if reading `path` is pre-authorized by this RunContext.
|
||||
/// Read access is granted (no approval prompt) for: the process working directory
|
||||
/// itself, its `docs/` and `skills/` subtrees (always-safe baseline), any
|
||||
/// itself and its `docs/` subtree (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.
|
||||
///
|
||||
@@ -84,7 +84,6 @@ impl RunContext {
|
||||
let mut roots: Vec<std::path::PathBuf> = vec![
|
||||
canonicalize_for_policy(".", &wd), // process working directory
|
||||
canonicalize_for_policy("docs", &wd),
|
||||
canonicalize_for_policy("skills", &wd),
|
||||
];
|
||||
roots.extend(self.allow_fs_reads.iter().map(|e| canonicalize_for_policy(e, &wd)));
|
||||
roots.extend(self.allow_fs_writes.iter().map(|e| canonicalize_for_policy(e, &wd)));
|
||||
|
||||
@@ -196,6 +196,33 @@ impl Skald {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuilds the frozen system prefix of the conversations a skills change made
|
||||
/// wrong (blueprint §6).
|
||||
///
|
||||
/// The prefix is normally left alone until its conversation has been idle for
|
||||
/// twenty minutes, which is right for an *injected file* and wrong for the
|
||||
/// **index**: an admin who installs a skill and then asks the assistant to use
|
||||
/// it would be told, at length and in good faith, that no such skill exists.
|
||||
///
|
||||
/// Called **directly** by the two skill tools, not through the system bus.
|
||||
/// Whoever writes a skill through a tool is inside this process and can say so;
|
||||
/// the bus (`SkillsChanged`) is for the other case — someone editing files on
|
||||
/// the box — where nothing in-process knows. A miss here is not a lost event,
|
||||
/// it is a user who is simply not logged in and whose next login builds a fresh
|
||||
/// prefix anyway.
|
||||
pub async fn invalidate_prompt_prefix(&self, scope: crate::skills::PromptScope) {
|
||||
for ctx in self.rt_user_contexts().all_live().await {
|
||||
let concerns = match &scope {
|
||||
// The group's tree is in everybody's index.
|
||||
crate::skills::PromptScope::Everyone => true,
|
||||
crate::skills::PromptScope::User(id) => *id == ctx.user_id,
|
||||
};
|
||||
if concerns {
|
||||
ctx.sessions.loop_runtime().invalidate_prefixes();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh every live user's global-connector access set in place — call after an
|
||||
/// admin enables/deletes a global connector or changes who may use it, so running
|
||||
/// sessions see it without a restart (the §7 MCP twin of the §6 fs remount). The
|
||||
|
||||
@@ -237,6 +237,21 @@ impl Tools {
|
||||
tool_registry.register(crate::tools::set_secret::SetSecret(Arc::clone(&models.secrets)));
|
||||
tool_registry.register(crate::tools::list_secrets::ListSecrets(Arc::clone(&models.secrets)));
|
||||
tool_registry.register(crate::tools::configure_plugin::ConfigurePlugin(Arc::clone(&integrations.plugin_manager)));
|
||||
// The whole write surface of the read-only skills trees (blueprint §7.3):
|
||||
// `Config`-category, so neither appears in a request's schema until
|
||||
// `activate_tools(["config"])` asks. They take the registry to read the
|
||||
// caller's role (`skill.manage` gates the group's scope) and the cell
|
||||
// `Skald::new` later fills, through which an installation reaches
|
||||
// conversations that are already running.
|
||||
tool_registry.register(crate::tools::skills::SkillRegister::new(
|
||||
Arc::clone(&rt.db), Arc::clone(&rt.prompt_prefixes)));
|
||||
tool_registry.register(crate::tools::skills::SkillDelete::new(
|
||||
Arc::clone(&rt.db), Arc::clone(&rt.prompt_prefixes)));
|
||||
// The download half of the skills lifecycle (blueprint §7.5): same
|
||||
// `Config` category, so it too stays out of every request's schema
|
||||
// until `activate_tools(["config"])`. It needs no state of its own —
|
||||
// the container it runs git in comes from each caller's `ToolContext`.
|
||||
tool_registry.register(crate::tools::fetch_repo::FetchRepo);
|
||||
|
||||
// Tools contributed by plugins (plugin.md §11), via `Plugin::tools()`.
|
||||
// The core never names a plugin crate: each one hands over whatever tools
|
||||
|
||||
@@ -31,7 +31,7 @@ use bundles::{Conversation, Infra, Integrations, Interaction, Media, Models, Tas
|
||||
use runtime::Runtime;
|
||||
use user_context::{UserContextFactory, UserContextRegistry};
|
||||
pub use user_context::UserContext;
|
||||
use wiring::{spawn_background, spawn_system_agents, spawn_user_lifecycle, wire};
|
||||
use wiring::{spawn_background, spawn_skills_freshness, spawn_system_agents, spawn_user_lifecycle, wire};
|
||||
|
||||
pub struct Skald {
|
||||
rt: Runtime,
|
||||
@@ -125,10 +125,20 @@ impl Skald {
|
||||
// can only be spawned once the instance exists (blueprint §6).
|
||||
spawn_user_lifecycle(&skald);
|
||||
|
||||
// And the same for the skills seam: the two tools were built with the cell
|
||||
// during composition; only now is there an instance able to answer it. A
|
||||
// `Weak`, like the reconciler's — the tools live in the registry `Skald`
|
||||
// owns, so a strong handle would be a cycle.
|
||||
skald.rt.prompt_prefixes.install(Arc::new(SkaldPromptPrefixes(Arc::downgrade(&skald))));
|
||||
|
||||
// Likewise the system-agent scheduler: it resolves a per-user runtime for
|
||||
// each user it runs an agent for (blueprint §13).
|
||||
spawn_system_agents(&skald);
|
||||
|
||||
// And the skills-freshness reactor: it reacts to the watcher's
|
||||
// `SkillsChanged` through `Skald`'s own accessor, same Weak shape (§8.3).
|
||||
spawn_skills_freshness(&skald);
|
||||
|
||||
Ok(skald)
|
||||
}
|
||||
|
||||
@@ -160,3 +170,20 @@ impl Skald {
|
||||
self.container.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The instance, seen from a skill tool that only wants to say "the index moved".
|
||||
///
|
||||
/// A `Weak` rather than a strong `Arc`: the tools holding the other end live in
|
||||
/// the registry `Skald` itself owns. An instance already on its way down simply
|
||||
/// stops upgrading, which is the right answer — there is nothing left to keep
|
||||
/// fresh.
|
||||
struct SkaldPromptPrefixes(std::sync::Weak<Skald>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::skills::PromptPrefixes for SkaldPromptPrefixes {
|
||||
async fn invalidate(&self, scope: crate::skills::PromptScope) {
|
||||
if let Some(skald) = self.0.upgrade() {
|
||||
skald.invalidate_prompt_prefix(scope).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ pub(super) struct Runtime {
|
||||
pub(super) global_tx: broadcast::Sender<GlobalEvent>,
|
||||
pub(super) shutdown_token: CancellationToken,
|
||||
pub(super) supervisor: Arc<TaskSupervisor>,
|
||||
/// How a skills write reaches conversations already running (blueprint §6).
|
||||
/// Held here because the two ends are built at different times: the tools
|
||||
/// that fill it exist during composition, the instance that answers it only
|
||||
/// afterwards — so `Skald::new` installs the reactor into this cell once it
|
||||
/// has itself, exactly like the plugin manager's `set_skald`.
|
||||
pub(super) prompt_prefixes: Arc<crate::skills::PromptPrefixCell>,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
@@ -78,6 +84,7 @@ impl Runtime {
|
||||
global_tx,
|
||||
shutdown_token: CancellationToken::new(),
|
||||
supervisor: TaskSupervisor::new(),
|
||||
prompt_prefixes: Arc::new(crate::skills::PromptPrefixCell::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,14 @@ pub(super) fn spawn_background(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Skills freshness for edits made by hand on the box (blueprint §8.2). The
|
||||
// in-process writers invalidate directly; this watches the two trees and
|
||||
// announces `SkillsChanged` only when the digest gate says the index moved.
|
||||
rt.supervisor.adopt_one(
|
||||
"skills-watch",
|
||||
crate::skills::watch::spawn(Arc::clone(&rt.system_bus), rt.shutdown_token.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawns the **user-lifecycle reconciler** — the single subscriber that turns
|
||||
@@ -174,6 +182,48 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the **skills-freshness reactor** — the subscriber that turns a
|
||||
/// `SkillsChanged` announcement into a prompt-prefix invalidation (blueprint
|
||||
/// §8.3).
|
||||
///
|
||||
/// Why the bus at all, when the skill tools call `invalidate_prompt_prefix`
|
||||
/// directly: the watcher exists for a writer *outside* the process (a hand
|
||||
/// edit on the box), and its consumers live in different places — the per-user
|
||||
/// loop runtimes here, a UI refresh later. A direct call would make the
|
||||
/// watcher hold `Skald`, which it deliberately does not. Best-effort by
|
||||
/// contract, and honestly so: a lost event costs a stale skill index for the
|
||||
/// twenty minutes of the prefix TTL, never a wrong answer.
|
||||
pub(super) fn spawn_skills_freshness(skald: &Arc<super::Skald>) {
|
||||
let weak = Arc::downgrade(skald);
|
||||
let shutdown = skald.rt.shutdown_token.clone();
|
||||
let mut rx = skald.rt.system_bus.subscribe();
|
||||
|
||||
skald.rt.supervisor.spawn("skills-freshness", async move {
|
||||
loop {
|
||||
let event = tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
event = rx.recv() => match event {
|
||||
Ok(e) => e,
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
warn!(n, "skills-freshness: system_bus lagged; a skill index may be stale until the prefix TTL");
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => break,
|
||||
},
|
||||
};
|
||||
|
||||
let SystemEvent::SkillsChanged { scope } = event else { continue };
|
||||
let Some(skald) = weak.upgrade() else { break };
|
||||
let scope = match scope {
|
||||
core_api::system_bus::SkillScope::Global => crate::skills::PromptScope::Everyone,
|
||||
core_api::system_bus::SkillScope::User(id) => crate::skills::PromptScope::User(id),
|
||||
};
|
||||
skald.invalidate_prompt_prefix(scope).await;
|
||||
}
|
||||
info!("skills-freshness: reactor stopped");
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the **system-agent scheduler** — the one instance-wide timer behind
|
||||
/// every background agent nobody asked for (event triage, the two memory lints).
|
||||
///
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! The one door into the two read-only trees (blueprint §7.3/§9).
|
||||
//!
|
||||
//! Everything here exists because a skill is an **immutable, validated
|
||||
//! artefact**: it is copied in whole or not at all, it is never edited in place,
|
||||
//! and modifying one means registering it again. The tree therefore never holds
|
||||
//! a half-written skill, which is what lets the index (`super::list`) read it
|
||||
//! without tolerating intermediate states.
|
||||
//!
|
||||
//! Two mechanics carry that promise and neither is optional:
|
||||
//!
|
||||
//! - **Staging plus a rename**, never a copy in place, so the indexer cannot
|
||||
//! observe a directory being filled.
|
||||
//! - **Three steps on replacement**, because `rename` over a non-empty directory
|
||||
//! fails: move the old one aside, move the new one in, delete the old. Not
|
||||
//! atomic in the strict sense — but the uncovered window contains only a state
|
||||
//! where the id *does not exist*, never one where it exists half-written, and
|
||||
//! that is the property the indexer actually needs.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use core_api::user_fs::UserFs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::validate::{ValidSkill, validate_dir};
|
||||
use super::{SKILL_FILE, Scope};
|
||||
|
||||
/// The provenance ticket a fetched skill carries, written next to its files.
|
||||
///
|
||||
/// The seam between two tools that deliberately know nothing about each other:
|
||||
/// `fetch_repo` (blueprint §7.5, session 4) downloads without knowing what it
|
||||
/// downloaded, `skill_register` installs without knowing where it came from, and
|
||||
/// this file is what crosses between them. `git clone` leaves nothing traceable,
|
||||
/// so without it neither "where is this skill from?" nor "has it changed
|
||||
/// upstream?" has an answer later.
|
||||
///
|
||||
/// It is *pinning*, not authenticity — whoever serves the repository serves the
|
||||
/// commit too — the same honesty the marketplace states about its digests.
|
||||
pub const SOURCE_FILE: &str = ".source.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sub_path: Option<String>,
|
||||
/// The commit the files were taken from — the field that makes a later
|
||||
/// upstream change *detectable*.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub commit: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fetched_at: Option<String>,
|
||||
/// Stamped by [`install`], so the ticket answers "since when is this here?"
|
||||
/// as well as "where from?".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub installed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads the ticket a source folder carries, if it carries one. A malformed one
|
||||
/// is *ignored*, never fatal: provenance is metadata about the skill, and losing
|
||||
/// it must not stop an otherwise valid installation.
|
||||
pub fn read_provenance(dir: &Path) -> Option<Provenance> {
|
||||
let raw = std::fs::read_to_string(dir.join(SOURCE_FILE)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// What an installation did, for the tool's answer to the model.
|
||||
pub struct Installed {
|
||||
pub id: String,
|
||||
/// The agent path of the installed folder (`skills/shared/ics-import`).
|
||||
pub agent_dir: String,
|
||||
/// Whether it took the place of a skill with the same id in the same scope.
|
||||
pub replaced: bool,
|
||||
}
|
||||
|
||||
/// The host directory backing one scope of this user's tree.
|
||||
pub fn tree_of(fs: &UserFs, scope: Scope) -> Result<&Path> {
|
||||
let sk = fs.skills.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("skills are not available in this context")
|
||||
})?;
|
||||
Ok(match scope {
|
||||
Scope::Shared => sk.shared_host.as_path(),
|
||||
Scope::Own => sk.own_host.as_path(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The agent-visible scope segment (`shared`, or the caller's username).
|
||||
pub fn segment_of(fs: &UserFs, scope: Scope) -> Result<&str> {
|
||||
let sk = fs.skills.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("skills are not available in this context")
|
||||
})?;
|
||||
Ok(match scope {
|
||||
Scope::Shared => core_api::user_fs::SKILLS_SHARED_SCOPE,
|
||||
Scope::Own => sk.own_username.as_str(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates `source` and installs it into `scope`, replacing an existing skill
|
||||
/// of the same id in that scope.
|
||||
///
|
||||
/// The source is copied *before* anything at the destination moves, so
|
||||
/// registering a skill onto itself (`skill_register("global",
|
||||
/// "skills/daniele/foo")` — the promotion path, which is deliberately the same
|
||||
/// call rather than an endpoint of its own) needs no special case.
|
||||
pub fn install(fs: &UserFs, scope: Scope, source: &Path) -> Result<Installed> {
|
||||
let valid = validate_dir(source)?;
|
||||
install_validated(fs, scope, source, &valid)
|
||||
}
|
||||
|
||||
fn install_validated(
|
||||
fs: &UserFs,
|
||||
scope: Scope,
|
||||
source: &Path,
|
||||
valid: &ValidSkill,
|
||||
) -> Result<Installed> {
|
||||
let tree = tree_of(fs, scope)?.to_path_buf();
|
||||
std::fs::create_dir_all(&tree)
|
||||
.with_context(|| format!("cannot open the skills tree at {}", tree.display()))?;
|
||||
|
||||
let target = tree.join(&valid.id);
|
||||
let replaced = target.exists();
|
||||
let tag = uuid::Uuid::new_v4().simple().to_string();
|
||||
// Staging lives **inside the destination tree**: `rename` only works within
|
||||
// one filesystem, and a dot-directory is skipped by the indexer (see
|
||||
// `super::collect`), so a crash mid-copy leaves litter, never a skill.
|
||||
let staging = tree.join(format!(".staging-{tag}"));
|
||||
|
||||
let outcome = (|| -> Result<()> {
|
||||
copy_tree(source, &staging)?;
|
||||
stamp_provenance(source, &staging);
|
||||
if replaced {
|
||||
let parked = tree.join(format!(".old-{tag}"));
|
||||
std::fs::rename(&target, &parked)
|
||||
.with_context(|| format!("cannot replace the installed `{}`", valid.id))?;
|
||||
// From here the id does not exist. If the second rename fails we put
|
||||
// the old one back rather than leave the scope short of a skill it
|
||||
// had a moment ago.
|
||||
if let Err(e) = std::fs::rename(&staging, &target) {
|
||||
let _ = std::fs::rename(&parked, &target);
|
||||
return Err(anyhow::Error::new(e)
|
||||
.context(format!("cannot install `{}`", valid.id)));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&parked);
|
||||
} else {
|
||||
std::fs::rename(&staging, &target)
|
||||
.with_context(|| format!("cannot install `{}`", valid.id))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if outcome.is_err() {
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
}
|
||||
outcome?;
|
||||
|
||||
Ok(Installed {
|
||||
id: valid.id.clone(),
|
||||
agent_dir: format!(
|
||||
"{}/{}/{}",
|
||||
core_api::user_fs::SKILLS_ROOT,
|
||||
segment_of(fs, scope)?,
|
||||
valid.id
|
||||
),
|
||||
replaced,
|
||||
})
|
||||
}
|
||||
|
||||
/// Carries the provenance ticket across, stamping the install date. Best-effort
|
||||
/// for the same reason [`read_provenance`] is tolerant: this is a label on the
|
||||
/// artefact, not part of it.
|
||||
fn stamp_provenance(source: &Path, staging: &Path) {
|
||||
let Some(mut p) = read_provenance(source) else { return };
|
||||
p.installed_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
if let Ok(json) = serde_json::to_string_pretty(&p) {
|
||||
let _ = std::fs::write(staging.join(SOURCE_FILE), json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies a validated tree, refusing links again on the way.
|
||||
///
|
||||
/// The re-check is not redundant paranoia about the walk in `validate`: the
|
||||
/// source folder is writable by the caller and by their container, so between
|
||||
/// the two passes it can change. The cheap answer is to never follow a link at
|
||||
/// either point.
|
||||
///
|
||||
/// Shared with `fetch_repo`, whose staging area is writable by the caller's
|
||||
/// container for exactly as long and so needs exactly the same guarantee.
|
||||
pub(crate) fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(to)
|
||||
.with_context(|| format!("cannot create {}", to.display()))?;
|
||||
for entry in std::fs::read_dir(from)
|
||||
.with_context(|| format!("cannot read {}", from.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let src = entry.path();
|
||||
let dst = to.join(entry.file_name());
|
||||
let meta = std::fs::symlink_metadata(&src)?;
|
||||
if meta.file_type().is_symlink() {
|
||||
bail!("`{}` is a symbolic link", entry.file_name().to_string_lossy());
|
||||
}
|
||||
if meta.is_dir() {
|
||||
copy_tree(&src, &dst)?;
|
||||
} else {
|
||||
std::fs::copy(&src, &dst)
|
||||
.with_context(|| format!("cannot copy {}", src.display()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes an installed skill. No recycle bin, deliberately: the source it was
|
||||
/// registered from almost always still exists, and a bin would be a second tree
|
||||
/// to index, mount and explain.
|
||||
pub fn remove(fs: &UserFs, scope: Scope, id: &str) -> Result<()> {
|
||||
let tree = tree_of(fs, scope)?;
|
||||
// The id names a directory *inside* the tree and nothing else — a `/` or a
|
||||
// `..` here would be a path, and paths are not ids.
|
||||
if id.is_empty() || id.contains('/') || id.contains('\\') || id.starts_with('.') {
|
||||
bail!("`{id}` is not a skill id (it is the folder name, e.g. `ics-import`)");
|
||||
}
|
||||
let dir = tree.join(id);
|
||||
if !dir.is_dir() {
|
||||
bail!(
|
||||
"no skill `{id}` in {}/{}/",
|
||||
core_api::user_fs::SKILLS_ROOT,
|
||||
segment_of(fs, scope)?
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).with_context(|| format!("cannot remove `{id}`"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── The approval card ─────────────────────────────────────────────────────────
|
||||
|
||||
/// What the human is shown before a registration goes through: `(old, new)` for
|
||||
/// the diff card the write tools already use.
|
||||
///
|
||||
/// This is the review moment blueprint §9.1 calls the point of the whole design
|
||||
/// — for the group's scope it is the **only** time a person reads a text that
|
||||
/// will enter everybody's prompt — so `new` is the candidate's `SKILL.md` in
|
||||
/// full, not a summary of it. When the id already exists, `old` is the installed
|
||||
/// body, which turns the card into a diff of what actually changes.
|
||||
///
|
||||
/// `None` when the source cannot be read or does not validate: the card then
|
||||
/// falls back to the generic approval event, and the refusal comes from the tool
|
||||
/// itself with its own message.
|
||||
pub fn preview(fs: &UserFs, scope: Scope, source: &Path) -> Option<(String, Option<String>, String)> {
|
||||
let valid = validate_dir(source).ok()?;
|
||||
let tree = tree_of(fs, scope).ok()?;
|
||||
let target = tree.join(&valid.id);
|
||||
let old = std::fs::read_to_string(target.join(SKILL_FILE)).ok();
|
||||
|
||||
let agent_dir = format!(
|
||||
"{}/{}/{}",
|
||||
core_api::user_fs::SKILLS_ROOT,
|
||||
segment_of(fs, scope).ok()?,
|
||||
valid.id
|
||||
);
|
||||
let header = card_header(&valid, scope, old.is_some(), &agent_dir);
|
||||
let body = std::fs::read_to_string(source.join(SKILL_FILE)).ok()?;
|
||||
|
||||
Some((
|
||||
agent_dir,
|
||||
old.map(|o| format!("{}\n{o}", card_header(&valid, scope, true, ""))),
|
||||
format!("{header}\n{body}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn card_header(valid: &ValidSkill, scope: Scope, replacing: bool, _dir: &str) -> String {
|
||||
let what = if replacing { "REPLACES the installed skill" } else { "new skill" };
|
||||
let audience = match scope {
|
||||
Scope::Shared => "the whole group — every member reads it as instructions",
|
||||
Scope::Own => "you only",
|
||||
};
|
||||
let deps = valid.deps().unwrap_or_else(|| "none".into());
|
||||
format!(
|
||||
"<!-- {what}: `{}` · visible to {audience}\n \
|
||||
{} files, {} KB · scripts: {} · dependency manifest: {deps}\n \
|
||||
files: {} -->\n",
|
||||
valid.id,
|
||||
valid.files.len(),
|
||||
valid.size_bytes.div_ceil(1024),
|
||||
if valid.has_scripts() { "yes" } else { "no" },
|
||||
valid
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::skills::tests_support::Tree;
|
||||
|
||||
fn front(name: &str, description: &str) -> String {
|
||||
format!("---\nname: {name}\ndescription: {description}\n---\n\nBody of {name}.\n")
|
||||
}
|
||||
|
||||
/// The installed folder is named by the frontmatter, and the index picks it
|
||||
/// up straight away.
|
||||
#[test]
|
||||
fn a_draft_folder_installs_under_its_declared_name() {
|
||||
let t = Tree::new("install-name", "daniele");
|
||||
let src = t.root.join("homes/u1/draft-2");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join(SKILL_FILE), front("ics-import", "Import an ICS feed.")).unwrap();
|
||||
|
||||
let got = install(&t.fs, Scope::Own, &src).unwrap();
|
||||
assert_eq!(got.id, "ics-import");
|
||||
assert_eq!(got.agent_dir, "skills/daniele/ics-import");
|
||||
assert!(!got.replaced);
|
||||
|
||||
let listed = crate::skills::list(&t.fs);
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].skill_file(), "skills/daniele/ics-import/SKILL.md");
|
||||
}
|
||||
|
||||
/// Re-registering the same id replaces it, and nothing of the old copy
|
||||
/// survives — the artefact is whole or absent, never merged.
|
||||
#[test]
|
||||
fn re_registering_replaces_without_leaving_the_old_files() {
|
||||
let t = Tree::new("install-replace", "daniele");
|
||||
let src = t.root.join("homes/u1/v1");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join(SKILL_FILE), front("x", "First.")).unwrap();
|
||||
std::fs::write(src.join("old-helper.py"), "1").unwrap();
|
||||
install(&t.fs, Scope::Own, &src).unwrap();
|
||||
|
||||
let src2 = t.root.join("homes/u1/v2");
|
||||
std::fs::create_dir_all(&src2).unwrap();
|
||||
std::fs::write(src2.join(SKILL_FILE), front("x", "Second.")).unwrap();
|
||||
let got = install(&t.fs, Scope::Own, &src2).unwrap();
|
||||
|
||||
assert!(got.replaced);
|
||||
let installed = t.root.join("skills-users/u1/x");
|
||||
assert!(!installed.join("old-helper.py").exists());
|
||||
assert_eq!(crate::skills::list(&t.fs)[0].description, "Second.");
|
||||
// No staging or parked leftovers.
|
||||
let stray: Vec<_> = std::fs::read_dir(t.root.join("skills-users/u1"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with('.'))
|
||||
.collect();
|
||||
assert!(stray.is_empty(), "leftovers: {stray:?}");
|
||||
}
|
||||
|
||||
/// Promotion is the same call with a different scope, and its source is the
|
||||
/// already-installed copy — which the copy-first ordering makes safe.
|
||||
#[test]
|
||||
fn promoting_ones_own_skill_to_the_group_is_the_same_call() {
|
||||
let t = Tree::new("install-promote", "daniele");
|
||||
let src = t.root.join("homes/u1/draft");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join(SKILL_FILE), front("x", "Mine.")).unwrap();
|
||||
install(&t.fs, Scope::Own, &src).unwrap();
|
||||
|
||||
let mine = t.root.join("skills-users/u1/x");
|
||||
install(&t.fs, Scope::Shared, &mine).unwrap();
|
||||
|
||||
let ids: Vec<(String, String)> = crate::skills::list(&t.fs)
|
||||
.into_iter()
|
||||
.map(|s| (s.scope, s.id))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![("shared".into(), "x".into()), ("daniele".into(), "x".into())]
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused source leaves the tree exactly as it was — the validation runs
|
||||
/// before a single byte is copied.
|
||||
#[test]
|
||||
fn an_invalid_source_touches_nothing() {
|
||||
let t = Tree::new("install-invalid", "daniele");
|
||||
let src = t.root.join("homes/u1/broken");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join("notes.txt"), "no frontmatter here").unwrap();
|
||||
|
||||
assert!(install(&t.fs, Scope::Own, &src).is_err());
|
||||
assert!(crate::skills::list(&t.fs).is_empty());
|
||||
assert_eq!(std::fs::read_dir(t.root.join("skills-users/u1")).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provenance_ticket_crosses_into_the_installed_copy() {
|
||||
let t = Tree::new("install-prov", "daniele");
|
||||
let src = t.root.join("homes/u1/fetched");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join(SKILL_FILE), front("x", "y")).unwrap();
|
||||
std::fs::write(
|
||||
src.join(SOURCE_FILE),
|
||||
r#"{"url":"https://example.invalid/r","commit":"a1b2c3d"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
install(&t.fs, Scope::Own, &src).unwrap();
|
||||
let p = read_provenance(&t.root.join("skills-users/u1/x")).unwrap();
|
||||
assert_eq!(p.commit.as_deref(), Some("a1b2c3d"));
|
||||
assert!(p.installed_at.is_some(), "install date not stamped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_the_folder_and_refuses_a_path() {
|
||||
let t = Tree::new("install-delete", "daniele");
|
||||
let src = t.root.join("homes/u1/d");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join(SKILL_FILE), front("x", "y")).unwrap();
|
||||
install(&t.fs, Scope::Own, &src).unwrap();
|
||||
|
||||
assert!(remove(&t.fs, Scope::Own, "../../etc").is_err());
|
||||
assert!(remove(&t.fs, Scope::Own, "nope").is_err());
|
||||
remove(&t.fs, Scope::Own, "x").unwrap();
|
||||
assert!(crate::skills::list(&t.fs).is_empty());
|
||||
}
|
||||
|
||||
/// The card shows the body that will enter the prompt, and on a replacement
|
||||
/// it shows the one being replaced — so the human reads a diff, not a name.
|
||||
#[test]
|
||||
fn the_card_carries_the_whole_skill_body() {
|
||||
let t = Tree::new("install-card", "daniele");
|
||||
let src = t.root.join("homes/u1/c");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::fs::write(src.join(SKILL_FILE), front("x", "y")).unwrap();
|
||||
|
||||
let (path, old, new) = preview(&t.fs, Scope::Shared, &src).unwrap();
|
||||
assert_eq!(path, "skills/shared/x");
|
||||
assert!(old.is_none());
|
||||
assert!(new.contains("Body of x."), "{new}");
|
||||
assert!(new.contains("every member reads it as instructions"), "{new}");
|
||||
|
||||
install(&t.fs, Scope::Shared, &src).unwrap();
|
||||
let (_, old, _) = preview(&t.fs, Scope::Shared, &src).unwrap();
|
||||
assert!(old.unwrap().contains("Body of x."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! The administrative view of the two trees — what `list_items(type="skills")`
|
||||
//! returns (blueprint §7.7).
|
||||
//!
|
||||
//! **Not the index, and the distinction is worth keeping sharp.** The index is
|
||||
//! for *deciding*: path plus a cut description, the minimum needed to tell
|
||||
//! whether a skill bears on the request, injected into every prompt and paid for
|
||||
//! in tokens on every request of every user. This is for *administering*: the
|
||||
//! full description, size, health and provenance, as JSON, only when asked. One
|
||||
//! is always there and thin; the other is on demand and complete.
|
||||
//!
|
||||
//! Two consequences fall out of that split:
|
||||
//!
|
||||
//! - The **description is not truncated here.** Truncate in both places and the
|
||||
//! full text becomes unreadable anywhere, while this tool exists precisely to
|
||||
//! be the place it can be read. The real ceiling belongs at registration
|
||||
//! ([`super::validate::DESCRIPTION_MAX`]), where the author is present and the
|
||||
//! refusal is useful.
|
||||
//! - **A broken skill appears here**, with the reason. The index skips it —
|
||||
//! correctly, since it cannot be trusted to describe itself — but then nothing
|
||||
//! would say *why* a folder placed on the box never showed up.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use core_api::user_fs::{SKILLS_ROOT, SKILLS_SHARED_SCOPE, UserFs};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::install::read_provenance;
|
||||
use super::validate::validate_dir;
|
||||
use super::{DESCRIPTION_LIMIT, SKILL_FILE, parse_front_matter};
|
||||
|
||||
/// Every skill folder visible to this user — valid or not — as JSON rows, in the
|
||||
/// index's order (group's tree first, then their own, each by id).
|
||||
pub fn report(fs: &UserFs) -> Vec<Value> {
|
||||
let Some(sk) = &fs.skills else { return Vec::new() };
|
||||
|
||||
let mut rows: Vec<(String, String, Value)> = Vec::new();
|
||||
for (scope, host) in [
|
||||
(SKILLS_SHARED_SCOPE, sk.shared_host.as_path()),
|
||||
(sk.own_username.as_str(), sk.own_host.as_path()),
|
||||
] {
|
||||
let Ok(entries) = std::fs::read_dir(host) else { continue };
|
||||
let mut ids: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().to_str().map(str::to_string))
|
||||
// Dot-directories are plumbing (a staging leftover), never a skill.
|
||||
.filter(|id| !id.starts_with('.'))
|
||||
.collect();
|
||||
ids.sort();
|
||||
for id in ids {
|
||||
let row = inspect(scope, &host.join(&id), &id);
|
||||
rows.push((id, scope.to_string(), row));
|
||||
}
|
||||
}
|
||||
|
||||
// A collision is a property of the *pair*, so it can only be marked once
|
||||
// both trees have been read — the same reason the index marks both lines.
|
||||
let colliding: std::collections::HashSet<String> = rows
|
||||
.iter()
|
||||
.filter(|(id, scope, _)| rows.iter().any(|(o, os, _)| o == id && os != scope))
|
||||
.map(|(id, _, _)| id.clone())
|
||||
.collect();
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, _, mut row)| {
|
||||
row["collision"] = Value::Bool(colliding.contains(&id));
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One folder, described as fully as it allows itself to be.
|
||||
fn inspect(scope: &str, dir: &Path, id: &str) -> Value {
|
||||
let path = format!("{SKILLS_ROOT}/{scope}/{id}");
|
||||
let mut row = json!({
|
||||
"id": id,
|
||||
"scope": scope,
|
||||
"path": path,
|
||||
"valid": false,
|
||||
"problem": Value::Null,
|
||||
});
|
||||
|
||||
// Validity here means **what the index does**: does its frontmatter parse?
|
||||
// The structural rules (`validate_dir`) are a stricter set — they gate what
|
||||
// may be *installed* — so a folder that fails them but parses is still shown
|
||||
// in the prompt and must be reported as such, with the extra problem named.
|
||||
let body = match std::fs::read_to_string(dir.join(SKILL_FILE)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
row["problem"] = json!(format!("no readable {SKILL_FILE}: {e}"));
|
||||
return row;
|
||||
}
|
||||
};
|
||||
let front = match parse_front_matter(&body) {
|
||||
Ok(f) => f,
|
||||
Err(problem) => {
|
||||
row["problem"] = json!(format!("invalid frontmatter: {problem}"));
|
||||
return row;
|
||||
}
|
||||
};
|
||||
|
||||
row["valid"] = Value::Bool(true);
|
||||
row["description"] = json!(front.description);
|
||||
row["truncated_in_index"] =
|
||||
Value::Bool(front.description.chars().count() > DESCRIPTION_LIMIT);
|
||||
if front.name != id {
|
||||
row["problem"] = json!(format!(
|
||||
"the frontmatter `name` is `{}` but the folder is `{id}`; the folder wins",
|
||||
front.name
|
||||
));
|
||||
}
|
||||
|
||||
match validate_dir(dir) {
|
||||
Ok(v) => {
|
||||
row["files"] = json!(v.files.len());
|
||||
row["size_bytes"] = json!(v.size_bytes);
|
||||
row["has_scripts"] = Value::Bool(v.has_scripts());
|
||||
row["deps"] = v.deps().map(Value::String).unwrap_or(Value::Null);
|
||||
}
|
||||
// Reachable only for a folder placed by hand: everything installed
|
||||
// through `skill_register` passed this very check.
|
||||
Err(e) => row["problem"] = json!(e.to_string()),
|
||||
}
|
||||
|
||||
if let Some(p) = read_provenance(dir) {
|
||||
row["source_url"] = json!(p.url);
|
||||
row["commit"] = p.commit.map(Value::String).unwrap_or(Value::Null);
|
||||
row["installed_at"] = p.installed_at.map(Value::String).unwrap_or(Value::Null);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::skills::tests_support::{Tree, valid};
|
||||
|
||||
/// The full description survives here even when the index cut it — this is
|
||||
/// the one place it can be read whole.
|
||||
#[test]
|
||||
fn the_description_is_reported_untruncated_and_flagged() {
|
||||
let long = "d".repeat(DESCRIPTION_LIMIT + 40);
|
||||
let t = Tree::new("inv-desc", "daniele");
|
||||
t.write("shared", "x", &valid("x", &long));
|
||||
|
||||
let rows = report(&t.fs);
|
||||
assert_eq!(rows[0]["description"], json!(long));
|
||||
assert_eq!(rows[0]["truncated_in_index"], json!(true));
|
||||
assert_eq!(rows[0]["path"], json!("skills/shared/x"));
|
||||
}
|
||||
|
||||
/// A folder the index skipped still shows up, with the reason — otherwise
|
||||
/// nothing anywhere answers "why did my skill never appear?".
|
||||
#[test]
|
||||
fn a_broken_skill_is_reported_with_its_problem() {
|
||||
let t = Tree::new("inv-broken", "daniele");
|
||||
t.write("shared", "good", &valid("good", "Works."));
|
||||
t.write("shared", "broken", "no frontmatter at all\n");
|
||||
|
||||
let rows = report(&t.fs);
|
||||
assert_eq!(rows.len(), 2);
|
||||
let broken = rows.iter().find(|r| r["id"] == json!("broken")).unwrap();
|
||||
assert_eq!(broken["valid"], json!(false));
|
||||
assert!(broken["problem"].as_str().unwrap().contains("frontmatter"));
|
||||
// …and the index really did skip it, so the two views agree on the facts
|
||||
// while disagreeing on what they show.
|
||||
assert_eq!(crate::skills::list(&t.fs).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colliding_id_is_marked_on_both_rows() {
|
||||
let t = Tree::new("inv-collide", "daniele");
|
||||
t.write("shared", "x", &valid("x", "Group's."));
|
||||
t.write("mine", "x", &valid("x", "Mine."));
|
||||
t.write("mine", "y", &valid("y", "Untouched."));
|
||||
|
||||
let rows = report(&t.fs);
|
||||
for r in &rows {
|
||||
let expect = r["id"] == json!("x");
|
||||
assert_eq!(r["collision"], json!(expect), "{r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! The skills index — **the only thing about a skill that reaches the prompt**.
|
||||
//!
|
||||
//! A skill is a folder with a `SKILL.md` in it, living in one of the two trees
|
||||
//! `UserFs` mounts read-only (`skills/shared/<id>` and `skills/<username>/<id>`,
|
||||
//! see [`core_api::user_fs::SkillMounts`]). Nothing here is a manager in the usual
|
||||
//! sense: this module is a set of **pure functions over those two paths**, in the
|
||||
//! shape of `LlmCommandManager` and for the same reason — the list must be a
|
||||
//! function of the content, never a file someone maintains by hand, or it diverges
|
||||
//! at the first skill added.
|
||||
//!
|
||||
//! What reaches the model is deliberately thin (blueprint §5, progressive
|
||||
//! disclosure): the **path** of each `SKILL.md` plus a truncated `description`.
|
||||
//! The body is never injected — the model reads it with `read_file` when the
|
||||
//! description matches. Carrying the full path rather than an id plus a
|
||||
//! composition rule is what makes a dedicated read tool unnecessary: it costs a
|
||||
//! few tokens per line and removes the one step the model can get wrong on its own.
|
||||
//!
|
||||
//! Three properties are load-bearing, and each is here because the alternative
|
||||
//! fails in a specific way:
|
||||
//!
|
||||
//! - **A stable order** (scope, then id). The index sits inside the string every
|
||||
//! provider uses as its cache key, so a non-deterministic order would cost a
|
||||
//! miss on every rebuild.
|
||||
//! - **A deterministic cut at the budget**, closed by an explicit omission line.
|
||||
//! Without the determinism the stable order stops buying a stable cache key;
|
||||
//! without the line the model reads a truncated list *believing it complete* and
|
||||
//! concludes in good faith that a skill does not exist.
|
||||
//! - **A broken skill is skipped, never fatal.** The index is built while
|
||||
//! assembling a system prompt; one malformed frontmatter must not take a
|
||||
//! conversation down with it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use core_api::user_fs::{SKILLS_ROOT, SKILLS_SHARED_SCOPE, UserFs};
|
||||
use tracing::warn;
|
||||
|
||||
pub mod install;
|
||||
pub mod inventory;
|
||||
pub mod validate;
|
||||
pub mod watch;
|
||||
|
||||
/// The file that makes a directory a skill.
|
||||
pub const SKILL_FILE: &str = "SKILL.md";
|
||||
|
||||
/// Which of a user's two trees an operation addresses.
|
||||
///
|
||||
/// The tools take `"mine"` / `"global"` rather than a username, and that is
|
||||
/// deliberate (blueprint §4.1): a tool argument naming the caller invites
|
||||
/// passing somebody *else's* name, which the server would then have to ignore.
|
||||
/// Human-readable path, stable argument for the machine.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
/// `skills/{username}/…` — the caller's own.
|
||||
Own,
|
||||
/// `skills/shared/…` — the group's, gated by the `skill.manage` capability.
|
||||
Shared,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"mine" => Ok(Scope::Own),
|
||||
"global" => Ok(Scope::Shared),
|
||||
other => anyhow::bail!(
|
||||
"unknown scope `{other}`: use \"mine\" (your own skills) or \"global\" \
|
||||
(the whole group's)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The spelling the tools take and the model reads back.
|
||||
pub fn as_arg(self) -> &'static str {
|
||||
match self {
|
||||
Scope::Own => "mine",
|
||||
Scope::Shared => "global",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt freshness (blueprint §6) ──────────────────────────────────────────
|
||||
|
||||
/// Whose system prompts a skills change has made stale.
|
||||
///
|
||||
/// Distinct from [`Scope`], which says *where a write went*: a write to the
|
||||
/// group's tree makes every live member's prompt stale, a write to one member's
|
||||
/// own tree makes only theirs. Session 5's `SkillsChanged` event carries the
|
||||
/// same shape, for the same reason.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PromptScope {
|
||||
/// The group's tree changed: everyone's index moved.
|
||||
Everyone,
|
||||
/// One member's own tree changed.
|
||||
User(String),
|
||||
}
|
||||
|
||||
/// How a skills write reaches conversations that are already running.
|
||||
///
|
||||
/// The index sits inside the frozen system prefix, which is rebuilt only after
|
||||
/// twenty idle minutes ([`crate::loop_adapters::prefix_cache`]). That is right
|
||||
/// for a file edited underneath a running conversation and wrong here: an admin
|
||||
/// who installs a skill and then asks the assistant to use it must not be told
|
||||
/// for twenty minutes that it does not exist.
|
||||
///
|
||||
/// **Not on the system bus.** `SkillsChanged` (session 5) is for a change made
|
||||
/// *outside* the process, by someone editing files on the box; a write made by a
|
||||
/// tool is already inside the process and can say so directly, which is both
|
||||
/// immediate and impossible to lose. The bus stays for the case it was designed
|
||||
/// for.
|
||||
#[async_trait::async_trait]
|
||||
pub trait PromptPrefixes: Send + Sync {
|
||||
async fn invalidate(&self, scope: PromptScope);
|
||||
}
|
||||
|
||||
/// The cell the skill tools hold, filled once the instance exists.
|
||||
///
|
||||
/// The tools are built during composition, before `Skald` does; the reactor they
|
||||
/// need can therefore only be installed afterwards — the same post-construction
|
||||
/// shape as the plugin manager's `set_skald` and the user-lifecycle reconciler.
|
||||
/// An empty cell is a silent no-op rather than an error: the only way to reach
|
||||
/// it is a `Skald` that failed to finish building, in which case there are no
|
||||
/// live conversations to keep fresh either.
|
||||
#[derive(Default)]
|
||||
pub struct PromptPrefixCell(OnceLock<Arc<dyn PromptPrefixes>>);
|
||||
|
||||
impl PromptPrefixCell {
|
||||
pub fn install(&self, sink: Arc<dyn PromptPrefixes>) {
|
||||
let _ = self.0.set(sink);
|
||||
}
|
||||
|
||||
pub async fn invalidate(&self, scope: PromptScope) {
|
||||
if let Some(sink) = self.0.get() {
|
||||
sink.invalidate(scope).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a `description` the index carries. The full text lives on disk and
|
||||
/// is surfaced by the enumeration tool; this cap applies **only** to the index,
|
||||
/// which is the one place tokens are paid on every request of every user.
|
||||
///
|
||||
/// Hermes cuts at 60. Sixty is too few here: the `description` *is* the use
|
||||
/// condition ("when should I reach for this?"), and cutting mid-sentence removes
|
||||
/// exactly the part that decides the trigger.
|
||||
pub const DESCRIPTION_LIMIT: usize = 200;
|
||||
|
||||
/// Ceiling on the whole rendered index, in bytes. A badly written skill must not
|
||||
/// be able to eat the prompt of everybody in the house.
|
||||
pub const INDEX_BUDGET: usize = 8 * 1024;
|
||||
|
||||
/// Room kept aside for the omission line, so appending it can never push the
|
||||
/// render past [`INDEX_BUDGET`]. A fixed reserve rather than a computed one keeps
|
||||
/// the cut a pure function of the ordered list.
|
||||
const OMISSION_RESERVE: usize = 96;
|
||||
|
||||
/// One installed skill, as the index sees it: where it is and when to reach for
|
||||
/// it. The body never enters this type — it is read from disk, by the model.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Skill {
|
||||
/// The directory name, which **is** the id.
|
||||
pub id: String,
|
||||
/// The agent-visible scope segment: `shared`, or the owner's username.
|
||||
pub scope: String,
|
||||
/// The frontmatter `description`, verbatim and untruncated.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
/// The skill's folder, in agent vocabulary (`skills/shared/ics-import`).
|
||||
pub fn agent_dir(&self) -> String {
|
||||
format!("{SKILLS_ROOT}/{}/{}", self.scope, self.id)
|
||||
}
|
||||
|
||||
/// The path the index prints — the `SKILL.md` itself, ready for `read_file`.
|
||||
pub fn skill_file(&self) -> String {
|
||||
format!("{}/{SKILL_FILE}", self.agent_dir())
|
||||
}
|
||||
}
|
||||
|
||||
/// Every skill visible to this user, in the index's stable order: the group's
|
||||
/// tree first, then their own, each sorted by id.
|
||||
///
|
||||
/// Per-user by construction — the own tree comes from `fs.skills`, which is built
|
||||
/// for one member — so another member's private skills cannot appear here. A
|
||||
/// `UserFs` without the skills tree (an inert placeholder, a unit test) simply has
|
||||
/// none.
|
||||
pub fn list(fs: &UserFs) -> Vec<Skill> {
|
||||
let Some(sk) = &fs.skills else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
collect(SKILLS_SHARED_SCOPE, &sk.shared_host, &mut out);
|
||||
collect(&sk.own_username, &sk.own_host, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads one scope's tree, appending its valid skills in id order. A tree that
|
||||
/// does not exist yet is not an error: it is the state of every fresh instance.
|
||||
fn collect(scope: &str, host: &Path, out: &mut Vec<Skill>) {
|
||||
let Ok(entries) = std::fs::read_dir(host) else { return };
|
||||
|
||||
let mut found: Vec<Skill> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_name().and_then(|n| n.to_str()) else { continue };
|
||||
// Dot-directories are plumbing (a staging leftover, an editor's cruft),
|
||||
// never a skill: an id starting with a dot cannot be registered.
|
||||
if id.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = match std::fs::read_to_string(path.join(SKILL_FILE)) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(scope, skill = id, error = %e, "skill skipped: no readable SKILL.md");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let front = match parse_front_matter(&body) {
|
||||
Ok(f) => f,
|
||||
Err(problem) => {
|
||||
warn!(scope, skill = id, problem, "skill skipped: invalid frontmatter");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The id is the directory, always — that is what every path in the index
|
||||
// is built from. A `name` that disagrees is worth saying out loud (the
|
||||
// registration tool makes the two agree by construction, so this can only
|
||||
// be a folder placed by hand) but not worth hiding the skill over.
|
||||
if front.name != id {
|
||||
warn!(
|
||||
scope, skill = id, declared = front.name,
|
||||
"skill frontmatter `name` differs from its directory; the directory wins"
|
||||
);
|
||||
}
|
||||
found.push(Skill { id: id.to_string(), scope: scope.to_string(), description: front.description });
|
||||
}
|
||||
|
||||
found.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
out.extend(found);
|
||||
}
|
||||
|
||||
/// The two mandatory frontmatter fields. Unknown keys (`license`,
|
||||
/// `allowed-tools`, anything a skill written elsewhere carries) are ignored.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct FrontMatter {
|
||||
#[serde(default)]
|
||||
pub(crate) name: String,
|
||||
#[serde(default)]
|
||||
pub(crate) description: String,
|
||||
}
|
||||
|
||||
/// Parses the leading `---` YAML block of a `SKILL.md`. `Err` carries a short
|
||||
/// reason, which the caller logs — this is the one place a hand-edited skill goes
|
||||
/// wrong, so the log line has to say which of the three ways it did.
|
||||
///
|
||||
/// Shared with [`validate`] on purpose: two frontmatter parsers would agree on
|
||||
/// the easy cases and diverge on the first odd one, and the pair that must never
|
||||
/// disagree is exactly *what the registration accepts* and *what the index
|
||||
/// shows* — a skill installed but invisible is the worst of both.
|
||||
pub(crate) fn parse_front_matter(body: &str) -> Result<FrontMatter, &'static str> {
|
||||
let rest = body
|
||||
.strip_prefix("---\n")
|
||||
.or_else(|| body.strip_prefix("---\r\n"))
|
||||
.ok_or("no frontmatter block")?;
|
||||
let end = rest
|
||||
.split_inclusive('\n')
|
||||
.scan(0usize, |at, line| {
|
||||
let start = *at;
|
||||
*at += line.len();
|
||||
Some((start, line))
|
||||
})
|
||||
.find(|(_, line)| matches!(line.trim_end(), "---" | "..."))
|
||||
.map(|(start, _)| start)
|
||||
.ok_or("unterminated frontmatter block")?;
|
||||
|
||||
let front: FrontMatter = serde_yaml::from_str(&rest[..end]).map_err(|_| "not valid YAML")?;
|
||||
if front.name.trim().is_empty() {
|
||||
return Err("frontmatter has no `name`");
|
||||
}
|
||||
if front.description.trim().is_empty() {
|
||||
return Err("frontmatter has no `description`");
|
||||
}
|
||||
Ok(FrontMatter { name: front.name.trim().to_string(), description: front.description.trim().to_string() })
|
||||
}
|
||||
|
||||
/// The index for one user, ready to replace `__SKILLS_LIST__`.
|
||||
pub fn render_index(fs: &UserFs) -> String {
|
||||
render(&list(fs))
|
||||
}
|
||||
|
||||
/// A stable digest of a rendered index. The invalidation rule of blueprint §6 is
|
||||
/// keyed on **this string changing**, not on a file inside a skill changing:
|
||||
/// editing a script or a reference document leaves the index byte-identical, so it
|
||||
/// costs nobody a cache miss. Only adding, removing or re-describing a skill does.
|
||||
pub fn digest(rendered: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(rendered.as_bytes()))
|
||||
}
|
||||
|
||||
/// A digest of one scope **tree's** visible content — the sorted
|
||||
/// (id, description) pairs, which is everything the index ever prints from it.
|
||||
///
|
||||
/// This is the file-watcher's gate (blueprint §8.2), and the rule is §6's, per
|
||||
/// tree instead of per render: editing a script or a reference document leaves
|
||||
/// it alone; adding, removing or re-describing a skill moves it. A collision
|
||||
/// marker needs no hashing of its own — it is a function of the two id sets,
|
||||
/// and those are hashed. An empty or missing tree digests as [`digest`]`("")`.
|
||||
pub fn tree_digest(host: &Path) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let label = host.file_name().and_then(|n| n.to_str()).unwrap_or("?");
|
||||
let mut found = Vec::new();
|
||||
collect(label, host, &mut found);
|
||||
let mut hasher = Sha256::new();
|
||||
for s in &found {
|
||||
hasher.update(s.id.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(s.description.as_bytes());
|
||||
hasher.update([0]);
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// The imperative preamble. Deliberately pushy — the failure mode of every skill
|
||||
/// system is the model *under*-triggering, and a neutral "the following skills are
|
||||
/// available" produces a model that scrolls past them.
|
||||
const HEADER: &str = "\
|
||||
## Skills (mandatory)
|
||||
|
||||
Before replying, scan the skills below. If a skill matches or is even partially \
|
||||
relevant to your task, you MUST read its `SKILL.md` with `read_file` and follow \
|
||||
its instructions. Err on the side of reading it — it is always better to have \
|
||||
context you don't need than to miss critical steps, pitfalls or established \
|
||||
workflows. Skills encode how a task should be done here, so read one even for a \
|
||||
task you already know how to do.
|
||||
|
||||
<available_skills>
|
||||
";
|
||||
|
||||
/// Closing rules. The `workdir` sentence is here, said once, because there is no
|
||||
/// launch tool to hide it in: a skill's scripts are run with the general-purpose
|
||||
/// `execute_cmd`, and a model that runs one from the home gets a bare ENOENT.
|
||||
const FOOTER: &str = "\
|
||||
</available_skills>
|
||||
|
||||
Only proceed without reading a skill if genuinely none are relevant.
|
||||
|
||||
Run a skill's scripts with `execute_cmd`, setting `workdir` to the skill's own \
|
||||
folder. The whole `skills/` tree is read-only: anything a skill needs to write \
|
||||
(caches, state, dependencies) goes in your home or `/tmp`.";
|
||||
|
||||
/// Renders the index from an ordered skill list — pure, so the budget cut and the
|
||||
/// collision marking are testable without a filesystem.
|
||||
///
|
||||
/// **Empty in, empty out**, and that is a contract rather than an optimisation:
|
||||
/// every word of prose lives in here, so an instance with no skills spends nothing
|
||||
/// and leaves no orphan sentence from which the model could infer that something
|
||||
/// exists. (The MCP list is the counter-example — its prose sits *around* the
|
||||
/// placeholder, so an empty list left a promise of a table behind, and the model
|
||||
/// answered by inventing a discovery tool.)
|
||||
pub fn render(skills: &[Skill]) -> String {
|
||||
if skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// An id present in both trees is marked on **both** lines: neither wins in
|
||||
// silence. The personal one winning would be a quiet divergence from the
|
||||
// group's set, the group's one winning would ignore the member's own work.
|
||||
// With full paths in the index the disambiguation is already free — the two
|
||||
// lines differ — so fail-loud costs only the marker.
|
||||
let colliding: std::collections::HashSet<&str> = skills
|
||||
.iter()
|
||||
.filter(|s| skills.iter().any(|o| o.id == s.id && o.scope != s.scope))
|
||||
.map(|s| s.id.as_str())
|
||||
.collect();
|
||||
|
||||
let paths: Vec<String> = skills.iter().map(Skill::skill_file).collect();
|
||||
let width = paths.iter().map(String::len).max().unwrap_or(0);
|
||||
|
||||
let rows: Vec<String> = skills
|
||||
.iter()
|
||||
.zip(&paths)
|
||||
.map(|(s, path)| {
|
||||
let mut desc = truncate(&flatten(&s.description), DESCRIPTION_LIMIT);
|
||||
if colliding.contains(s.id.as_str()) {
|
||||
desc.push_str(" [name collision]");
|
||||
}
|
||||
format!(" {path:<width$} {desc}\n")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = String::with_capacity(HEADER.len() + FOOTER.len() + 128);
|
||||
out.push_str(HEADER);
|
||||
|
||||
let mut room = INDEX_BUDGET
|
||||
.saturating_sub(HEADER.len() + FOOTER.len() + OMISSION_RESERVE);
|
||||
let mut omitted = 0;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
// Stop at the **first** row that does not fit, rather than skipping it and
|
||||
// trying the next: the cut has to be a suffix of the stable order, or two
|
||||
// renders of the same set could keep different skills.
|
||||
if row.len() > room {
|
||||
omitted = rows.len() - i;
|
||||
break;
|
||||
}
|
||||
room -= row.len();
|
||||
out.push_str(row);
|
||||
}
|
||||
if omitted > 0 {
|
||||
warn!(omitted, total = rows.len(), "skills index over budget: tail omitted");
|
||||
out.push_str(&format!(" [{omitted} more skills omitted — index budget reached]\n"));
|
||||
}
|
||||
|
||||
out.push_str(FOOTER);
|
||||
out
|
||||
}
|
||||
|
||||
/// A description as one line: a multi-line one would break the column layout, and
|
||||
/// the index is read as a table.
|
||||
fn flatten(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Truncates to `limit` **characters** (never bytes — this text is user-authored
|
||||
/// and routinely accented), marking the cut so the model knows it read a prefix.
|
||||
fn truncate(s: &str, limit: usize) -> String {
|
||||
if s.chars().count() <= limit {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(limit).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// A temporary `{WD}` with the two scope trees and a `UserFs` over it — shared
|
||||
/// by the index, install and inventory tests, which all need the same fixture
|
||||
/// and would otherwise each grow their own slightly different one.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests_support {
|
||||
use super::*;
|
||||
use core_api::user_fs::SkillMounts;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) struct Tree {
|
||||
pub(crate) root: PathBuf,
|
||||
pub(crate) fs: UserFs,
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
pub(crate) fn new(tag: &str, username: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"skald-skills-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let shared = root.join("skills");
|
||||
let own = root.join("skills-users").join("u1");
|
||||
std::fs::create_dir_all(&shared).unwrap();
|
||||
std::fs::create_dir_all(&own).unwrap();
|
||||
let fs = UserFs::new(
|
||||
"u1",
|
||||
root.join("homes").join("u1"),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.with_skills(SkillMounts {
|
||||
root_host: root.join(".skills-root").join("u1"),
|
||||
shared_host: shared,
|
||||
own_host: own,
|
||||
own_username: username.into(),
|
||||
});
|
||||
Self { root, fs }
|
||||
}
|
||||
|
||||
/// Drops a skill straight into a scope tree, the way a hand-placed folder
|
||||
/// on the box arrives — bypassing the registration tool, which these
|
||||
/// tests are deliberately not exercising.
|
||||
pub(crate) fn write(&self, scope: &str, id: &str, body: &str) {
|
||||
let base = match scope {
|
||||
"shared" => self.root.join("skills"),
|
||||
_ => self.root.join("skills-users").join("u1"),
|
||||
};
|
||||
let dir = base.join(id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(SKILL_FILE), body).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tree {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn valid(name: &str, description: &str) -> String {
|
||||
format!("---\nname: {name}\ndescription: {description}\n---\n\nThe body.\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tests_support::{Tree, valid};
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn skill(scope: &str, id: &str, description: &str) -> Skill {
|
||||
Skill { id: id.into(), scope: scope.into(), description: description.into() }
|
||||
}
|
||||
|
||||
/// Both trees are enumerated, the group's first, each in id order — and the
|
||||
/// order is the whole reason: it is inside the provider's cache key.
|
||||
#[test]
|
||||
fn both_scopes_are_listed_in_a_stable_order() {
|
||||
let t = Tree::new("order", "daniele");
|
||||
t.write("shared", "pdf-forms", &valid("pdf-forms", "Fill a PDF form."));
|
||||
t.write("shared", "ics-import", &valid("ics-import", "Import an ICS feed."));
|
||||
t.write("mine", "spesa", &valid("spesa", "Reconcile the statement."));
|
||||
|
||||
let got: Vec<(String, String)> =
|
||||
list(&t.fs).into_iter().map(|s| (s.scope, s.id)).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
("shared".into(), "ics-import".into()),
|
||||
("shared".into(), "pdf-forms".into()),
|
||||
("daniele".into(), "spesa".into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A skill nobody can parse is skipped; the ones around it are not. The index
|
||||
/// is built while assembling a prompt, so a broken folder must cost its own
|
||||
/// line and nothing else.
|
||||
#[test]
|
||||
fn a_malformed_skill_is_skipped_not_fatal() {
|
||||
let t = Tree::new("malformed", "daniele");
|
||||
t.write("shared", "good", &valid("good", "Works."));
|
||||
t.write("shared", "no-frontmatter", "Just a body, no YAML at all.\n");
|
||||
t.write("shared", "unterminated", "---\nname: x\ndescription: y\n");
|
||||
t.write("shared", "not-yaml", "---\nname: [unclosed\n---\n");
|
||||
t.write("shared", "no-description", "---\nname: x\n---\n");
|
||||
std::fs::create_dir_all(t.root.join("skills").join("no-skill-md")).unwrap();
|
||||
|
||||
let ids: Vec<String> = list(&t.fs).into_iter().map(|s| s.id).collect();
|
||||
assert_eq!(ids, vec!["good".to_string()]);
|
||||
}
|
||||
|
||||
/// The directory is the id, whatever the frontmatter says — every path in the
|
||||
/// index is built from it.
|
||||
#[test]
|
||||
fn the_directory_is_the_id() {
|
||||
let t = Tree::new("id", "daniele");
|
||||
t.write("shared", "ics-import", &valid("something-else", "Import an ICS feed."));
|
||||
let got = list(&t.fs);
|
||||
assert_eq!(got[0].id, "ics-import");
|
||||
assert_eq!(got[0].skill_file(), "skills/shared/ics-import/SKILL.md");
|
||||
}
|
||||
|
||||
/// The heart of the multi-user half: the own tree is keyed on the userid, so a
|
||||
/// private skill of one member cannot reach another member's prompt.
|
||||
#[test]
|
||||
fn a_private_skill_belongs_to_one_member_only() {
|
||||
let a = Tree::new("private-a", "anna");
|
||||
a.write("mine", "budget", &valid("budget", "Anna's own."));
|
||||
let b = Tree::new("private-b", "bruno");
|
||||
|
||||
assert!(render_index(&a.fs).contains("skills/anna/budget/SKILL.md"));
|
||||
assert_eq!(render_index(&b.fs), "");
|
||||
}
|
||||
|
||||
/// Nothing installed ⇒ nothing rendered. Not "an empty section": the whole
|
||||
/// prose lives inside the render precisely so that this case costs zero tokens
|
||||
/// and leaves no sentence the model could read as a promise.
|
||||
#[test]
|
||||
fn no_skills_renders_nothing_at_all() {
|
||||
assert_eq!(render(&[]), "");
|
||||
let bare = UserFs::new("u1", PathBuf::from("/wd/homes/u1"), "c", PathBuf::from("/root"), vec![], vec![], None);
|
||||
assert_eq!(render_index(&bare), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_carries_the_full_path_and_a_truncated_description() {
|
||||
let long = "x".repeat(DESCRIPTION_LIMIT + 50);
|
||||
let out = render(&[
|
||||
skill("shared", "ics-import", "Download an iCalendar (ICS) feed\nand output JSON."),
|
||||
skill("daniele", "spesa", &long),
|
||||
]);
|
||||
|
||||
// The path is printed in full: no id-plus-composition-rule for the model
|
||||
// to get wrong.
|
||||
assert!(out.contains("skills/shared/ics-import/SKILL.md"), "{out}");
|
||||
assert!(out.contains("skills/daniele/spesa/SKILL.md"), "{out}");
|
||||
// A multi-line description becomes one line.
|
||||
assert!(out.contains("Download an iCalendar (ICS) feed and output JSON."), "{out}");
|
||||
// …and a long one is cut, visibly.
|
||||
assert!(out.contains(&format!("{}…", "x".repeat(DESCRIPTION_LIMIT))), "{out}");
|
||||
assert!(!out.contains(&"x".repeat(DESCRIPTION_LIMIT + 1)), "{out}");
|
||||
// The imperative header and the closing rule are both there.
|
||||
assert!(out.starts_with("## Skills (mandatory)"), "{out}");
|
||||
assert!(out.contains("MUST read its `SKILL.md`"), "{out}");
|
||||
assert!(out.ends_with("goes in your home or `/tmp`."), "{out}");
|
||||
}
|
||||
|
||||
/// The same id in both trees: both lines stay, both are marked. Neither tree
|
||||
/// shadows the other, here or in the path router.
|
||||
#[test]
|
||||
fn a_colliding_id_is_marked_on_both_lines() {
|
||||
let out = render(&[
|
||||
skill("shared", "ics-import", "The group's."),
|
||||
skill("shared", "pdf-forms", "Untouched."),
|
||||
skill("daniele", "ics-import", "My fork."),
|
||||
]);
|
||||
assert_eq!(out.matches("[name collision]").count(), 2, "{out}");
|
||||
for line in out.lines().filter(|l| l.contains("pdf-forms")) {
|
||||
assert!(!line.contains("[name collision]"), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Over budget the index cuts from the tail of the stable order and says how
|
||||
/// many it dropped — deterministically, because the cut is part of the cache
|
||||
/// key, and out loud, because a silently truncated index makes the model
|
||||
/// conclude in good faith that a skill does not exist.
|
||||
#[test]
|
||||
fn over_budget_the_tail_is_cut_deterministically_and_announced() {
|
||||
let many: Vec<Skill> = (0..400)
|
||||
.map(|i| skill("shared", &format!("skill-{i:03}"), &"d".repeat(DESCRIPTION_LIMIT)))
|
||||
.collect();
|
||||
|
||||
let out = render(&many);
|
||||
assert!(out.len() <= INDEX_BUDGET, "budget blown: {} bytes", out.len());
|
||||
assert!(out.contains("more skills omitted — index budget reached"), "{out}");
|
||||
// A suffix of the order is what went missing: the first is in, the last is not.
|
||||
assert!(out.contains("skills/shared/skill-000/SKILL.md"), "{out}");
|
||||
assert!(!out.contains("skills/shared/skill-399/SKILL.md"), "{out}");
|
||||
// Same set in, same bytes out — otherwise the stable order buys nothing.
|
||||
assert_eq!(out, render(&many));
|
||||
assert_eq!(digest(&out), digest(&render(&many)));
|
||||
}
|
||||
|
||||
/// The digest keys on what the model can see. Editing a script or a reference
|
||||
/// document leaves it alone; re-describing a skill moves it.
|
||||
#[test]
|
||||
fn the_digest_follows_the_index_not_the_files() {
|
||||
let before = render(&[skill("shared", "ics-import", "Import an ICS feed.")]);
|
||||
let same = render(&[skill("shared", "ics-import", "Import an ICS feed.")]);
|
||||
let after = render(&[skill("shared", "ics-import", "Import an ICS feed, then dedupe.")]);
|
||||
assert_eq!(digest(&before), digest(&same));
|
||||
assert_ne!(digest(&before), digest(&after));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! What a folder must be before it is allowed to become a skill.
|
||||
//!
|
||||
//! **One validation site, because there is one door.** The two trees are
|
||||
//! read-only in both directions (blueprint §9), so every byte that ever lands in
|
||||
//! them passes through here — and the index on the other side can therefore
|
||||
//! assume it is reading well-formed skills instead of tolerating half-written
|
||||
//! ones. That is the whole argument for the read-only trees: with three write
|
||||
//! paths (fs-tools, the container shell, an HTTP copy) validation would either
|
||||
//! live in three places or nowhere.
|
||||
//!
|
||||
//! This module is deliberately callable from more than the registration tool:
|
||||
//! the ZIP upload and the marketplace of blueprint §11.2 are the same check with
|
||||
//! a different source of bytes, and two validators would diverge at the first
|
||||
//! edge case.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use super::{SKILL_FILE, parse_front_matter};
|
||||
|
||||
/// Longest `name` accepted, matching the `^[a-z0-9][a-z0-9-]{0,63}$` shape: the
|
||||
/// name becomes a directory name and then a path segment in every prompt.
|
||||
pub const NAME_MAX: usize = 64;
|
||||
|
||||
/// Ceiling on the `description`, applied **here** rather than in the index.
|
||||
///
|
||||
/// One limit, at the one place it can fail usefully: the author is present, sees
|
||||
/// the refusal and can shorten the text. The index's 200-character cut
|
||||
/// ([`super::DESCRIPTION_LIMIT`]) is a different rule for a different reason —
|
||||
/// tokens paid on every request of every user — and truncating there is not a
|
||||
/// rejection, since the full text stays readable through the enumeration tool.
|
||||
pub const DESCRIPTION_MAX: usize = 1000;
|
||||
|
||||
/// Ceiling on how many files one skill may carry.
|
||||
pub const MAX_FILES: usize = 500;
|
||||
|
||||
/// Ceiling on a skill's total size. Generous for instructions plus scripts and
|
||||
/// reference documents; far below anything that would be a *dataset*, which is
|
||||
/// not what this tree is for.
|
||||
pub const MAX_TOTAL_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// A source folder that passed every check — the only thing [`super::install`]
|
||||
/// accepts, so an unvalidated path cannot reach the tree by construction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidSkill {
|
||||
/// The id, taken from the frontmatter `name` and **not** from the source
|
||||
/// folder's name. The working copy may be called `draft-2`; the installed
|
||||
/// artefact is called what it declares itself to be, and from then on
|
||||
/// id = directory name by construction.
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
/// Every regular file, relative to the source root, in a stable order —
|
||||
/// what the approval card lists.
|
||||
pub files: Vec<PathBuf>,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
impl ValidSkill {
|
||||
/// Whether the skill carries anything executable, for the enumeration tool.
|
||||
pub fn has_scripts(&self) -> bool {
|
||||
self.files.iter().any(|f| {
|
||||
matches!(
|
||||
f.extension().and_then(|e| e.to_str()),
|
||||
Some("py" | "js" | "mjs" | "cjs" | "ts" | "sh" | "bash")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which ecosystem's dependency manifest it ships, if any. Reported rather
|
||||
/// than acted on: v1 installs nothing (blueprint §10), and a skill that
|
||||
/// needs a package says so in its body.
|
||||
pub fn deps(&self) -> Option<String> {
|
||||
let has = |n: &str| self.files.iter().any(|f| f == Path::new(n));
|
||||
match (has("requirements.txt"), has("package.json")) {
|
||||
(true, true) => Some("python+node".into()),
|
||||
(true, false) => Some("python".into()),
|
||||
(false, true) => Some("node".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a candidate skill folder on the host filesystem.
|
||||
///
|
||||
/// Every refusal names what to fix: this error text is read by a model that will
|
||||
/// try again, and "invalid skill" would only produce a guess.
|
||||
pub fn validate_dir(dir: &Path) -> Result<ValidSkill> {
|
||||
if !dir.is_dir() {
|
||||
bail!(
|
||||
"not a folder: {}. A skill is a folder containing a `{SKILL_FILE}`.",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
let skill_md = dir.join(SKILL_FILE);
|
||||
if !skill_md.is_file() {
|
||||
bail!(
|
||||
"no `{SKILL_FILE}` in that folder. A skill is a folder whose `{SKILL_FILE}` \
|
||||
opens with a YAML frontmatter block declaring `name` and `description`."
|
||||
);
|
||||
}
|
||||
|
||||
let body = std::fs::read_to_string(&skill_md)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {SKILL_FILE}: {e}"))?;
|
||||
let front = parse_front_matter(&body).map_err(|problem| {
|
||||
anyhow::anyhow!(
|
||||
"invalid `{SKILL_FILE}` frontmatter ({problem}). It must start with a `---` line, \
|
||||
then `name:` and `description:`, then a closing `---`."
|
||||
)
|
||||
})?;
|
||||
|
||||
check_name(&front.name)?;
|
||||
if front.description.chars().count() > DESCRIPTION_MAX {
|
||||
bail!(
|
||||
"the `description` is {} characters; the limit is {DESCRIPTION_MAX}. It is the \
|
||||
*use condition* — when to reach for this skill — not a manual; the body of \
|
||||
`{SKILL_FILE}` is where the detail goes.",
|
||||
front.description.chars().count()
|
||||
);
|
||||
}
|
||||
|
||||
let mut walk = Walk::default();
|
||||
walk.visit(dir, Path::new(""))?;
|
||||
|
||||
Ok(ValidSkill {
|
||||
id: front.name,
|
||||
description: front.description,
|
||||
files: walk.files,
|
||||
size_bytes: walk.bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// The id charset: `^[a-z0-9][a-z0-9-]{0,63}$`.
|
||||
///
|
||||
/// Checked by hand rather than by regex because the failure has to *teach* — the
|
||||
/// caller is a model that will retry, and "does not match a pattern" is not a
|
||||
/// correction.
|
||||
fn check_name(name: &str) -> Result<()> {
|
||||
if name.len() > NAME_MAX {
|
||||
bail!("the frontmatter `name` is longer than {NAME_MAX} characters: `{name}`");
|
||||
}
|
||||
let ok_first = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
|
||||
let ok_rest = name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
|
||||
if !ok_first || !ok_rest {
|
||||
bail!(
|
||||
"the frontmatter `name` must be lowercase letters, digits and hyphens, starting \
|
||||
with a letter or digit (it becomes the folder name and the path the assistant \
|
||||
reads): `{name}`"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursive walk that enforces the structural rules while it counts.
|
||||
#[derive(Default)]
|
||||
struct Walk {
|
||||
files: Vec<PathBuf>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn visit(&mut self, dir: &Path, rel: &Path) -> Result<()> {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", dir.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
// Stable order: the file list ends up on an approval card, and a card
|
||||
// that reshuffles between two renders of the same folder reads as a
|
||||
// different change.
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let child_rel = rel.join(&name);
|
||||
|
||||
// `symlink_metadata` does NOT follow the link, which is the point:
|
||||
// a link is refused for what it is, before anything asks where it
|
||||
// points. A skill is copied into a tree every member reads, and a
|
||||
// link out of that tree would make the installed artefact a window
|
||||
// onto something the installation never reviewed.
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", child_rel.display()))?;
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
bail!(
|
||||
"`{}` is a symbolic link. A skill must be self-contained — copy the real \
|
||||
file in instead.",
|
||||
child_rel.display()
|
||||
);
|
||||
}
|
||||
if meta.is_dir() {
|
||||
self.visit(&path, &child_rel)?;
|
||||
continue;
|
||||
}
|
||||
if !meta.is_file() {
|
||||
bail!("`{}` is not a regular file.", child_rel.display());
|
||||
}
|
||||
|
||||
self.bytes += meta.len();
|
||||
self.files.push(child_rel);
|
||||
if self.files.len() > MAX_FILES {
|
||||
bail!("that folder holds more than {MAX_FILES} files — too much for a skill.");
|
||||
}
|
||||
if self.bytes > MAX_TOTAL_BYTES {
|
||||
bail!(
|
||||
"that folder is over {} MiB — too much for a skill. Skills hold \
|
||||
instructions, scripts and reference documents; bulk data belongs in your \
|
||||
home or a project.",
|
||||
MAX_TOTAL_BYTES / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Dir(PathBuf);
|
||||
|
||||
impl Dir {
|
||||
fn new(tag: &str) -> Self {
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"skald-skillval-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Dir(p)
|
||||
}
|
||||
fn write(&self, rel: &str, body: &str) {
|
||||
let p = self.0.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, body).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn front(name: &str, description: &str) -> String {
|
||||
format!("---\nname: {name}\ndescription: {description}\n---\n\nBody.\n")
|
||||
}
|
||||
|
||||
/// The id comes from the frontmatter, never from the folder: the working
|
||||
/// copy is allowed a scratch name, the artefact is not.
|
||||
#[test]
|
||||
fn the_id_comes_from_the_frontmatter_not_the_folder() {
|
||||
let d = Dir::new("id");
|
||||
d.write("SKILL.md", &front("ics-import", "Import an ICS feed."));
|
||||
d.write("scripts/run.py", "print(1)\n");
|
||||
let v = validate_dir(&d.0).unwrap();
|
||||
assert_eq!(v.id, "ics-import");
|
||||
assert_eq!(v.files, vec![PathBuf::from("SKILL.md"), PathBuf::from("scripts/run.py")]);
|
||||
assert!(v.has_scripts());
|
||||
assert_eq!(v.deps(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_without_a_skill_md_is_refused() {
|
||||
let d = Dir::new("nomd");
|
||||
d.write("notes.txt", "hello");
|
||||
let e = validate_dir(&d.0).unwrap_err().to_string();
|
||||
assert!(e.contains("SKILL.md"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_cannot_be_a_folder_is_refused() {
|
||||
for bad in ["Ics Import", "../escape", "-leading", "UPPER"] {
|
||||
let d = Dir::new("badname");
|
||||
d.write("SKILL.md", &front(bad, "x"));
|
||||
assert!(validate_dir(&d.0).is_err(), "accepted `{bad}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_description_is_refused_here_not_truncated() {
|
||||
let d = Dir::new("longdesc");
|
||||
d.write("SKILL.md", &front("x", &"d".repeat(DESCRIPTION_MAX + 1)));
|
||||
let e = validate_dir(&d.0).unwrap_err().to_string();
|
||||
assert!(e.contains(&DESCRIPTION_MAX.to_string()), "{e}");
|
||||
}
|
||||
|
||||
/// A symlink is refused for being one, without asking where it points: the
|
||||
/// installed copy is read as instruction by everyone the scope covers.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_anywhere_inside_is_refused() {
|
||||
let d = Dir::new("symlink");
|
||||
d.write("SKILL.md", &front("x", "y"));
|
||||
std::os::unix::fs::symlink("/etc/passwd", d.0.join("secrets.txt")).unwrap();
|
||||
let e = validate_dir(&d.0).unwrap_err().to_string();
|
||||
assert!(e.contains("symbolic link"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_manifests_are_reported_not_installed() {
|
||||
let d = Dir::new("deps");
|
||||
d.write("SKILL.md", &front("x", "y"));
|
||||
d.write("requirements.txt", "requests\n");
|
||||
assert_eq!(validate_dir(&d.0).unwrap().deps().as_deref(), Some("python"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! The skills freshness watcher (blueprint §8.2): freshness for edits made **by
|
||||
//! hand on the box**.
|
||||
//!
|
||||
//! Every in-process writer — `skill_register`, `skill_delete`, the future UI —
|
||||
//! already invalidates the prompt prefix directly; this task exists for the one
|
||||
//! writer that is not in the process: the admin in SSH, a `git pull` of skills.
|
||||
//! It is deliberately *not* on the correctness path: a missed event costs a
|
||||
//! stale index for the twenty minutes of the prefix TTL, never a wrong one.
|
||||
//!
|
||||
//! The gate is the digest, and it is the whole design. `notify` is noisy — an
|
||||
//! editor's save is a burst, an install is dozens of events, and every prompt
|
||||
//! build *reads* every `SKILL.md` — so what reaches the bus is never "the fs
|
||||
//! moved" but "the rendered index would differ" ([`super::tree_digest`]). A
|
||||
//! script edit produces events and then silence, which is exactly the property
|
||||
//! §6 asks for: the frozen prefix citing that skill has not aged.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use core_api::system_bus::{SkillScope, SystemEvent, SystemEventBus};
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::container::{SKILLS_DIR, SKILLS_USERS_DIR};
|
||||
|
||||
/// The quiet period after the last fs event before the digests are recomputed.
|
||||
/// What matters is the settled state of the tree, not any intermediate one.
|
||||
const DEBOUNCE: Duration = Duration::from_millis(800);
|
||||
|
||||
/// Spawns the watcher on the two trees (`{WD}/skills`, `{WD}/skills-users`),
|
||||
/// emitting `SystemEvent::SkillsChanged` for each scope whose digest moved.
|
||||
pub fn spawn(bus: Arc<SystemEventBus>, shutdown: CancellationToken) -> tokio::task::JoinHandle<()> {
|
||||
let wd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
tokio::spawn(run(bus, shutdown, wd, DEBOUNCE))
|
||||
}
|
||||
|
||||
/// The watcher's body, split from [`spawn`] so the tests can point it at a
|
||||
/// temporary `{WD}` and shorten the debounce.
|
||||
async fn run(bus: Arc<SystemEventBus>, shutdown: CancellationToken, wd: PathBuf, debounce: Duration) {
|
||||
// The fs backend reports **canonical** paths (on macOS `/var` is a symlink
|
||||
// to `/private/var`, and FSEvents answers with the real one), so the roots
|
||||
// must be canonical too or `classify` never matches and every event is
|
||||
// silently dropped.
|
||||
let wd = match wd.canonicalize() {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!(path = %wd.display(), error = %e, "skills-watch: cannot canonicalize the working directory, not started");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let shared_dir = wd.join(SKILLS_DIR);
|
||||
let users_dir = wd.join(SKILLS_USERS_DIR);
|
||||
// Created rather than merely watched: these are instance data dirs that
|
||||
// `ContainerManager::ensure` would create anyway, and on a box before its
|
||||
// first user neither exists yet — a watcher that fails to install at boot
|
||||
// would never notice the first tree appearing.
|
||||
for dir in [&shared_dir, &users_dir] {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
warn!(path = %dir.display(), error = %e, "skills-watch: cannot create the tree, not started");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<PathBuf>>();
|
||||
let mut watcher = match RecommendedWatcher::new(
|
||||
move |res: notify::Result<notify::Event>| {
|
||||
let Ok(event) = res else { return };
|
||||
// A pure read is never a change — and it matters here: every prompt
|
||||
// build reads every `SKILL.md`, so IN_ACCESS / CLOSE_NOWRITE would
|
||||
// re-digest the trees after every single conversation turn.
|
||||
if matches!(event.kind, EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
if event.paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = tx.send(event.paths);
|
||||
},
|
||||
Config::default(),
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "skills-watch: watcher create failed, not started");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for dir in [&shared_dir, &users_dir] {
|
||||
if let Err(e) = watcher.watch(dir, RecursiveMode::Recursive) {
|
||||
warn!(path = %dir.display(), error = %e, "skills-watch: watch install failed, not started");
|
||||
return;
|
||||
}
|
||||
}
|
||||
info!("skills-watch: watching the two skills trees");
|
||||
|
||||
// Baselines, taken before anything can be emitted: only a *change* from
|
||||
// here on is worth an announcement. `empty` is the digest of a tree with no
|
||||
// visible skill — a tree first seen in that state (e.g. created empty by
|
||||
// `ensure` at container setup) alters no index and announces nothing.
|
||||
let empty = super::tree_digest(&shared_dir.join("__never__"));
|
||||
let mut shared_digest = super::tree_digest(&shared_dir);
|
||||
let mut user_digests = digests_by_user(&users_dir);
|
||||
|
||||
let mut touched_shared = false;
|
||||
let mut touched_users: HashSet<String> = HashSet::new();
|
||||
let mut quiet: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
paths = rx.recv() => {
|
||||
let Some(paths) = paths else { break }; // watcher dropped
|
||||
for p in &paths {
|
||||
match classify(p, &shared_dir, &users_dir) {
|
||||
Some(SkillScope::Global) => touched_shared = true,
|
||||
Some(SkillScope::User(u)) => { touched_users.insert(u); }
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Restart the quiet period on every event: a save burst or an
|
||||
// install settles only when the events stop coming.
|
||||
quiet = Some(Box::pin(tokio::time::sleep(debounce)));
|
||||
}
|
||||
// `pending` when disarmed: the arm below fires only once armed.
|
||||
_ = async { match &mut quiet { Some(s) => s.as_mut().await, None => std::future::pending().await } } => {
|
||||
quiet = None;
|
||||
|
||||
if touched_shared {
|
||||
touched_shared = false;
|
||||
let now = super::tree_digest(&shared_dir);
|
||||
if now != shared_digest {
|
||||
shared_digest = now;
|
||||
info!("skills-watch: the group's tree changed, announcing");
|
||||
bus.send(SystemEvent::SkillsChanged { scope: SkillScope::Global });
|
||||
}
|
||||
}
|
||||
for uid in touched_users.drain() {
|
||||
let now = super::tree_digest(&users_dir.join(&uid));
|
||||
let old = user_digests.insert(uid.clone(), now.clone());
|
||||
let changed = match old {
|
||||
Some(old) => old != now,
|
||||
None => now != empty,
|
||||
};
|
||||
if changed {
|
||||
info!(user = %uid, "skills-watch: a member's tree changed, announcing");
|
||||
bus.send(SystemEvent::SkillsChanged { scope: SkillScope::User(uid) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("skills-watch: stopped");
|
||||
}
|
||||
|
||||
/// Maps a changed fs path to the scope tree it touches.
|
||||
///
|
||||
/// `Path::starts_with` is component-wise, which is exactly what saves the
|
||||
/// prefix trap here: `{WD}/skills-users/…` does **not** start with
|
||||
/// `{WD}/skills`. An event on the `skills-users` root itself names no user
|
||||
/// yet and is ignored — a new member's directory reports itself by its own
|
||||
/// path.
|
||||
fn classify(path: &Path, shared_dir: &Path, users_dir: &Path) -> Option<SkillScope> {
|
||||
if path.starts_with(shared_dir) {
|
||||
return Some(SkillScope::Global);
|
||||
}
|
||||
let rel = path.strip_prefix(users_dir).ok()?;
|
||||
let uid = rel.components().next()?.as_os_str().to_str()?;
|
||||
Some(SkillScope::User(uid.to_string()))
|
||||
}
|
||||
|
||||
/// The baseline digests of every member tree present at startup.
|
||||
fn digests_by_user(users_dir: &Path) -> HashMap<String, String> {
|
||||
let Ok(entries) = std::fs::read_dir(users_dir) else { return HashMap::new() };
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.map(|uid| {
|
||||
let d = super::tree_digest(&users_dir.join(&uid));
|
||||
(uid, d)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::skills::tests_support::{Tree, valid};
|
||||
|
||||
/// The classification trap the whole design leans on: `skills-users` is a
|
||||
/// **sibling** of `skills`, and a component-wise prefix check must not let
|
||||
/// a member's edit fall into the group's scope.
|
||||
#[test]
|
||||
fn classify_never_confuses_the_two_sibling_trees() {
|
||||
let wd = Path::new("/wd");
|
||||
let shared = wd.join(SKILLS_DIR);
|
||||
let users = wd.join(SKILLS_USERS_DIR);
|
||||
|
||||
assert_eq!(
|
||||
classify(Path::new("/wd/skills/ics-import/SKILL.md"), &shared, &users),
|
||||
Some(SkillScope::Global)
|
||||
);
|
||||
assert_eq!(classify(Path::new("/wd/skills"), &shared, &users), Some(SkillScope::Global));
|
||||
assert_eq!(
|
||||
classify(Path::new("/wd/skills-users/u1/spesa/SKILL.md"), &shared, &users),
|
||||
Some(SkillScope::User("u1".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
classify(Path::new("/wd/skills-users/u1"), &shared, &users),
|
||||
Some(SkillScope::User("u1".into()))
|
||||
);
|
||||
// The users root itself names nobody.
|
||||
assert_eq!(classify(Path::new("/wd/skills-users"), &shared, &users), None);
|
||||
// Anything else is not ours at all.
|
||||
assert_eq!(classify(Path::new("/wd/homes/u1/x"), &shared, &users), None);
|
||||
}
|
||||
|
||||
/// The gate itself, over a real tree: an edit the index cannot see passes
|
||||
/// in silence; one it can see announces. (The pure half —
|
||||
/// `the_digest_follows_the_index_not_the_files` — lives in `skills/mod.rs`;
|
||||
/// this is the watcher half §15 asks for.)
|
||||
#[test]
|
||||
fn tree_digest_gates_on_what_the_index_can_see() {
|
||||
let t = Tree::new("watch-digest", "daniele");
|
||||
t.write("shared", "ics-import", &valid("ics-import", "Import an ICS feed."));
|
||||
let dir = t.root.join(SKILLS_DIR);
|
||||
|
||||
let before = super::super::tree_digest(&dir);
|
||||
// A script appears: events fire, the index does not move.
|
||||
std::fs::write(dir.join("ics-import").join("run.py"), "print('hi')\n").unwrap();
|
||||
assert_eq!(super::super::tree_digest(&dir), before);
|
||||
// The body changes under the same description: still invisible.
|
||||
t.write("shared", "ics-import", &valid("ics-import", "Import an ICS feed."));
|
||||
assert_eq!(super::super::tree_digest(&dir), before);
|
||||
// A re-description is what the prefix is made of: the digest moves.
|
||||
t.write("shared", "ics-import", &valid("ics-import", "Import an ICS feed, then dedupe."));
|
||||
assert_ne!(super::super::tree_digest(&dir), before);
|
||||
// An empty or missing tree is the same digest as no tree.
|
||||
assert_eq!(super::super::tree_digest(&dir.join("__never__")), super::super::digest(""));
|
||||
}
|
||||
|
||||
/// End to end: a hand edit on the box reaches the bus — but only when the
|
||||
/// index would notice.
|
||||
#[tokio::test]
|
||||
async fn a_hand_edit_announces_only_what_the_index_feels() {
|
||||
let t = Tree::new("watch-e2e", "daniele");
|
||||
t.write("shared", "ics-import", &valid("ics-import", "Import an ICS feed."));
|
||||
t.write("mine", "spesa", &valid("spesa", "Reconcile the statement."));
|
||||
|
||||
let bus = Arc::new(SystemEventBus::new());
|
||||
let mut rx = bus.subscribe();
|
||||
let shutdown = CancellationToken::new();
|
||||
let task = tokio::spawn(run(
|
||||
Arc::clone(&bus),
|
||||
shutdown.clone(),
|
||||
t.root.clone(),
|
||||
Duration::from_millis(100),
|
||||
));
|
||||
|
||||
// Give the watcher a moment to install before touching the tree.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
// A script edit is fs activity the index cannot see: no announcement,
|
||||
// however long we wait.
|
||||
std::fs::write(t.root.join(SKILLS_DIR).join("ics-import").join("run.py"), "print(1)\n").unwrap();
|
||||
let quiet = tokio::time::timeout(Duration::from_millis(1500), rx.recv()).await;
|
||||
assert!(quiet.is_err(), "a script edit announced something: {quiet:?}");
|
||||
|
||||
// A re-description of a shared skill announces the group's scope.
|
||||
t.write("shared", "ics-import", &valid("ics-import", "Import an ICS feed, then dedupe."));
|
||||
let announced = tokio::time::timeout(Duration::from_secs(10), rx.recv())
|
||||
.await
|
||||
.expect("no SkillsChanged within 10s of a description edit")
|
||||
.expect("bus closed");
|
||||
assert!(
|
||||
matches!(announced, SystemEvent::SkillsChanged { scope: SkillScope::Global }),
|
||||
"expected SkillsChanged(Global), got {announced:?}"
|
||||
);
|
||||
|
||||
// And one of an own skill announces only that member.
|
||||
t.write("mine", "spesa", &valid("spesa", "Reconcile the statement, monthly."));
|
||||
let announced = tokio::time::timeout(Duration::from_secs(10), rx.recv())
|
||||
.await
|
||||
.expect("no SkillsChanged within 10s of an own-skill edit")
|
||||
.expect("bus closed");
|
||||
assert!(
|
||||
matches!(announced, SystemEvent::SkillsChanged { scope: SkillScope::User(ref u) } if u == "u1"),
|
||||
"expected SkillsChanged(User(u1)), got {announced:?}"
|
||||
);
|
||||
|
||||
shutdown.cancel();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), task).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
//! `fetch_repo` — downloads a subtree of a **public git repository** into the
|
||||
//! caller's workspace (blueprint §7.5).
|
||||
//!
|
||||
//! It exists for what a `git clone` does **not** do, and the name says so on
|
||||
//! purpose: it is shallow, it checks out only the requested subtree, it drops
|
||||
//! `.git`, it refuses symlinks and oversized downloads before a byte reaches the
|
||||
//! destination, and it leaves a `.source.json` provenance ticket (URL, sub-path,
|
||||
//! commit SHA, date) next to the files — the one piece of traceability a clone
|
||||
//! never writes, without which "where did this come from?" and "did it change
|
||||
//! upstream?" have no answer later.
|
||||
//!
|
||||
//! It deliberately **installs nothing**: files land in `destination`, and if
|
||||
//! what was downloaded is a skill the installation is a separate
|
||||
//! `skill_register`, with its own approval card over readable files. A
|
||||
//! download-and-install in one step would move the human's only review moment
|
||||
//! onto a card showing *a URL*, and a URL is not reviewable.
|
||||
//!
|
||||
//! Network egress stays inside the caller's **container** (`docker exec git …`),
|
||||
//! never in the Skald process — the sandbox's network identity, not the
|
||||
//! server's. The sanitization and the move into `destination` run host-side on
|
||||
//! the bind-mounted staging directory, so the two sides never disagree about
|
||||
//! what landed.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use core_api::user_fs::UserFs;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::skills::install::{Provenance, SOURCE_FILE, copy_tree};
|
||||
use crate::tools::fs::resolve_host_path;
|
||||
use crate::tools::{SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult};
|
||||
|
||||
/// Ceiling on how many files a download may carry. Generous for working
|
||||
/// material (reference trees, examples, configs); far below a bulk mirror,
|
||||
/// which this tool is not for.
|
||||
pub const FETCH_MAX_FILES: usize = 2000;
|
||||
|
||||
/// Ceiling on a download's total size (64 MiB).
|
||||
pub const FETCH_MAX_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Wall-clock bound on the in-container `git` work. Enforced twice: `timeout`
|
||||
/// inside the script (so a killed client cannot leave a `git` running in the
|
||||
/// container) and a Rust-side timeout around the `docker` child as backstop.
|
||||
const CLONE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// The limits a fetch enforces, as data so a test can shrink them.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Limits {
|
||||
max_files: usize,
|
||||
max_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { max_files: FETCH_MAX_FILES, max_bytes: FETCH_MAX_BYTES }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a fetch delivered, for the tool's answer to the model.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Fetched {
|
||||
files: usize,
|
||||
bytes: u64,
|
||||
commit: String,
|
||||
}
|
||||
|
||||
// ── The cloner seam ───────────────────────────────────────────────────────────
|
||||
|
||||
/// How a repository gets cloned, as a trait so the tests run the **same script**
|
||||
/// against a local fixture repo without a Docker daemon.
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait RepoCloner: Send + Sync {
|
||||
/// Clones `url` — only `sub_path`, when given — into a fresh `repo/`
|
||||
/// directory under the staging dir, and returns the checked-out commit SHA.
|
||||
/// `host_dir` and `container_dir` name the **same** staging directory on the
|
||||
/// two sides of the home bind mount; implementations use the side they run
|
||||
/// on.
|
||||
async fn clone(
|
||||
&self,
|
||||
url: &str,
|
||||
sub_path: Option<&str>,
|
||||
host_dir: &Path,
|
||||
container_dir: &Path,
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
||||
/// The one clone script, run through `sh -c … _ <url> <sub> <dir> <timeout>`
|
||||
/// with every value **positional**, so a URL or path containing quotes or
|
||||
/// `$(…)` is data and not shell syntax — the same rule `exec_fs` follows.
|
||||
///
|
||||
/// `--filter=blob:none` keeps a big repository's history and untouched blobs
|
||||
/// off the wire; not every server supports it, so a filtered clone that fails
|
||||
/// is retried unfiltered rather than reported. `--sparse` plus
|
||||
/// `sparse-checkout set` materializes only the requested subtree.
|
||||
const CLONE_SCRIPT: &str = r#"
|
||||
set -eu
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
TW=""
|
||||
if [ "$4" != "0" ]; then TW="timeout $4"; fi
|
||||
mkdir -p -- "$3"
|
||||
if [ -z "$2" ]; then
|
||||
$TW git clone --quiet --depth 1 "$1" "$3/repo"
|
||||
else
|
||||
if ! $TW git clone --quiet --depth 1 --filter=blob:none --sparse "$1" "$3/repo"; then
|
||||
rm -rf -- "$3/repo"
|
||||
$TW git clone --quiet --depth 1 --sparse "$1" "$3/repo"
|
||||
fi
|
||||
git -C "$3/repo" sparse-checkout set "$2"
|
||||
fi
|
||||
git -C "$3/repo" rev-parse HEAD
|
||||
"#;
|
||||
|
||||
/// Production cloner: runs [`CLONE_SCRIPT`] **inside the caller's container**
|
||||
/// via `docker exec`, so the egress keeps the sandbox's network identity.
|
||||
pub(crate) struct ContainerGit {
|
||||
container: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RepoCloner for ContainerGit {
|
||||
async fn clone(
|
||||
&self,
|
||||
url: &str,
|
||||
sub_path: Option<&str>,
|
||||
_host_dir: &Path,
|
||||
container_dir: &Path,
|
||||
) -> Result<String> {
|
||||
let dir = container_dir.to_string_lossy().into_owned();
|
||||
let out = run_positional(
|
||||
"docker",
|
||||
&[
|
||||
"exec".into(),
|
||||
self.container.clone(),
|
||||
"sh".into(),
|
||||
"-c".into(),
|
||||
CLONE_SCRIPT.into(),
|
||||
"_".into(),
|
||||
url.into(),
|
||||
sub_path.unwrap_or("").into(),
|
||||
dir,
|
||||
CLONE_TIMEOUT.as_secs().saturating_sub(20).to_string(),
|
||||
],
|
||||
CLONE_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
parse_sha(&out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `program argv…` capturing stdout, with `kill_on_drop` plus a hard
|
||||
/// timeout. On timeout the dropped child is killed — and for the production
|
||||
/// cloner the script's own `timeout` is what stops the `git` the dead client
|
||||
/// would otherwise leave behind in the container.
|
||||
async fn run_positional(program: &str, argv: &[String], timeout: Duration) -> Result<String> {
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
cmd.args(argv)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn `{program}`"))?;
|
||||
let out = tokio::time::timeout(timeout, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("timed out after {}s", timeout.as_secs()))?
|
||||
.with_context(|| format!("`{program}` failed to report"))?;
|
||||
if !out.status.success() {
|
||||
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// The clone script's last stdout line is the `rev-parse HEAD` answer.
|
||||
fn parse_sha(stdout: &str) -> Result<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("the clone produced no commit id"))
|
||||
}
|
||||
|
||||
// ── The fetch itself ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Drops the staging directory however the fetch ends — mid-copy failures
|
||||
/// included — so a refused or interrupted download leaves no litter in the
|
||||
/// caller's home.
|
||||
struct Staging(PathBuf);
|
||||
|
||||
impl Drop for Staging {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
// Take the `.skald` parent too when nothing else is using it — a
|
||||
// refused download must leave no litter, and `remove_dir` on a
|
||||
// non-empty directory is the cheap no-op that says so.
|
||||
if let Some(parent) = self.0.parent() {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads `url` (only `sub_path`, when given) into the agent path
|
||||
/// `destination`, sanitizes host-side, and writes the provenance ticket.
|
||||
pub(crate) async fn fetch(
|
||||
fs: &UserFs,
|
||||
url: &str,
|
||||
sub_path: Option<&str>,
|
||||
destination: &str,
|
||||
cloner: &dyn RepoCloner,
|
||||
limits: Limits,
|
||||
) -> Result<Fetched> {
|
||||
// The root spellings converge here, not only in the tool's argument check:
|
||||
// `Some(".")` reaching the script would take the *sparse* arm and
|
||||
// `sparse-checkout set .` would materialize the root files and nothing else.
|
||||
let sub_path = sub_path.and_then(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s == "." { None } else { Some(s) }
|
||||
});
|
||||
|
||||
// An absolute spelling is container vocabulary: map it back to an agent
|
||||
// path first, so the writability check below speaks the same language as
|
||||
// the relative case. Landing nowhere means container-only, and a download
|
||||
// there could never be reviewed or registered from the host side.
|
||||
let agent = if Path::new(destination).is_absolute() {
|
||||
match fs.container_to_agent(Path::new(destination)) {
|
||||
Some(mapped) => mapped,
|
||||
None => bail!(
|
||||
"`{destination}` exists only inside your container. Download somewhere you \
|
||||
can reach from both sides — your home (`~/…`), a project or a shared folder."
|
||||
),
|
||||
}
|
||||
} else {
|
||||
destination.to_string()
|
||||
};
|
||||
|
||||
if !fs.can_write_to(&agent) {
|
||||
bail!(
|
||||
"`{destination}` is read-only for you (everything under `skills/` always is — a \
|
||||
skill is *installed* with skill_register, never downloaded into place). Pick a \
|
||||
folder in your home, or a project/shared folder you can write to."
|
||||
);
|
||||
}
|
||||
let host_dest = resolve_host_path(fs, &agent)?;
|
||||
|
||||
if host_dest.exists() {
|
||||
if !host_dest.is_dir() {
|
||||
bail!("`{destination}` already exists and is not a folder. Pick a new path.");
|
||||
}
|
||||
if std::fs::read_dir(&host_dest)
|
||||
.with_context(|| format!("cannot read {}", host_dest.display()))?
|
||||
.next()
|
||||
.is_some()
|
||||
{
|
||||
bail!(
|
||||
"`{destination}` already exists and is not empty — fetch_repo never merges \
|
||||
into files that are already there. Pick a new folder, or empty that one."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Staging lives inside the home bind mount: the container's `git` writes
|
||||
// there, and the host-side half then validates and moves from the same
|
||||
// bytes. `.skald` is transient state, hidden from the agent's listings.
|
||||
let tag = uuid::Uuid::new_v4().simple().to_string();
|
||||
let stage_host = fs.home_host.join(".skald").join(format!("fetch-{tag}"));
|
||||
let stage_container = fs.container_home.join(".skald").join(format!("fetch-{tag}"));
|
||||
std::fs::create_dir_all(&stage_host)
|
||||
.with_context(|| format!("cannot stage in {}", stage_host.display()))?;
|
||||
let _staging = Staging(stage_host.clone());
|
||||
|
||||
let commit = cloner
|
||||
.clone(url, sub_path, &stage_host, &stage_container)
|
||||
.await?;
|
||||
|
||||
// `.git` never crosses into the destination. For a subtree fetch it could
|
||||
// not travel anyway; for a root fetch this removal is the guarantee, so it
|
||||
// runs in both cases rather than being the subtree case's good fortune.
|
||||
let dotgit = stage_host.join("repo").join(".git");
|
||||
if dotgit.is_dir() {
|
||||
std::fs::remove_dir_all(&dotgit).context("cannot drop the `.git` history")?;
|
||||
} else if dotgit.exists() {
|
||||
std::fs::remove_file(&dotgit).context("cannot drop the `.git` history")?;
|
||||
}
|
||||
|
||||
let extracted = match sub_path {
|
||||
None => stage_host.join("repo"),
|
||||
Some(sub) => stage_host.join("repo").join(sub),
|
||||
};
|
||||
if !extracted.is_dir() {
|
||||
match sub_path {
|
||||
Some(sub) => bail!("the repository has no `{sub}` folder."),
|
||||
None => bail!("the clone produced no files."),
|
||||
}
|
||||
}
|
||||
|
||||
let mut found = Sanitized::default();
|
||||
sanitize(&extracted, Path::new(""), &limits, &mut found)?;
|
||||
|
||||
std::fs::create_dir_all(&host_dest)
|
||||
.with_context(|| format!("cannot create {destination}"))?;
|
||||
copy_tree(&extracted, &host_dest)?;
|
||||
|
||||
let ticket = Provenance {
|
||||
url: url.to_string(),
|
||||
sub_path: sub_path.map(str::to_string),
|
||||
commit: Some(commit.clone()),
|
||||
fetched_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||
installed_at: None,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&ticket)?;
|
||||
std::fs::write(host_dest.join(SOURCE_FILE), json)
|
||||
.with_context(|| format!("cannot write the {SOURCE_FILE} ticket"))?;
|
||||
|
||||
Ok(Fetched { files: found.files, bytes: found.bytes, commit })
|
||||
}
|
||||
|
||||
/// Recursive walk that enforces the download rules while it counts. Every
|
||||
/// refusal names what to fix — the caller is a model that will retry, and
|
||||
/// "download rejected" would only produce a guess.
|
||||
#[derive(Default)]
|
||||
struct Sanitized {
|
||||
files: usize,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
fn sanitize(dir: &Path, rel: &Path, limits: &Limits, acc: &mut Sanitized) -> Result<()> {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", dir.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let name = entry.file_name();
|
||||
let child_rel = rel.join(&name);
|
||||
// `symlink_metadata` does NOT follow the link, which is the point: a
|
||||
// link is refused for what it is, before anything asks where it points.
|
||||
let meta = std::fs::symlink_metadata(entry.path())
|
||||
.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", child_rel.display()))?;
|
||||
|
||||
if meta.file_type().is_symlink() {
|
||||
bail!(
|
||||
"the repository contains a symbolic link (`{}`). fetch_repo does not bring \
|
||||
links over — download the real file instead.",
|
||||
child_rel.display()
|
||||
);
|
||||
}
|
||||
if meta.is_dir() {
|
||||
sanitize(&entry.path(), &child_rel, limits, acc)?;
|
||||
continue;
|
||||
}
|
||||
if !meta.is_file() {
|
||||
bail!("`{}` is not a regular file.", child_rel.display());
|
||||
}
|
||||
|
||||
acc.files += 1;
|
||||
acc.bytes += meta.len();
|
||||
if acc.files > limits.max_files {
|
||||
bail!(
|
||||
"that subtree holds more than {} files — fetch_repo is for working material, \
|
||||
not bulk mirrors.",
|
||||
limits.max_files
|
||||
);
|
||||
}
|
||||
if acc.bytes > limits.max_bytes {
|
||||
bail!(
|
||||
"that subtree is over {} MiB — fetch_repo is for working material, not bulk \
|
||||
data. Clone it by hand with execute_cmd if you really need it all.",
|
||||
limits.max_bytes / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Argument parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Public repositories over https only: ssh would need credentials the caller
|
||||
/// does not have (and should not), and a local path is not a repository fetch.
|
||||
fn check_url(url: &str) -> Result<()> {
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"fetch_repo downloads from public repositories over https: `{url}`. (No ssh or \
|
||||
local paths — no credentials are involved, and none should be.)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes the `sub_path` argument: `""` / `"."` mean the repository root,
|
||||
/// anything else must be a relative path that stays inside the repo.
|
||||
fn check_sub_path(raw: &str) -> Result<Option<String>> {
|
||||
let s = raw.trim();
|
||||
if s.is_empty() || s == "." {
|
||||
return Ok(None);
|
||||
}
|
||||
let p = Path::new(s);
|
||||
if p.is_absolute() {
|
||||
bail!("`sub_path` is relative to the repository root, not absolute: `{s}`");
|
||||
}
|
||||
if p.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
bail!("`sub_path` must stay inside the repository: `{s}`");
|
||||
}
|
||||
let normalized: PathBuf = p
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(x) => Some(x),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if normalized.as_os_str().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(normalized.to_string_lossy().replace('\\', "/")))
|
||||
}
|
||||
|
||||
// ── The tool ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct FetchRepo;
|
||||
|
||||
impl Tool for FetchRepo {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::FETCH_REPO }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
fn display_name(&self) -> &str { "Fetch Repository" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Download a subtree of a public git repository (https only) into a folder you can \
|
||||
write — your home, a project or a shared folder. Shallow and sanitized: no `.git` \
|
||||
history, no symbolic links, size limits apply. It installs NOTHING: the files are \
|
||||
left at `destination`, plus a `.source.json` ticket recording the URL, sub-path and \
|
||||
exact commit. If the download is a skill, review the files and then install it with \
|
||||
`skill_register` — never download straight into `skills/`, which is read-only. \
|
||||
`destination` must not exist yet (or be an empty folder); a path that exists only \
|
||||
inside the container, such as /tmp, is refused."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["url", "destination"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The repository's https URL, e.g. \
|
||||
\"https://github.com/anthropics/skills\"."
|
||||
},
|
||||
"sub_path": {
|
||||
"type": "string",
|
||||
"description": "Folder inside the repository to download, e.g. \
|
||||
\"skills/ics-import\". Omit, or pass \"\" or \".\", \
|
||||
to take the whole repository."
|
||||
},
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"description": "Agent path of the folder to fill, e.g. \
|
||||
\"~/downloads/ics-import\". Created if missing; must be \
|
||||
empty if it already exists."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let url = args["url"].as_str().unwrap_or("?");
|
||||
let dest = args["destination"].as_str().unwrap_or("?");
|
||||
match args["sub_path"].as_str().filter(|s| !s.is_empty() && *s != ".") {
|
||||
Some(sub) => format!("download `{sub}` of {url} into `{dest}`"),
|
||||
None => format!("download {url} into `{dest}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
args["destination"].as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let fs = Arc::clone(&ctx.fs);
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let url = args["url"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `url`"))?;
|
||||
check_url(url)?;
|
||||
let sub = match args["sub_path"].as_str() {
|
||||
Some(raw) => check_sub_path(raw)?,
|
||||
None => None,
|
||||
};
|
||||
let destination = args["destination"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `destination`"))?;
|
||||
|
||||
let cloner = ContainerGit { container: fs.container_name.clone() };
|
||||
let done = fetch(&fs, url, sub.as_deref(), destination, &cloner, Limits::default()).await?;
|
||||
|
||||
let short = done.commit.chars().take(7).collect::<String>();
|
||||
Ok(ToolResult::Text(format!(
|
||||
"Fetched {} files ({:.0} KiB) from {url} @ {short} into {destination}.\n\
|
||||
A `{SOURCE_FILE}` next to the files records where they came from.\n\
|
||||
Nothing was installed — if this is a skill, review the files and then call \
|
||||
`skill_register` on `{destination}`.",
|
||||
done.files,
|
||||
done.bytes as f64 / 1024.0,
|
||||
)))
|
||||
} )))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::skills::tests_support::Tree;
|
||||
|
||||
// ── Fixture: a local git repository, cloned by the same script ────────────
|
||||
|
||||
/// The test cloner runs the **same** [`CLONE_SCRIPT`] on the host (the dev
|
||||
/// machine has git; `$4 = 0` selects the no-`timeout` arm, since macOS
|
||||
/// lacks the GNU binary). Only the Docker wrapper is faked away.
|
||||
struct HostGit;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RepoCloner for HostGit {
|
||||
async fn clone(
|
||||
&self,
|
||||
url: &str,
|
||||
sub_path: Option<&str>,
|
||||
host_dir: &Path,
|
||||
_container_dir: &Path,
|
||||
) -> Result<String> {
|
||||
let out = run_positional(
|
||||
"sh",
|
||||
&[
|
||||
"-c".into(),
|
||||
CLONE_SCRIPT.into(),
|
||||
"_".into(),
|
||||
url.into(),
|
||||
sub_path.unwrap_or("").into(),
|
||||
host_dir.to_string_lossy().into_owned(),
|
||||
"0".into(),
|
||||
],
|
||||
Duration::from_secs(60),
|
||||
)
|
||||
.await?;
|
||||
parse_sha(&out)
|
||||
}
|
||||
}
|
||||
|
||||
struct Repo(PathBuf);
|
||||
|
||||
impl Repo {
|
||||
/// A fixture repository with two top-level folders and a root file.
|
||||
fn new(tag: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"skald-fetchrepo-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("alpha")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("beta/nested")).unwrap();
|
||||
std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
|
||||
std::fs::write(dir.join("alpha/a.txt"), "alpha\n").unwrap();
|
||||
std::fs::write(dir.join("alpha/second.txt"), "second\n").unwrap();
|
||||
std::fs::write(dir.join("beta/b.txt"), "beta\n").unwrap();
|
||||
std::fs::write(dir.join("beta/nested/deep.txt"), "deep\n").unwrap();
|
||||
let r = Repo(dir);
|
||||
r.git(&["init", "-q"]);
|
||||
r.git(&["add", "-A"]);
|
||||
r.git(&["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"]);
|
||||
r
|
||||
}
|
||||
|
||||
fn git(&self, args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&self.0)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn head(&self) -> String {
|
||||
self.git(&["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
fn write(&self, rel: &str, body: &str) {
|
||||
std::fs::write(self.0.join(rel), body).unwrap();
|
||||
}
|
||||
|
||||
fn commit_all(&self) {
|
||||
self.git(&["add", "-A"]);
|
||||
self.git(&["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "more"]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Repo {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_with(fs: &UserFs, url: &str, sub: Option<&str>, dest: &str) -> Result<Fetched> {
|
||||
fetch(fs, url, sub, dest, &HostGit, Limits::default()).await
|
||||
}
|
||||
|
||||
fn listing(dir: &Path) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
for e in std::fs::read_dir(&d).unwrap().filter_map(|e| e.ok()) {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p.clone());
|
||||
}
|
||||
out.push(p.strip_prefix(dir).unwrap().to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
// ── The fetch ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Only the requested subtree lands in the destination — the rest of the
|
||||
/// repository never crosses over.
|
||||
#[tokio::test]
|
||||
async fn only_the_requested_subtree_lands_in_the_destination() {
|
||||
let repo = Repo::new("sub");
|
||||
let t = Tree::new("sub", "daniele");
|
||||
|
||||
fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("alpha"), "~/dl")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let files = listing(&t.root.join("homes/u1/dl"));
|
||||
assert!(files.contains(&"a.txt".to_string()), "{files:?}");
|
||||
assert!(files.contains(&"second.txt".to_string()), "{files:?}");
|
||||
assert!(!files.iter().any(|f| f.contains("beta") || f.contains("README")), "{files:?}");
|
||||
}
|
||||
|
||||
/// A root fetch gets everything but the `.git` history — the one thing the
|
||||
/// tool exists to keep out.
|
||||
#[tokio::test]
|
||||
async fn a_root_fetch_gets_everything_but_the_git_history() {
|
||||
let repo = Repo::new("root");
|
||||
let t = Tree::new("root", "daniele");
|
||||
|
||||
for sub in [None, Some(""), Some(".")] {
|
||||
let dest = format!("~/dl-{}", sub.unwrap_or("bare").replace('.', "dot"));
|
||||
fetch_with(&t.fs, &repo.0.to_string_lossy(), sub, &dest)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("sub {sub:?}: {e}"));
|
||||
let host = t.root.join("homes/u1").join(&dest[2..]);
|
||||
let files = listing(&host);
|
||||
assert!(files.iter().any(|f| f == "beta/nested/deep.txt"), "{files:?}");
|
||||
assert!(!files.iter().any(|f| f.contains(".git")), "{files:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticket records the exact commit — the field that makes a later
|
||||
/// upstream change detectable.
|
||||
#[tokio::test]
|
||||
async fn the_provenance_ticket_carries_the_commit() {
|
||||
let repo = Repo::new("prov");
|
||||
let t = Tree::new("prov", "daniele");
|
||||
|
||||
fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("alpha"), "~/dl")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(t.root.join("homes/u1/dl/.source.json")).unwrap();
|
||||
let p: Provenance = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(p.url, repo.0.to_string_lossy());
|
||||
assert_eq!(p.sub_path.as_deref(), Some("alpha"));
|
||||
assert_eq!(p.commit.as_deref(), Some(repo.head().as_str()));
|
||||
assert!(p.fetched_at.is_some());
|
||||
assert!(p.installed_at.is_none(), "install stamps that one");
|
||||
}
|
||||
|
||||
/// A `sub_path` the repository does not have is a speaking refusal, not an
|
||||
/// empty destination.
|
||||
#[tokio::test]
|
||||
async fn a_missing_sub_path_is_refused() {
|
||||
let repo = Repo::new("nosub");
|
||||
let t = Tree::new("nosub", "daniele");
|
||||
|
||||
let e = fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("gamma"), "~/dl")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("no `gamma` folder"), "{e}");
|
||||
assert!(!t.root.join("homes/u1/dl").exists(), "nothing landed");
|
||||
assert!(!t.root.join("homes/u1/.skald").exists(), "no staging litter");
|
||||
}
|
||||
|
||||
/// A symlink is refused for being one, wherever it points — and the
|
||||
/// destination stays untouched.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_symlink_in_the_repo_is_refused() {
|
||||
let repo = Repo::new("symlink");
|
||||
std::os::unix::fs::symlink("/etc/passwd", repo.0.join("alpha/secrets")).unwrap();
|
||||
repo.commit_all();
|
||||
let t = Tree::new("symlink", "daniele");
|
||||
|
||||
let e = fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("alpha"), "~/dl")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("symbolic link"), "{e}");
|
||||
assert!(!t.root.join("homes/u1/dl").exists(), "nothing landed");
|
||||
}
|
||||
|
||||
/// Over the file cap the refusal comes **before** a byte is written to the
|
||||
/// destination (the cap here is the test's, not the shipped one).
|
||||
#[tokio::test]
|
||||
async fn over_the_file_cap_is_refused_before_writing() {
|
||||
let repo = Repo::new("cap");
|
||||
let t = Tree::new("cap", "daniele");
|
||||
let limits = Limits { max_files: 2, max_bytes: FETCH_MAX_BYTES };
|
||||
|
||||
let e = fetch(&t.fs, &repo.0.to_string_lossy(), None, "~/dl", &HostGit, limits)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("more than 2 files"), "{e}");
|
||||
assert!(!t.root.join("homes/u1/dl").exists(), "nothing landed");
|
||||
}
|
||||
|
||||
// ── The destination rules ─────────────────────────────────────────────────
|
||||
|
||||
/// `skills/` is read-only in both directions: a download is never the way
|
||||
/// in — `skill_register` is.
|
||||
#[tokio::test]
|
||||
async fn the_skills_tree_is_not_a_destination() {
|
||||
let repo = Repo::new("ro");
|
||||
let t = Tree::new("ro", "daniele");
|
||||
|
||||
let e = fetch_with(&t.fs, &repo.0.to_string_lossy(), None, "skills/shared/x")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("read-only"), "{e}");
|
||||
}
|
||||
|
||||
/// A container-only path is refused with a message that says where to go —
|
||||
/// the download would be unreachable from the host half.
|
||||
#[tokio::test]
|
||||
async fn a_container_only_destination_is_refused() {
|
||||
let repo = Repo::new("conly");
|
||||
let t = Tree::new("conly", "daniele");
|
||||
|
||||
let e = fetch_with(&t.fs, &repo.0.to_string_lossy(), None, "/tmp/dl")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("only inside your container"), "{e}");
|
||||
}
|
||||
|
||||
/// Existing is fine only while it is empty; a non-empty folder is never
|
||||
/// merged into.
|
||||
#[tokio::test]
|
||||
async fn an_existing_destination_must_be_empty() {
|
||||
let repo = Repo::new("exists");
|
||||
let t = Tree::new("exists", "daniele");
|
||||
|
||||
let home = t.root.join("homes/u1");
|
||||
std::fs::create_dir_all(home.join("empty")).unwrap();
|
||||
std::fs::create_dir_all(home.join("full")).unwrap();
|
||||
std::fs::write(home.join("full/keep.txt"), "mine\n").unwrap();
|
||||
std::fs::write(home.join("afile"), "file\n").unwrap();
|
||||
|
||||
fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("alpha"), "~/empty")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let e = fetch_with(&t.fs, &repo.0.to_string_lossy(), None, "~/full")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("not empty"), "{e}");
|
||||
|
||||
let e = fetch_with(&t.fs, &repo.0.to_string_lossy(), None, "~/afile")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.to_string().contains("not a folder"), "{e}");
|
||||
}
|
||||
|
||||
/// The absolute spelling of a mounted path is the same destination — the
|
||||
/// container vocabulary maps back before any check runs.
|
||||
#[tokio::test]
|
||||
async fn an_absolute_home_spelling_works() {
|
||||
let repo = Repo::new("abs");
|
||||
let t = Tree::new("abs", "daniele");
|
||||
|
||||
fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("alpha"), "/root/dl")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(t.root.join("homes/u1/dl/a.txt").exists());
|
||||
}
|
||||
|
||||
// ── Argument parsing ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn only_public_https_urls_pass() {
|
||||
assert!(check_url("https://github.com/x/y").is_ok());
|
||||
assert!(check_url("http://example.com/r.git").is_ok());
|
||||
for bad in ["git@github.com:x/y.git", "ssh://git@h/r", "file:///tmp/r", "/tmp/r"] {
|
||||
assert!(check_url(bad).is_err(), "accepted `{bad}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_spellings_mean_no_subtree() {
|
||||
assert_eq!(check_sub_path("").unwrap(), None);
|
||||
assert_eq!(check_sub_path(".").unwrap(), None);
|
||||
assert_eq!(check_sub_path("a/b").unwrap(), Some("a/b".to_string()));
|
||||
assert_eq!(check_sub_path("./a/./b").unwrap(), Some("a/b".to_string()));
|
||||
assert!(check_sub_path("/etc").is_err());
|
||||
assert!(check_sub_path("../out").is_err());
|
||||
assert!(check_sub_path("a/../../out").is_err());
|
||||
}
|
||||
|
||||
// ── The crossing into an installation ─────────────────────────────────────
|
||||
|
||||
/// What `fetch_repo` leaves behind is what `skill_register` picks up: the
|
||||
/// ticket crosses into the installed skill, stamped with the install date.
|
||||
#[tokio::test]
|
||||
async fn a_fetched_skill_registers_with_its_provenance() {
|
||||
let repo = Repo::new("cross");
|
||||
repo.write("alpha/SKILL.md", &crate::skills::tests_support::valid("alpha-x", "The x."));
|
||||
repo.commit_all();
|
||||
let t = Tree::new("cross", "daniele");
|
||||
|
||||
fetch_with(&t.fs, &repo.0.to_string_lossy(), Some("alpha"), "~/draft")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let host = t.root.join("homes/u1/draft");
|
||||
let done = crate::skills::install::install(&t.fs, crate::skills::Scope::Own, &host).unwrap();
|
||||
assert_eq!(done.id, "alpha-x");
|
||||
|
||||
let p = crate::skills::install::read_provenance(
|
||||
&t.root.join("skills-users/u1/alpha-x"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.commit.as_deref(), Some(repo.head().as_str()));
|
||||
assert!(p.installed_at.is_some(), "the install stamped its date");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use anyhow::{Context, Result};
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use core_api::user_fs::UserFs;
|
||||
use core_api::user_fs::{RouteError, UserFs};
|
||||
|
||||
use crate::tools::{SimpleExecution, ToolExecution, ToolRegistry, ToolResult};
|
||||
|
||||
@@ -221,9 +221,11 @@ pub(super) fn write_string(user_path: &str, content: &str) -> Result<()> {
|
||||
/// from inside the container (`execute_cmd`), a symlink planted there that points
|
||||
/// outside the home is caught by canonicalizing and prefix-checking against the base.
|
||||
pub(crate) fn resolve_host_path(fs: &UserFs, agent_path: &str) -> Result<PathBuf> {
|
||||
let (base, tail) = fs.host_base_and_tail(agent_path).ok_or_else(|| {
|
||||
anyhow::anyhow!("no such shared folder, or you are not a member: {agent_path}")
|
||||
})?;
|
||||
let (base, tail) = match fs.host_base_and_tail(agent_path) {
|
||||
Ok(pair) => pair,
|
||||
Err(RouteError::Denied(msg)) => anyhow::bail!(msg),
|
||||
Err(RouteError::SkillAlias { id, tail }) => resolve_skill_alias(fs, &id, &tail)?,
|
||||
};
|
||||
// Canonicalize both sides so the prefix check is symlink-aware.
|
||||
let base_canon = canonicalize_for_policy(&base.to_string_lossy(), Path::new("/"));
|
||||
let joined = base.join(&tail);
|
||||
@@ -234,6 +236,55 @@ pub(crate) fn resolve_host_path(fs: &UserFs, agent_path: &str) -> Result<PathBuf
|
||||
Ok(canon)
|
||||
}
|
||||
|
||||
/// Resolves the tolerant bare-id alias `skills/<id>/…` — the shortest spelling of a
|
||||
/// skill path, and therefore the one a model reaches for on its own, both out of
|
||||
/// habit and because skill bodies written elsewhere cite it that way.
|
||||
///
|
||||
/// It resolves **only when the id lives in exactly one** of the two trees. A
|
||||
/// collision fails loudly, listing both full paths, rather than letting either win:
|
||||
/// the personal tree winning would mean a silent divergence from the group's set,
|
||||
/// the group's winning would mean the member's own work is ignored, and neither is
|
||||
/// something to decide behind the model's back. With the full path printed in the
|
||||
/// index the disambiguation is free anyway — they are two different lines.
|
||||
///
|
||||
/// The root itself is probed last, so the signpost `skills/README.md` reads like any
|
||||
/// other file rather than being the one path in the tree that fails.
|
||||
fn resolve_skill_alias(fs: &UserFs, id: &str, tail: &str) -> Result<(PathBuf, String)> {
|
||||
let found: Vec<(String, PathBuf)> = fs
|
||||
.skill_alias_candidates(id)
|
||||
.into_iter()
|
||||
.filter(|(_, host)| host.is_dir())
|
||||
.collect();
|
||||
|
||||
match found.len() {
|
||||
1 => {
|
||||
let (_, host) = found.into_iter().next().expect("len checked");
|
||||
Ok((host, tail.to_string()))
|
||||
}
|
||||
0 => {
|
||||
// Not a skill id. It may still be something in the root mount — the
|
||||
// signpost README — before it is nothing at all.
|
||||
if let Some(sk) = fs.skills.as_ref().filter(|sk| sk.root_host.join(id).exists()) {
|
||||
return Ok((sk.root_host.clone(), agent_join_str(id, tail)));
|
||||
}
|
||||
anyhow::bail!(fs.skill_route_hint(id))
|
||||
}
|
||||
_ => {
|
||||
let paths: Vec<String> = found.into_iter().map(|(agent, _)| agent).collect();
|
||||
anyhow::bail!(
|
||||
"`skills/{id}` is ambiguous — that id exists in more than one place. \
|
||||
Use the full path: {}",
|
||||
paths.join(" or ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins a first segment with a possibly-empty tail, for a path relative to a base.
|
||||
fn agent_join_str(head: &str, tail: &str) -> String {
|
||||
if tail.is_empty() { head.to_string() } else { format!("{head}/{tail}") }
|
||||
}
|
||||
|
||||
/// Resolve a path arriving from the show-file / file-viewer surface into
|
||||
/// `(host_abs, agent_display)`, scoped to the caller's workspace.
|
||||
///
|
||||
@@ -990,4 +1041,89 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&udir);
|
||||
let _ = std::fs::remove_dir_all(&sdir);
|
||||
}
|
||||
|
||||
/// The skills tree end to end, on disk: both scopes resolve, the bare-id alias
|
||||
/// resolves only when unambiguous, a collision fails loudly naming both paths,
|
||||
/// an invented scope segment is refused with a hint instead of quietly becoming
|
||||
/// a file in the home, and containment holds inside a skill exactly as it does
|
||||
/// in the home.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn skills_tree_routes_and_contains() {
|
||||
use core_api::user_fs::SkillMounts;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("skald-skills-{}", std::process::id()));
|
||||
let home = root.join("homes").join("u1");
|
||||
let skroot = root.join(".skills-root").join("u1");
|
||||
let shared = root.join("skills");
|
||||
let own = root.join("skills-users").join("u1");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for d in [&home, &skroot, &shared, &own] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(skroot.join("README.md"), "signpost").unwrap();
|
||||
std::fs::create_dir_all(shared.join("ics-import")).unwrap();
|
||||
std::fs::write(shared.join("ics-import").join("SKILL.md"), "shared one").unwrap();
|
||||
std::fs::create_dir_all(own.join("spesa")).unwrap();
|
||||
std::fs::write(own.join("spesa").join("SKILL.md"), "mine").unwrap();
|
||||
|
||||
let fs = UserFs::new(
|
||||
"u1",
|
||||
home.clone(),
|
||||
"skald-u1",
|
||||
PathBuf::from("/root"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.with_skills(SkillMounts {
|
||||
root_host: skroot.clone(),
|
||||
shared_host: shared.clone(),
|
||||
own_host: own.clone(),
|
||||
own_username: "daniele".into(),
|
||||
});
|
||||
|
||||
let read = |p: &str| std::fs::read_to_string(resolve_host_path(&fs, p).unwrap()).unwrap();
|
||||
|
||||
// Both scopes, spelled fully.
|
||||
assert_eq!(read("skills/shared/ics-import/SKILL.md"), "shared one");
|
||||
assert_eq!(read("skills/daniele/spesa/SKILL.md"), "mine");
|
||||
// The container spelling reaches the same files (reverse-mapped by
|
||||
// `resolve_target`, which is what an absolute path goes through).
|
||||
let via_container = match resolve_target(&fs, "/root/skills/shared/ics-import/SKILL.md").unwrap() {
|
||||
FsTarget::Host(h) => h,
|
||||
FsTarget::Container { path, .. } => panic!("mounted skill routed to the container as {path:?}"),
|
||||
};
|
||||
assert_eq!(std::fs::read_to_string(via_container).unwrap(), "shared one");
|
||||
// The signpost is readable rather than being the one path in the tree that fails.
|
||||
assert_eq!(read("skills/README.md"), "signpost");
|
||||
|
||||
// The bare-id alias: the shortest spelling, resolving because each id is
|
||||
// unique across the two trees.
|
||||
assert_eq!(read("skills/ics-import/SKILL.md"), "shared one");
|
||||
assert_eq!(read("skills/spesa/SKILL.md"), "mine");
|
||||
|
||||
// Same id in both trees: neither wins, and the error names both full paths.
|
||||
std::fs::create_dir_all(own.join("ics-import")).unwrap();
|
||||
std::fs::write(own.join("ics-import").join("SKILL.md"), "my fork").unwrap();
|
||||
let err = resolve_host_path(&fs, "skills/ics-import/SKILL.md").unwrap_err().to_string();
|
||||
assert!(err.contains("skills/shared/ics-import"), "{err}");
|
||||
assert!(err.contains("skills/daniele/ics-import"), "{err}");
|
||||
// The full paths still work while the alias is ambiguous.
|
||||
assert_eq!(read("skills/shared/ics-import/SKILL.md"), "shared one");
|
||||
assert_eq!(read("skills/daniele/ics-import/SKILL.md"), "my fork");
|
||||
|
||||
// An invented scope segment: refused with a hint, and — the part that matters
|
||||
// — it never becomes a path under the home that no indexer would ever read.
|
||||
let err = resolve_host_path(&fs, "skills/pippo/SKILL.md").unwrap_err().to_string();
|
||||
assert!(err.contains("other members' skills are not accessible"), "{err}");
|
||||
assert!(!home.join("skills").exists(), "the invented scope leaked into the home");
|
||||
|
||||
// Containment inside a skill: a symlink planted in one cannot lead out of it.
|
||||
std::os::unix::fs::symlink(&root, shared.join("ics-import").join("escape")).unwrap();
|
||||
assert!(resolve_host_path(&fs, "skills/shared/ics-import/escape/homes/u1/x").is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Tool for ListItems {
|
||||
• `cron` — scheduled tasks/cron jobs with id, title, cron expression, agent_id, enabled, kind, last/next run.\n\
|
||||
• `agents` — sub-agents available to delegate to (id, name, description, optional `instructions` on how to call the agent well, optional client). Do NOT invoke the `main` agent.\n\
|
||||
• `mcp` — MCP servers, which users call \"Connectors\": which ones are already loaded into this session, which are ready for `activate_tools`, which are installed but unusable and why, and which the user could still activate. Read this before assuming a connector is missing.\n\
|
||||
• `skills` — installed skills, in both scopes: id, scope, path, the FULL description (the prompt index shows a shortened one), size, whether it is healthy, and where it was fetched from. Use this to answer \"which skills do I have?\" and to find the id before deleting one.\n\
|
||||
To list stored secret names use `list_secrets` instead."
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ impl Tool for ListItems {
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["plugins", "cron", "agents", "mcp"],
|
||||
"enum": ["plugins", "cron", "agents", "mcp", "skills"],
|
||||
"description": "Which kind of item to list."
|
||||
}
|
||||
}
|
||||
@@ -70,10 +71,21 @@ impl Tool for ListItems {
|
||||
format!("list {kind}")
|
||||
}
|
||||
|
||||
/// `mcp` is the one type that needs the caller: which connectors are theirs,
|
||||
/// which are loaded into *this* session, and what their role may do. The
|
||||
/// other three are instance-wide and stay on the context-free `execute`.
|
||||
/// Two types need the caller. `mcp`: which connectors are theirs, which are
|
||||
/// loaded into *this* session, what their role may do. `skills`: half the
|
||||
/// tree is that member's own, so the answer is per-user by construction —
|
||||
/// `ctx.fs` **is** the question "which skills can this user see", already
|
||||
/// answered, which is why nothing here queries anything. The other three are
|
||||
/// instance-wide and stay on the context-free `execute`.
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
if args["type"].as_str() == Some("skills") {
|
||||
let fs = ctx.fs.clone();
|
||||
return Box::new(crate::tools::SimpleExecution::new(Box::pin(async move {
|
||||
Ok(crate::tools::ToolResult::Json(Value::Array(
|
||||
crate::skills::inventory::report(&fs),
|
||||
)))
|
||||
})));
|
||||
}
|
||||
if args["type"].as_str() != Some("mcp") {
|
||||
return self.run(args);
|
||||
}
|
||||
@@ -102,8 +114,8 @@ impl Tool for ListItems {
|
||||
match kind {
|
||||
// Reached only through the context-free `execute` (no caller, so no
|
||||
// report to build) — `run_with` intercepts the real call path.
|
||||
"mcp" => anyhow::bail!(
|
||||
"list_items: type `mcp` needs a session context and was called without one"
|
||||
"mcp" | "skills" => anyhow::bail!(
|
||||
"list_items: type `{kind}` needs a session context and was called without one"
|
||||
),
|
||||
"plugins" => {
|
||||
let plugins = tokio::task::block_in_place(|| {
|
||||
@@ -156,7 +168,7 @@ impl Tool for ListItems {
|
||||
.collect();
|
||||
Ok(serde_json::to_string_pretty(&arr)?)
|
||||
}
|
||||
other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: plugins, cron, agents, mcp)"),
|
||||
other => anyhow::bail!("list_items: unknown type `{other}` (expected one of: plugins, cron, agents, mcp, skills)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn is_file_write_tool(name: &str) -> bool {
|
||||
|
||||
/// Tools that read file contents or directory listings from disk.
|
||||
/// Used by the approval gate to apply the `RunContext` read fast-path (auto-allow
|
||||
/// working dir / `docs/` / `skills/` / `allow_fs_reads`). All take a `path` argument.
|
||||
/// working dir / `docs/` / `allow_fs_reads`). All take a `path` argument.
|
||||
/// Update this list whenever a new file-read tool is added.
|
||||
pub const FILE_READ_TOOLS: &[&str] = &[
|
||||
"read_file",
|
||||
@@ -36,6 +36,7 @@ pub mod ast_outline;
|
||||
pub mod configure_plugin;
|
||||
pub mod cron_jobs;
|
||||
pub mod exec;
|
||||
pub mod fetch_repo;
|
||||
pub mod fs;
|
||||
pub mod image_generate;
|
||||
pub mod list_items;
|
||||
@@ -43,6 +44,7 @@ pub mod mcp_report;
|
||||
pub mod list_secrets;
|
||||
pub mod notify;
|
||||
pub mod set_secret;
|
||||
pub mod skills;
|
||||
pub mod read_notification;
|
||||
pub mod show_file;
|
||||
pub mod toggle_item;
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! `skill_register` and `skill_delete` — the whole write surface of the skills
|
||||
//! trees (blueprint §7.3/§7.4).
|
||||
//!
|
||||
//! Both live in the **`Config` category**, so they are absent from the schema of
|
||||
//! every request until `activate_tools(["config"])` asks for them. The round that
|
||||
//! costs is a fair price for administration, but the real gain is elsewhere: a
|
||||
//! prompt injection cannot reach a tool the model has not been shown, so it must
|
||||
//! first make the model *activate the group* — one more step, and a step that
|
||||
//! leaves a line in the transcript.
|
||||
//!
|
||||
//! Authorization is a **capability on the role**, checked server-side (§14).
|
||||
//! `scope: "global"` needs `skill.manage`; `scope: "mine"` is always the
|
||||
//! caller's own. Never inferred from anything the prompt says about who the user
|
||||
//! is — the same shape as `mcp.register_local_script` versus
|
||||
//! `mcp.register_remote`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::skills::{PromptPrefixCell, PromptScope, Scope, install};
|
||||
use crate::tools::fs::{FsTarget, resolve_target};
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
};
|
||||
|
||||
/// Everything both tools need: the registry (to read the caller's role) and the
|
||||
/// seam that tells live conversations their index moved.
|
||||
struct Deps {
|
||||
registry: Arc<SqlitePool>,
|
||||
prefixes: Arc<PromptPrefixCell>,
|
||||
}
|
||||
|
||||
impl Deps {
|
||||
/// Whether this caller may write to the group's tree.
|
||||
///
|
||||
/// A failure to *read* the role is a denial, not a pass: the group's scope is
|
||||
/// the one that puts text into everybody's prompt, and "the database hiccuped"
|
||||
/// is not a reason to widen.
|
||||
async fn may_manage_shared(&self, user_id: &str) -> bool {
|
||||
let role = match crate::db::users::get(&self.registry, user_id).await {
|
||||
Ok(Some(u)) => u.role_id,
|
||||
Ok(None) => return false,
|
||||
Err(e) => {
|
||||
tracing::warn!(user = %user_id, error = %e, "skills: cannot read role, denying global scope");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
crate::db::role_capabilities::has(
|
||||
&self.registry,
|
||||
&role,
|
||||
crate::db::role_capabilities::MANAGE_SKILLS,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn authorize(&self, user_id: &str, scope: Scope) -> Result<()> {
|
||||
if scope == Scope::Shared && !self.may_manage_shared(user_id).await {
|
||||
anyhow::bail!(
|
||||
"you are not allowed to change the group's skills. Use scope \"mine\" for a \
|
||||
skill of your own, or ask an admin to install this one for everybody."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Announces that the rendered index has moved, so a conversation that is
|
||||
/// already warm does not keep quoting the old one for twenty minutes.
|
||||
async fn invalidate(&self, user_id: &str, scope: Scope) {
|
||||
let scope = match scope {
|
||||
Scope::Shared => PromptScope::Everyone,
|
||||
Scope::Own => PromptScope::User(user_id.to_string()),
|
||||
};
|
||||
self.prefixes.invalidate(scope).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the `scope` argument shared by both tools.
|
||||
fn scope_arg(args: &Value) -> Result<Scope> {
|
||||
let raw = args["scope"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `scope` (\"mine\" or \"global\")"))?;
|
||||
Scope::parse(raw)
|
||||
}
|
||||
|
||||
/// The `scope` property, identical in both schemas.
|
||||
fn scope_property() -> Value {
|
||||
json!({
|
||||
"type": "string",
|
||||
"enum": ["mine", "global"],
|
||||
"description": "\"mine\" — your own skills, visible only to you. \
|
||||
\"global\" — the group's skills, which every member reads as \
|
||||
instructions (requires the skill.manage capability)."
|
||||
})
|
||||
}
|
||||
|
||||
// ── skill_register ────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SkillRegister(Deps);
|
||||
|
||||
impl SkillRegister {
|
||||
pub fn new(registry: Arc<SqlitePool>, prefixes: Arc<PromptPrefixCell>) -> Self {
|
||||
Self(Deps { registry, prefixes })
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for SkillRegister {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::SKILL_REGISTER }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
fn display_name(&self) -> &str { "Install Skill" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Install a skill folder into the read-only skills tree — the only way to add one. \
|
||||
The folder must live somewhere you can write (your home, a project or a shared \
|
||||
folder), NOT in the container-only filesystem such as /tmp, and must contain a \
|
||||
`SKILL.md` opening with a YAML frontmatter block declaring `name` (lowercase \
|
||||
letters, digits and hyphens) and `description` (when to use the skill, under 1000 \
|
||||
characters). The installed folder is named after that `name`, not after the source \
|
||||
folder. Registering an id that already exists in the same scope replaces it — that \
|
||||
is how a skill is updated; a skill is never edited in place. Read `docs/skills.md` \
|
||||
before writing one."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["scope", "path"],
|
||||
"properties": {
|
||||
"scope": scope_property(),
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path of the folder to install, e.g. \"~/drafts/ics-import\". \
|
||||
It is copied, not moved."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
match args["scope"].as_str() {
|
||||
Some("global") => format!("install `{path}` as a skill for the whole group"),
|
||||
_ => format!("install `{path}` as one of your skills"),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
args["path"].as_str().map(str::to_string)
|
||||
}
|
||||
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let fs = ctx.fs.clone();
|
||||
let user_id = ctx.user_id.clone();
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let scope = scope_arg(&args)?;
|
||||
self.0.authorize(&user_id, scope).await?;
|
||||
|
||||
let path = args["path"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `path`"))?;
|
||||
|
||||
// The copy is host-side, so the source has to be on a mount. `/tmp`
|
||||
// is the first place a model puts a working folder, and a bare
|
||||
// ENOENT there reads as "the folder is gone" rather than "wrong side
|
||||
// of the boundary" — so say which it is.
|
||||
let host = match resolve_target(&fs, path)? {
|
||||
FsTarget::Host(p) => p,
|
||||
FsTarget::Container { .. } => anyhow::bail!(
|
||||
"`{path}` exists only inside your container, and a skill is installed from \
|
||||
the host side. Move the folder into your home (e.g. `~/{}`) and register \
|
||||
that path instead.",
|
||||
std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "my-skill".into())
|
||||
),
|
||||
};
|
||||
|
||||
let done = install::install(&fs, scope, &host)?;
|
||||
self.0.invalidate(&user_id, scope).await;
|
||||
|
||||
let verb = if done.replaced { "Replaced" } else { "Installed" };
|
||||
Ok(ToolResult::Text(format!(
|
||||
"{verb} skill `{}` at {}. It is in the index now — read it back with \
|
||||
`read_file {}/SKILL.md`.",
|
||||
done.id, done.agent_dir, done.agent_dir
|
||||
)))
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// ── skill_delete ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SkillDelete(Deps);
|
||||
|
||||
impl SkillDelete {
|
||||
pub fn new(registry: Arc<SqlitePool>, prefixes: Arc<PromptPrefixCell>) -> Self {
|
||||
Self(Deps { registry, prefixes })
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for SkillDelete {
|
||||
fn name(&self) -> &str { crate::tools::tool_names::SKILL_DELETE }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Config }
|
||||
fn display_name(&self) -> &str { "Delete Skill" }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Remove an installed skill. The id is its folder name — use \
|
||||
`list_items` with type=skills to see the installed ids and scopes. \
|
||||
There is no recycle bin: the folder is deleted."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["scope", "id"],
|
||||
"properties": {
|
||||
"scope": scope_property(),
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The skill's id — its folder name, e.g. \"ics-import\"."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, _length: ToolDescriptionLength) -> String {
|
||||
let id = args["id"].as_str().unwrap_or("?");
|
||||
match args["scope"].as_str() {
|
||||
// Said in full on the card: this removes something from every
|
||||
// member's prompt, not just from the caller's.
|
||||
Some("global") => format!("delete skill `{id}` for the whole group"),
|
||||
_ => format!("delete your skill `{id}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let fs = ctx.fs.clone();
|
||||
let user_id = ctx.user_id.clone();
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let scope = scope_arg(&args)?;
|
||||
self.0.authorize(&user_id, scope).await?;
|
||||
let id = args["id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument `id`"))?;
|
||||
|
||||
install::remove(&fs, scope, id)?;
|
||||
self.0.invalidate(&user_id, scope).await;
|
||||
Ok(ToolResult::Text(format!("Deleted skill `{id}` ({}).", scope.as_arg())))
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
// `connect_lazy` performs no I/O, and neither tool touches the pool
|
||||
// unless it is asked for the group's scope.
|
||||
let pool = Arc::new(SqlitePool::connect_lazy("sqlite::memory:").unwrap());
|
||||
let cell = Arc::new(PromptPrefixCell::default());
|
||||
let mut r = ToolRegistry::new();
|
||||
r.register(SkillRegister::new(Arc::clone(&pool), Arc::clone(&cell)));
|
||||
r.register(SkillDelete::new(pool, cell));
|
||||
r
|
||||
}
|
||||
|
||||
fn names(defs: &[Value]) -> Vec<String> {
|
||||
defs.iter()
|
||||
.filter_map(|d| d["function"]["name"].as_str().map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The two write verbs are absent from the schema of an ordinary request and
|
||||
/// arrive only with `activate_tools(["config"])`. That extra round is the
|
||||
/// price of administration; the gain is that a prompt injection has to make
|
||||
/// the model activate the group first — a step that shows in the transcript.
|
||||
#[tokio::test]
|
||||
async fn the_write_verbs_are_invisible_until_the_config_group_is_activated() {
|
||||
let r = registry();
|
||||
assert!(names(&r.openai_definitions_excluding_config()).is_empty());
|
||||
|
||||
let mut lazy = names(&r.openai_definitions_config_only());
|
||||
lazy.sort();
|
||||
assert_eq!(lazy, vec!["skill_delete".to_string(), "skill_register".to_string()]);
|
||||
}
|
||||
|
||||
/// Enumerating is harmless and is what makes "delete the X skill" possible
|
||||
/// without guessing an id, so it stays in every request.
|
||||
#[test]
|
||||
fn enumeration_is_not_in_the_lazy_group() {
|
||||
assert_ne!(
|
||||
crate::tools::ToolCategory::Config,
|
||||
crate::tools::ToolCategory::Introspection,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scope_argument_is_the_vocabulary_the_tools_take() {
|
||||
assert_eq!(Scope::parse("mine").unwrap(), Scope::Own);
|
||||
assert_eq!(Scope::parse("global").unwrap(), Scope::Shared);
|
||||
// Not a username: a tool that asked for one would invite passing
|
||||
// somebody else's, which the server would have to ignore anyway.
|
||||
assert!(Scope::parse("daniele").is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,12 @@ pub const READ_NOTIFICATION: &str = "read_notification";
|
||||
pub const EXECUTE_CMD: &str = "execute_cmd";
|
||||
pub const SHOW_FILE_TO_USER: &str = "show_file_to_user";
|
||||
pub const IMAGE_GENERATE: &str = "image_generate";
|
||||
/// The one write verb of the read-only skills trees (blueprint §7.3). Named here
|
||||
/// because the approval gate builds it a review card of its own.
|
||||
pub const SKILL_REGISTER: &str = "skill_register";
|
||||
pub const SKILL_DELETE: &str = "skill_delete";
|
||||
/// Downloads a subtree of a public git repository into the caller's workspace
|
||||
/// (blueprint §7.5). Deliberately not `git_clone`: it is shallow, drops `.git`,
|
||||
/// sanitizes, and leaves a `.source.json` provenance ticket — none of which a
|
||||
/// name borrowed from git would promise.
|
||||
pub const FETCH_REPO: &str = "fetch_repo";
|
||||
|
||||
Reference in New Issue
Block a user