feat(skills): rebuild the skill system for the multi-user model
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:
Daniele
2026-08-08 23:05:35 +01:00
parent 71e1a26b08
commit c27da4e6ab
88 changed files with 4624 additions and 9546 deletions
+28
View File
@@ -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
View File
@@ -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")));
}
}