From e658821f1e995f14675939ddaed9aee645c4f965 Mon Sep 17 00:00:00 2001 From: Alex Oliveira <4482374+aroff@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:45:12 +0000 Subject: [PATCH 1/2] feat(managed): enroll, apply, status and unenroll a signed managed state Apply deploys the listed skills from a private store into each agent's user skills folder, as a link or a verified copy, records what it owns, adopts identical entries, reports collisions and project id clashes, removes unlisted owned entries, and quarantines blocked, disallowed or changed content instead of deleting it. One apply runs at a time per user. Bumps aikit to 9de6b40 for the per-user skills folders. --- Cargo.lock | 36 +- Cargo.toml | 8 +- .../src/commands/eval/run/execute.rs | 2 + crates/fastskill-cli/src/commands/managed.rs | 457 ++++++++++++ .../src/commands/managed_tests.rs | 325 +++++++++ crates/fastskill-cli/src/commands/mod.rs | 1 + crates/fastskill-cli/src/main.rs | 40 +- .../tests/command_namespace_test.rs | 6 +- .../fastskill-cli/tests/managed_cli_test.rs | 78 ++ crates/fastskill-core/Cargo.toml | 3 + .../fastskill-core/src/core/managed/apply.rs | 462 ++++++++++++ .../src/core/managed/apply_tests.rs | 672 ++++++++++++++++++ .../fastskill-core/src/core/managed/config.rs | 13 +- .../src/core/managed/enrollment.rs | 179 +++++ .../fastskill-core/src/core/managed/layout.rs | 126 ++++ crates/fastskill-core/src/core/managed/mod.rs | 17 + .../src/core/managed/quarantine.rs | 143 ++++ .../src/core/managed/records.rs | 90 +++ .../fastskill-core/src/core/managed/store.rs | 133 ++++ .../fastskill-core/src/core/managed/tests.rs | 6 +- crates/fastskill-core/src/write_ops.rs | 16 + ...apshot_helpers__cli_directory_walking.snap | 11 +- ...pers__cli_finds_skills_in_current_dir.snap | 11 +- ...i__snapshot_helpers__cli_with_env_var.snap | 11 +- ...sts__cli__snapshot_helpers__help_flag.snap | 11 +- ...__cli__snapshot_helpers__help_no_args.snap | 11 +- ...li__snapshot_helpers__help_short_flag.snap | 11 +- webdocs/cli-reference/managed-command.mdx | 110 +++ webdocs/cli-reference/meta.json | 1 + 29 files changed, 2930 insertions(+), 60 deletions(-) create mode 100644 crates/fastskill-cli/src/commands/managed.rs create mode 100644 crates/fastskill-cli/src/commands/managed_tests.rs create mode 100644 crates/fastskill-cli/tests/managed_cli_test.rs create mode 100644 crates/fastskill-core/src/core/managed/apply.rs create mode 100644 crates/fastskill-core/src/core/managed/apply_tests.rs create mode 100644 crates/fastskill-core/src/core/managed/enrollment.rs create mode 100644 crates/fastskill-core/src/core/managed/layout.rs create mode 100644 crates/fastskill-core/src/core/managed/quarantine.rs create mode 100644 crates/fastskill-core/src/core/managed/records.rs create mode 100644 crates/fastskill-core/src/core/managed/store.rs create mode 100644 webdocs/cli-reference/managed-command.mdx diff --git a/Cargo.lock b/Cargo.lock index 550c6fb7..b7333dd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,7 +45,7 @@ dependencies = [ [[package]] name = "aikit-agent" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=eb50f2c5509d4345cab65af0f7faed012ab0e557#eb50f2c5509d4345cab65af0f7faed012ab0e557" +source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" dependencies = [ "reqwest 0.12.28", "serde", @@ -58,7 +58,7 @@ dependencies = [ [[package]] name = "aikit-evals" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=eb50f2c5509d4345cab65af0f7faed012ab0e557#eb50f2c5509d4345cab65af0f7faed012ab0e557" +source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" dependencies = [ "aikit-sdk", "async-trait", @@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "aikit-sdk" version = "0.3.0" -source = "git+https://github.com/goaikit/aikit.git?rev=eb50f2c5509d4345cab65af0f7faed012ab0e557#eb50f2c5509d4345cab65af0f7faed012ab0e557" +source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" dependencies = [ "aikit-agent", "claude-agent-sdk", @@ -88,6 +88,7 @@ dependencies = [ "jsonschema", "nix", "reqwest 0.12.28", + "schemars", "serde", "serde_json", "sha2 0.10.9", @@ -102,7 +103,7 @@ dependencies = [ [[package]] name = "aikit-skillopt" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=eb50f2c5509d4345cab65af0f7faed012ab0e557#eb50f2c5509d4345cab65af0f7faed012ab0e557" +source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" dependencies = [ "aikit-evals", "aikit-sdk", @@ -115,7 +116,7 @@ dependencies = [ [[package]] name = "aikit-textgrad" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=eb50f2c5509d4345cab65af0f7faed012ab0e557#eb50f2c5509d4345cab65af0f7faed012ab0e557" +source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" dependencies = [ "aikit-evals", "aikit-sdk", @@ -496,7 +497,7 @@ version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ - "darling 0.23.0", + "darling 0.20.11", "ident_case", "prettyplease", "proc-macro2", @@ -693,7 +694,7 @@ dependencies = [ [[package]] name = "cli-framework" version = "0.5.8" -source = "git+https://github.com/aroff/cli-framework?rev=f24518f19423ce5ebbf73ffb9396161a8dde0d11#f24518f19423ce5ebbf73ffb9396161a8dde0d11" +source = "git+https://github.com/aroff/cli-framework?rev=971134fd4257578ba19e95faae5d7a1c1bc74bde#971134fd4257578ba19e95faae5d7a1c1bc74bde" dependencies = [ "aikit-sdk", "ailoop-core", @@ -1249,7 +1250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1355,6 +1356,7 @@ dependencies = [ name = "fastskill-core" version = "0.9.269" dependencies = [ + "aikit-sdk", "ammonia", "anyhow", "assert_cmd", @@ -2005,7 +2007,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3016,7 +3018,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.21", "tokio", "tracing", @@ -3054,9 +3056,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3518,7 +3520,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3531,7 +3533,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3589,7 +3591,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4143,7 +4145,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5018,7 +5020,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5c930875..80e41ee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,11 +20,11 @@ repository = "https://github.com/gofastskill/fastskill" # and compiles two mutually-incompatible copies into the binary. # # aikit-sdk for agent metadata file resolution -aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "eb50f2c5509d4345cab65af0f7faed012ab0e557" } +aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "9de6b40db1703c921d1be327d0e417be1f734c03" } # aikit-evals provides the generic eval runner infrastructure -aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "eb50f2c5509d4345cab65af0f7faed012ab0e557" } +aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "9de6b40db1703c921d1be327d0e417be1f734c03" } # aikit-skillopt provides the text-gradient skill optimization loop -aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "eb50f2c5509d4345cab65af0f7faed012ab0e557" } +aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "9de6b40db1703c921d1be327d0e417be1f734c03" } # Async runtime tokio = { version = "1.52", features = ["rt-multi-thread", "net", "fs", "io-util", "macros", "process"] } @@ -66,7 +66,7 @@ tower = { version = "0.5", features = ["util", "timeout", "limit"] } tower-http = { version = "0.7", features = ["cors", "trace", "compression-gzip", "fs"] } # CLI framework for versioned API server hosting -cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "f24518f19423ce5ebbf73ffb9396161a8dde0d11", default-features = false, features = ["api-server"] } +cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "971134fd4257578ba19e95faae5d7a1c1bc74bde", default-features = false, features = ["api-server"] } # MCP protocol types and transports. Version and feature set MUST track # cli-framework's own `rmcp` dependency: `fastskill mcp serve` wraps diff --git a/crates/fastskill-cli/src/commands/eval/run/execute.rs b/crates/fastskill-cli/src/commands/eval/run/execute.rs index c47fb02f..e8a97617 100644 --- a/crates/fastskill-cli/src/commands/eval/run/execute.rs +++ b/crates/fastskill-cli/src/commands/eval/run/execute.rs @@ -335,6 +335,8 @@ async fn run_agent( // A failed case's scratch workspace is moved here so it survives // for debugging; successful workspaces are deleted. retain_workspace_in: Some(run_dir.join("workspaces")), + // The harness capture isn't offered from the CLI yet. + capture_harness: false, }; if !plan.use_json { diff --git a/crates/fastskill-cli/src/commands/managed.rs b/crates/fastskill-cli/src/commands/managed.rs new file mode 100644 index 00000000..cb36d9dc --- /dev/null +++ b/crates/fastskill-cli/src/commands/managed.rs @@ -0,0 +1,457 @@ +//! `fastskill managed`: follow a signed managed state for this user's agents +//! ([ADR-0016](../../../../docs/adr/0016-machines-follow-a-signed-managed-state.md)). +//! +//! `enroll`, `apply` and `unenroll` are writes, `status` is a read (ADR-0003). The work is in +//! `fastskill_core::core::managed`; this module reads the settings, runs it and renders it. + +use crate::error::{CliError, CliResult}; +use cli_framework::command::{FromArgValueMap, IntoCommandSpec}; +use cli_framework::spec::arg_spec::{ArgKind, ArgSpec, ArgValueType, Cardinality}; +use cli_framework::spec::command_tree::{CommandSpec, GroupMetadata}; +use cli_framework::spec::value::ArgValue; +use fastskill_core::core::managed::config::user_file_path; +use fastskill_core::core::managed::{ + self, ApplyContext, ApplyOutcome, ApplyResult, ManagedLayout, ManagedSettings, ManagedStatus, + UnenrollOutcome, +}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::IsTerminal; +use std::path::PathBuf; + +/// The `managed` group. +pub fn group_metadata() -> GroupMetadata { + GroupMetadata { + summary: "Follow a signed managed state for this user's agents", + hidden: false, + category: Some("Operations"), + help_order: Some(50), + } +} + +/// The options every `managed` command takes. +#[derive(Debug, Default)] +pub struct ManagedArgs { + pub json: bool, + /// `status` only: include the last apply's full report. + pub report: bool, +} + +fn json_arg() -> ArgSpec { + ArgSpec { + name: "json", + kind: ArgKind::Flag, + long: Some("json"), + value_type: ArgValueType::Bool, + cardinality: Cardinality::Optional, + help: "Output in JSON format", + ..Default::default() + } +} + +fn spec(summary: &'static str, syntax: &'static str, examples: Vec<&'static str>) -> CommandSpec { + CommandSpec { + summary, + syntax: Some(syntax), + category: Some("managed"), + examples, + args: vec![json_arg()], + ..Default::default() + } +} + +impl FromArgValueMap for ManagedArgs { + fn from_arg_value_map(map: &HashMap) -> Self { + Self { + json: matches!(map.get("json"), Some(ArgValue::Bool(true))), + report: matches!(map.get("report"), Some(ArgValue::Bool(true))), + } + } +} + +macro_rules! managed_args { + ($name:ident, $spec:expr) => { + #[derive(Debug, Default)] + pub struct $name(pub ManagedArgs); + + impl IntoCommandSpec for $name { + fn command_spec() -> CommandSpec { + $spec + } + } + + impl FromArgValueMap for $name { + fn from_arg_value_map(map: &HashMap) -> Self { + Self(ManagedArgs::from_arg_value_map(map)) + } + } + }; +} + +managed_args!( + EnrollArgs, + spec( + "Enroll this user with the configured managed source and apply its state", + "managed enroll [--json]", + vec!["fastskill managed enroll"], + ) +); +managed_args!( + ApplyArgs, + spec( + "Apply the managed state to this user's agents", + "managed apply [--json]", + vec!["fastskill managed apply", "fastskill managed apply --json"], + ) +); +managed_args!(StatusArgs, { + let mut spec = spec( + "Show the managed source, state, targets, collisions and quarantine", + "managed status [--report] [--json]", + vec![ + "fastskill managed status", + "fastskill managed status --report", + ], + ); + spec.args.push(ArgSpec { + name: "report", + kind: ArgKind::Flag, + long: Some("report"), + value_type: ArgValueType::Bool, + cardinality: Cardinality::Optional, + help: "Include everything the last apply did", + ..Default::default() + }); + spec +}); +managed_args!( + UnenrollArgs, + spec( + "Remove managed skills, records and settings for this user, keeping the quarantine", + "managed unenroll [--json]", + vec!["fastskill managed unenroll"], + ) +); + +/// What a command runs against, so tests can point it elsewhere. +pub struct Environment { + pub layout: ManagedLayout, + pub settings: ManagedSettings, + pub user_settings_file: Option, + pub home: PathBuf, + pub project_skills: Option, + /// Whether a person is watching; a hook runs without a terminal. + pub interactive: bool, +} + +impl Environment { + /// This user's environment. + pub fn current() -> CliResult { + let settings = ManagedSettings::load().map_err(fastskill_core::ServiceError::from)?; + Ok(Self { + layout: ManagedLayout::for_current_user()?, + settings, + user_settings_file: user_file_path(), + home: dirs::home_dir() + .ok_or_else(|| CliError::Config("can't determine the home folder".to_string()))?, + project_skills: crate::config::resolve_skills_storage_directory(false).ok(), + interactive: std::io::stdout().is_terminal(), + }) + } + + fn context(&self, may_enroll: bool) -> CliResult { + if !self.settings.is_configured() { + return Err(not_configured()); + } + Ok(ApplyContext { + layout: self.layout.clone(), + settings: self.settings.clone(), + home: self.home.clone(), + project_skills: self.project_skills.clone(), + may_enroll, + now: chrono::Utc::now(), + }) + } +} + +fn not_configured() -> CliError { + CliError::Config( + "no managed source is configured; set `source` and `[[keys]]` in the system or user \ + managed.toml" + .to_string(), + ) +} + +pub async fn execute_enroll(args: EnrollArgs) -> CliResult<()> { + let env = Environment::current()?; + emit(run_enroll(&env, &args.0)?) +} + +pub async fn execute_apply(args: ApplyArgs) -> CliResult<()> { + let env = Environment::current()?; + emit(run_apply(&env, &args.0)?) +} + +pub async fn execute_status(args: StatusArgs) -> CliResult<()> { + let env = Environment::current()?; + emit(Ok(Some(run_status(&env, &args.0)?))) +} + +pub async fn execute_unenroll(args: UnenrollArgs) -> CliResult<()> { + let env = Environment::current()?; + emit(Ok(Some(run_unenroll(&env, &args.0)?))) +} + +/// Print what a command produced; an incomplete apply is an error after its report. +fn emit(result: Result, (String, CliError)>) -> CliResult<()> { + match result { + Ok(Some(text)) => { + crate::outln!("{text}"); + Ok(()) + } + Ok(None) => Ok(()), + Err((text, error)) => { + crate::outln!("{text}"); + Err(error) + } + } +} + +type Rendered = Result, (String, CliError)>; + +pub fn run_enroll(env: &Environment, args: &ManagedArgs) -> CliResult { + let result = managed::enroll(&env.context(true)?)?; + Ok(rendered_apply(result, env, args)) +} + +pub fn run_apply(env: &Environment, args: &ManagedArgs) -> CliResult { + let result = managed::apply(&env.context(env.settings.source_from_system)?)?; + Ok(rendered_apply(result, env, args)) +} + +fn rendered_apply(result: ApplyResult, env: &Environment, args: &ManagedArgs) -> Rendered { + let outcome = match result { + // A hook that finds an apply running has nothing to add. + ApplyResult::Busy if !env.interactive => return Ok(None), + ApplyResult::Busy => { + return Ok(Some( + "An apply is already running for this user; it will finish on its own.".to_string(), + )) + } + ApplyResult::Done(outcome) => outcome, + }; + let text = if args.json { + serde_json::to_string_pretty(&*outcome).unwrap_or_default() + } else { + render_outcome(&outcome) + }; + if outcome.completed { + Ok(Some(text)) + } else { + Err(( + text, + CliError::Validation(format!( + "the apply didn't complete: {} problem(s); see `fastskill managed status --report`", + outcome.failures.len() + )), + )) + } +} + +/// The human report of an apply. +pub fn render_outcome(outcome: &ApplyOutcome) -> String { + let mut lines = Vec::new(); + if outcome.enrolled_now { + lines.push("Enrolled this user.".to_string()); + } + lines.push(format!( + "Applied the managed state for {:?}{}.", + outcome.subject, + if outcome.expired { + " (expired: no skills were added, changed or removed)" + } else { + "" + } + )); + if let Some(problem) = &outcome.state_problem { + lines.push(format!("Used the last accepted state: {problem}")); + } + if outcome.targets.is_empty() { + lines.push("No agent targets were found for this user.".to_string()); + } + for target in &outcome.targets { + lines.push(format!(" target {}", target.display())); + } + for (label, changes) in [ + ("deployed", &outcome.deployed), + ("adopted", &outcome.adopted), + ("removed", &outcome.removed), + ] { + for change in changes { + lines.push(format!(" {label:<8} {}", change.path.display())); + } + } + for record in &outcome.quarantined { + lines.push(format!( + " quarantined {} ({:?}) -> {}", + record.former_path.display(), + record.reason, + record.path.display() + )); + } + for path in &outcome.collisions { + lines.push(format!( + " collision {}: not FastSkill's and not the listed content; left alone", + path.display() + )); + } + for path in &outcome.project_clashes { + lines.push(format!( + " clash {}: a project skill has the id of a managed skill", + path.display() + )); + } + for (what, why) in &outcome.failures { + lines.push(format!(" failed {what}: {why}")); + } + lines.join("\n") +} + +pub fn run_status(env: &Environment, args: &ManagedArgs) -> CliResult { + let status = managed::status(&env.layout, &env.settings, chrono::Utc::now())?; + if args.json { + return Ok( + serde_json::to_string_pretty(&status_json(&status, args.report)).unwrap_or_default(), + ); + } + Ok(render_status(&status, args.report)) +} + +fn status_json(status: &ManagedStatus, report: bool) -> Value { + let last = status.last_apply.as_ref(); + json!({ + "source": status.source, + "enrolled": status.enrollment.is_some(), + "machine_id": status.enrollment.as_ref().map(|e| e.machine_id.clone()), + "subject": status.subject, + "issued_at": status.issued_at, + "expires_at": status.expires_at, + "expired": status.expired, + "state_problem": status.state_problem, + "targets": last.map(|l| l.targets.clone()).unwrap_or_default(), + "collisions": last.map(|l| l.collisions.clone()).unwrap_or_default(), + "owned": status.owned, + "quarantine": status.quarantine.iter().map(|record| json!({ + "path": record.path, + "former_path": record.former_path, + "digest": record.digest, + "reason": record.reason, + "quarantined_at": record.quarantined_at, + })).collect::>(), + "last_apply": if report { serde_json::to_value(last).unwrap_or_default() } else { + json!(last.map(|l| json!({ "applied_at": l.applied_at, "completed": l.completed }))) + }, + }) +} + +/// The human status. +pub fn render_status(status: &ManagedStatus, report: bool) -> String { + let Some(source) = &status.source else { + return "No managed source is configured.".to_string(); + }; + let mut lines = vec![format!("Source: {source}")]; + let Some(enrollment) = &status.enrollment else { + lines.push("Not enrolled; run `fastskill managed enroll`.".to_string()); + return lines.join("\n"); + }; + lines.push(format!("Machine: {}", enrollment.machine_id)); + if let Some(subject) = &status.subject { + lines.push(format!("Subject: {subject}")); + } + if let Some(expires_at) = status.expires_at { + lines.push(format!( + "Expires: {expires_at}{}", + if status.expired { " (expired)" } else { "" } + )); + } + if let Some(problem) = &status.state_problem { + lines.push(format!("State: {problem}")); + } + if let Some(last) = &status.last_apply { + lines.push(format!( + "Last apply: {} ({})", + last.applied_at.map(|at| at.to_string()).unwrap_or_default(), + if last.completed { + "completed" + } else { + "did not complete" + } + )); + for target in &last.targets { + lines.push(format!(" target {}", target.display())); + } + for path in &last.collisions { + lines.push(format!(" collision {}", path.display())); + } + if report { + lines.push(render_outcome(last)); + } + } + lines.push(format!("Managed entries: {}", status.owned.len())); + if status.quarantine.is_empty() { + lines.push("Quarantine: empty".to_string()); + } else { + lines.push("Quarantine:".to_string()); + for record in &status.quarantine { + lines.push(format!( + " {} ({:?}, was {})", + record.path.display(), + record.reason, + record.former_path.display() + )); + } + } + lines.join("\n") +} + +pub fn run_unenroll(env: &Environment, args: &ManagedArgs) -> CliResult { + let outcome = managed::unenroll( + &env.layout, + &env.settings, + env.user_settings_file.as_deref(), + chrono::Utc::now(), + )?; + if args.json { + return Ok(serde_json::to_string_pretty(&unenroll_json(&outcome)).unwrap_or_default()); + } + let mut lines = vec!["Unenrolled this user.".to_string()]; + for path in &outcome.removed { + lines.push(format!(" removed {}", path.display())); + } + for record in &outcome.quarantined { + lines.push(format!( + " quarantined {} -> {}", + record.former_path.display(), + record.path.display() + )); + } + if let Some(file) = &outcome.removed_settings { + lines.push(format!(" removed {}", file.display())); + } + Ok(lines.join("\n")) +} + +fn unenroll_json(outcome: &UnenrollOutcome) -> Value { + json!({ + "removed": outcome.removed, + "quarantined": outcome.quarantined.iter().map(|record| json!({ + "path": record.path, + "former_path": record.former_path, + })).collect::>(), + "removed_settings": outcome.removed_settings, + }) +} + +#[cfg(test)] +#[path = "managed_tests.rs"] +mod tests; diff --git a/crates/fastskill-cli/src/commands/managed_tests.rs b/crates/fastskill-cli/src/commands/managed_tests.rs new file mode 100644 index 00000000..ac33a7d7 --- /dev/null +++ b/crates/fastskill-cli/src/commands/managed_tests.rs @@ -0,0 +1,325 @@ +use super::*; +use fastskill_core::core::managed::{ + Enrollment, EntryChange, ManagedSource, PinnedKey, QuarantineReason, QuarantineRecord, +}; +use tempfile::TempDir; + +fn environment(dir: &TempDir, configured: bool, interactive: bool) -> Environment { + let source = dir.path().join("source").join("state.dsse"); + Environment { + layout: ManagedLayout::new(dir.path().join("data")), + settings: ManagedSettings { + source: configured.then_some(ManagedSource::File(source)), + keys: vec![PinnedKey { + id: "k1".to_string(), + public_key: [7; 32], + }], + source_from_system: true, + ..ManagedSettings::default() + }, + user_settings_file: Some(dir.path().join("managed.toml")), + home: dir.path().join("home"), + project_skills: None, + interactive, + } +} + +fn human() -> ManagedArgs { + ManagedArgs::default() +} + +fn json_args() -> ManagedArgs { + ManagedArgs { + json: true, + report: false, + } +} + +fn enroll_record(env: &Environment) { + std::fs::create_dir_all(env.layout.managed()).unwrap(); + let enrollment = Enrollment::new( + env.settings.source.as_ref().unwrap().as_str(), + chrono::Utc::now(), + ); + std::fs::write( + env.layout.enrollment_file(), + serde_json::to_vec(&enrollment).unwrap(), + ) + .unwrap(); +} + +fn outcome(completed: bool) -> ApplyOutcome { + let change = |name: &str| EntryChange { + path: PathBuf::from(format!("/home/u/.claude/skills/{name}")), + id: name.to_string(), + digest: "sha256:00".to_string(), + }; + ApplyOutcome { + completed, + applied_at: Some(chrono::Utc::now()), + subject: "team-a".to_string(), + expired: true, + state_problem: Some("the source can't be reached".to_string()), + enrolled_now: true, + targets: vec![PathBuf::from("/home/u/.claude/skills")], + deployed: vec![change("one")], + adopted: vec![change("two")], + removed: vec![change("three")], + quarantined: vec![QuarantineRecord { + former_path: PathBuf::from("/home/u/.claude/skills/bad"), + reason: QuarantineReason::Blocked, + path: PathBuf::from("/data/quarantine/x/content"), + ..QuarantineRecord::default() + }], + collisions: vec![PathBuf::from("/home/u/.claude/skills/theirs")], + project_clashes: vec![PathBuf::from("/p/.claude/skills/one")], + failures: if completed { + Vec::new() + } else { + vec![("one".to_string(), "digest mismatch".to_string())] + }, + ..ApplyOutcome::default() + } +} + +#[test] +fn specs_name_the_commands_and_flags() { + assert!(EnrollArgs::command_spec().summary.contains("Enroll")); + assert!(ApplyArgs::command_spec().summary.contains("Apply")); + assert!(UnenrollArgs::command_spec().summary.contains("Remove")); + let status = StatusArgs::command_spec(); + let names: Vec<_> = status.args.iter().map(|arg| arg.name).collect(); + assert_eq!(names, ["json", "report"]); + assert_eq!(group_metadata().category, Some("Operations")); +} + +#[test] +fn args_read_the_flags() { + let mut map = HashMap::new(); + map.insert("json".to_string(), ArgValue::Bool(true)); + map.insert("report".to_string(), ArgValue::Bool(true)); + let args = StatusArgs::from_arg_value_map(&map).0; + assert!(args.json && args.report); + let args = ApplyArgs::from_arg_value_map(&HashMap::new()).0; + assert!(!args.json && !args.report); + assert!(!EnrollArgs::from_arg_value_map(&HashMap::new()).0.json); + assert!(!UnenrollArgs::from_arg_value_map(&HashMap::new()).0.json); +} + +#[test] +fn without_a_source_apply_and_enroll_say_so() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, false, true); + for result in [run_apply(&env, &human()), run_enroll(&env, &human())] { + let error = result.err().unwrap().to_string(); + assert!(error.contains("no managed source is configured"), "{error}"); + } +} + +#[test] +fn apply_before_enrolling_is_an_error_when_the_source_is_the_users() { + let dir = TempDir::new().unwrap(); + let mut env = environment(&dir, true, true); + env.settings.source_from_system = false; + let error = run_apply(&env, &human()).err().unwrap().to_string(); + assert!(error.contains("isn't enrolled"), "{error}"); +} + +#[test] +fn enroll_with_an_unreadable_state_fails() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, true); + let error = run_enroll(&env, &human()).err().unwrap().to_string(); + assert!(error.contains("can't read the managed state"), "{error}"); +} + +#[test] +fn a_busy_apply_is_silent_from_a_hook_and_explained_in_a_terminal() { + let dir = TempDir::new().unwrap(); + let hook = environment(&dir, true, false); + let _lock = hook.layout.try_lock().unwrap().unwrap(); + assert!(matches!(run_apply(&hook, &human()).unwrap(), Ok(None))); + let terminal = environment(&dir, true, true); + let text = run_apply(&terminal, &human()).unwrap().unwrap().unwrap(); + assert!(text.contains("already running"), "{text}"); +} + +#[test] +fn a_completed_apply_is_reported() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, true); + let done = ApplyResult::Done(Box::new(outcome(true))); + let text = rendered_apply(done, &env, &human()).unwrap().unwrap(); + for expected in [ + "Enrolled this user.", + "(expired", + "Used the last accepted state", + "deployed", + "adopted", + "removed", + "quarantined /home/u/.claude/skills/bad", + "collision", + "clash", + ] { + assert!(text.contains(expected), "{expected} in {text}"); + } + let done = ApplyResult::Done(Box::new(outcome(true))); + let text = rendered_apply(done, &env, &json_args()).unwrap().unwrap(); + let value: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value["subject"], "team-a"); +} + +#[test] +fn an_incomplete_apply_is_an_error_after_its_report() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, true); + let done = ApplyResult::Done(Box::new(outcome(false))); + let (text, error) = rendered_apply(done, &env, &human()).unwrap_err(); + assert!(text.contains("failed one: digest mismatch"), "{text}"); + assert!(error.to_string().contains("1 problem(s)")); +} + +#[test] +fn an_apply_without_targets_says_so() { + let text = render_outcome(&ApplyOutcome { + completed: true, + ..ApplyOutcome::default() + }); + assert!(text.contains("No agent targets were found"), "{text}"); + assert!(!text.contains("expired")); +} + +#[test] +fn emit_prints_and_passes_errors_on() { + assert!(emit(Ok(Some("text".to_string()))).is_ok()); + assert!(emit(Ok(None)).is_ok()); + let error = CliError::Validation("no".to_string()); + assert!(emit(Err(("text".to_string(), error))).is_err()); +} + +#[test] +fn status_without_a_source_or_enrollment() { + let dir = TempDir::new().unwrap(); + let text = run_status(&environment(&dir, false, true), &human()).unwrap(); + assert_eq!(text, "No managed source is configured."); + let text = run_status(&environment(&dir, true, true), &human()).unwrap(); + assert!(text.contains("Not enrolled"), "{text}"); +} + +#[test] +fn status_of_an_enrolled_user() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, true); + enroll_record(&env); + std::fs::write( + env.layout.last_apply_file(), + serde_json::to_vec(&outcome(false)).unwrap(), + ) + .unwrap(); + let source = dir.path().join("skill"); + std::fs::create_dir_all(&source).unwrap(); + managed::quarantine::quarantine( + &env.layout, + &source, + None, + QuarantineReason::NotAllowed, + chrono::Utc::now(), + ) + .unwrap(); + + let text = run_status(&env, &human()).unwrap(); + for expected in [ + "Machine:", + "State: no state has been accepted yet", + "did not complete", + "target", + "collision", + "Managed entries: 0", + "NotAllowed", + ] { + assert!(text.contains(expected), "{expected} in {text}"); + } + assert!(!text.contains("deployed")); + let report = ManagedArgs { + json: false, + report: true, + }; + assert!(run_status(&env, &report).unwrap().contains("deployed")); + + let value: Value = serde_json::from_str(&run_status(&env, &json_args()).unwrap()).unwrap(); + assert_eq!(value["enrolled"], true); + assert_eq!(value["last_apply"]["completed"], false); + assert_eq!(value["quarantine"][0]["reason"], "not-allowed"); + let report = ManagedArgs { + json: true, + report: true, + }; + let value: Value = serde_json::from_str(&run_status(&env, &report).unwrap()).unwrap(); + assert_eq!(value["last_apply"]["subject"], "team-a"); +} + +#[test] +fn status_shows_the_state_and_an_empty_quarantine() { + let status = ManagedStatus { + source: Some("/s/state.dsse".to_string()), + enrollment: Some(Enrollment::new("/s/state.dsse", chrono::Utc::now())), + subject: Some("team-a".to_string()), + expires_at: Some(chrono::Utc::now()), + expired: true, + last_apply: Some(outcome(true)), + ..ManagedStatus::default() + }; + let text = render_status(&status, false); + for expected in [ + "Subject: team-a", + "(expired)", + "(completed)", + "Quarantine: empty", + ] { + assert!(text.contains(expected), "{expected} in {text}"); + } +} + +#[test] +fn unenroll_removes_the_records_and_settings() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, true); + enroll_record(&env); + std::fs::write(env.user_settings_file.as_ref().unwrap(), "").unwrap(); + let text = run_unenroll(&env, &human()).unwrap(); + assert!(text.starts_with("Unenrolled this user."), "{text}"); + assert!(text.contains("managed.toml"), "{text}"); + assert!(!env.layout.enrollment_file().exists()); + + enroll_record(&env); + let value: Value = serde_json::from_str(&run_unenroll(&env, &json_args()).unwrap()).unwrap(); + assert_eq!(value["removed_settings"], Value::Null); +} + +#[test] +fn unenroll_lists_removed_and_quarantined_entries() { + let outcome = UnenrollOutcome { + removed: vec![PathBuf::from("/h/.claude/skills/one")], + quarantined: vec![QuarantineRecord { + former_path: PathBuf::from("/h/.claude/skills/two"), + path: PathBuf::from("/q/content"), + ..QuarantineRecord::default() + }], + removed_settings: None, + }; + let value = unenroll_json(&outcome); + assert_eq!( + value["quarantined"][0]["former_path"], + "/h/.claude/skills/two" + ); + assert_eq!(value["removed"][0], "/h/.claude/skills/one"); +} + +#[test] +fn unenroll_is_refused_when_the_system_requires_it() { + let dir = TempDir::new().unwrap(); + let mut env = environment(&dir, true, true); + env.settings.required_by_system = true; + let error = run_unenroll(&env, &human()).unwrap_err().to_string(); + assert!(error.contains("can't unenroll"), "{error}"); +} diff --git a/crates/fastskill-cli/src/commands/mod.rs b/crates/fastskill-cli/src/commands/mod.rs index d0eaf509..48eb8376 100644 --- a/crates/fastskill-cli/src/commands/mod.rs +++ b/crates/fastskill-cli/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod eval; pub mod init; pub mod install; pub mod list; +pub mod managed; pub mod marketplace; pub mod mcp; pub mod read; diff --git a/crates/fastskill-cli/src/main.rs b/crates/fastskill-cli/src/main.rs index 83f05667..ba9439eb 100644 --- a/crates/fastskill-cli/src/main.rs +++ b/crates/fastskill-cli/src/main.rs @@ -106,8 +106,8 @@ fn app_error_exit_code(error: &anyhow::Error) -> i32 { } use commands::{ - add, analyze, bundle, cache, doctor, eval, init, install, list, marketplace, mcp, read, - reindex, remove, repin, repos, search, serve, skillopt, update, + add, analyze, bundle, cache, doctor, eval, init, install, list, managed, marketplace, mcp, + read, reindex, remove, repin, repos, search, serve, skillopt, update, }; /// The binary's name, as reported by `--version` and used to derive MCP tool @@ -552,6 +552,42 @@ fn build_app(builder: AppBuilder, state: Arc) -> anyhow::Result>(); let expected = CANONICAL_PATHS.iter().copied().collect::>(); assert_eq!(actual, expected); - assert_eq!(actual.len(), 55); + assert_eq!(actual.len(), 59); } #[test] diff --git a/crates/fastskill-cli/tests/managed_cli_test.rs b/crates/fastskill-cli/tests/managed_cli_test.rs new file mode 100644 index 00000000..f36dd982 --- /dev/null +++ b/crates/fastskill-cli/tests/managed_cli_test.rs @@ -0,0 +1,78 @@ +//! `fastskill managed` run end to end against an isolated home. +#![cfg(unix)] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::TempDir; + +fn run(root: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_fastskill")) + .args(args) + .current_dir(root) + .env("HOME", root.join("home")) + .env("XDG_CONFIG_HOME", root.join("config")) + .env("XDG_DATA_HOME", root.join("data")) + .output() + .unwrap() +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +#[test] +fn without_settings_status_says_so_and_apply_fails() { + let root = TempDir::new().unwrap(); + let status = run(root.path(), &["managed", "status"]); + assert!(status.status.success(), "{}", text(&status.stderr)); + assert!(text(&status.stdout).contains("No managed source is configured.")); + + let apply = run(root.path(), &["managed", "apply"]); + assert!(!apply.status.success()); + let output = text(&apply.stdout) + &text(&apply.stderr); + assert!( + output.contains("no managed source is configured"), + "{output}" + ); +} + +#[test] +fn with_user_settings_enroll_status_and_unenroll() { + let root = TempDir::new().unwrap(); + let settings = root + .path() + .join("config") + .join("fastskill") + .join("managed.toml"); + std::fs::create_dir_all(settings.parent().unwrap()).unwrap(); + let state = root.path().join("source").join("state.dsse"); + std::fs::write( + &settings, + format!( + "source = {:?}\n\n[[keys]]\nid = \"k1\"\npublic_key = \ + \"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=\"\n", + state.display().to_string() + ), + ) + .unwrap(); + + let status = run(root.path(), &["managed", "status"]); + assert!(status.status.success(), "{}", text(&status.stderr)); + assert!( + text(&status.stdout).contains("Not enrolled"), + "{}", + text(&status.stdout) + ); + + let enroll = run(root.path(), &["managed", "enroll"]); + assert!(!enroll.status.success()); + let output = text(&enroll.stdout) + &text(&enroll.stderr); + assert!(output.contains("can't read the managed state"), "{output}"); + + let unenroll = run(root.path(), &["managed", "unenroll", "--json"]); + assert!(unenroll.status.success(), "{}", text(&unenroll.stderr)); + let value: serde_json::Value = serde_json::from_slice(&unenroll.stdout).unwrap(); + assert!(value["removed_settings"].as_str().is_some(), "{value}"); + assert!(!settings.exists()); +} diff --git a/crates/fastskill-core/Cargo.toml b/crates/fastskill-core/Cargo.toml index 4d09fc81..9013a371 100644 --- a/crates/fastskill-core/Cargo.toml +++ b/crates/fastskill-core/Cargo.toml @@ -25,6 +25,9 @@ serde_yaml.workspace = true toml.workspace = true toml_edit.workspace = true +# aikit-sdk: per-user Agent skills folders and skill entry deployment (managed state) +aikit-sdk.workspace = true + # Error handling anyhow.workspace = true thiserror.workspace = true diff --git a/crates/fastskill-core/src/core/managed/apply.rs b/crates/fastskill-core/src/core/managed/apply.rs new file mode 100644 index 00000000..e2135938 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/apply.rs @@ -0,0 +1,462 @@ +//! Applying a managed state to this user's Agent targets (ADR-0016 decisions 8 to 12 and 17). +//! +//! One apply, under the per-user lock: +//! +//! 1. reads the enrollment, enrolling first when the caller allows it; +//! 2. fetches the state, falling back to the last accepted one when the source can't be read +//! or what it serves is refused; +//! 3. unless the state has expired, publishes each listed skill into the store and deploys it +//! to every target, adopting identical content, quarantining what must go, reporting +//! collisions and removing owned entries that are no longer listed; +//! 4. quarantines blocked content in the targets and the project skills folder, even when the +//! state has expired; +//! 5. records ownership, the accepted state and what happened. + +use super::config::{ManagedSettings, ManagedSource}; +use super::layout::{read_record, write_record, ManagedLayout}; +use super::quarantine::{quarantine, remove_entry, QuarantineReason, QuarantineRecord}; +use super::records::{Enrollment, EntryMode, OwnedEntry, Ownership}; +use super::state::{Allowed, ManagedState}; +use super::store::ManagedStore; +use super::OpenedState; +use crate::core::content_digest::content_digest; +use crate::core::service::ServiceError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// What an apply works on. +#[derive(Debug, Clone)] +pub struct ApplyContext { + pub layout: ManagedLayout, + pub settings: ManagedSettings, + /// The user's home folder, under which Agent targets are found. + pub home: PathBuf, + /// The current project's skills folder, checked for blocked content and id clashes. + pub project_skills: Option, + /// Enroll when this user isn't enrolled yet. + pub may_enroll: bool, + pub now: DateTime, +} + +/// Whether an apply ran. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApplyResult { + /// Another apply holds the lock. + Busy, + Done(Box), +} + +/// One target entry an apply changed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryChange { + pub path: PathBuf, + pub id: String, + pub digest: String, +} + +/// What an apply did. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyOutcome { + /// Every step succeeded. + pub completed: bool, + pub applied_at: Option>, + pub subject: String, + pub issued_at: Option>, + pub expires_at: Option>, + /// The state has expired, so no content was added, changed or removed for being unlisted. + pub expired: bool, + /// Why the source's state wasn't used, when the last accepted one was used instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_problem: Option, + pub enrolled_now: bool, + pub targets: Vec, + pub deployed: Vec, + pub adopted: Vec, + pub removed: Vec, + pub quarantined: Vec, + /// Entries that aren't FastSkill's and hold other content than the state lists. + pub collisions: Vec, + /// Project skills with the id of a managed skill. + pub project_clashes: Vec, + /// Skills or entries that failed, with why. + pub failures: Vec<(String, String)>, +} + +/// Apply the configured managed state. +pub fn apply(context: &ApplyContext) -> Result { + let source = context + .settings + .source + .clone() + .ok_or_else(|| ServiceError::Config("no managed source is configured".to_string()))?; + let layout = &context.layout; + let Some(_lock) = layout.try_lock()? else { + return Ok(ApplyResult::Busy); + }; + let mut outcome = ApplyOutcome { + applied_at: Some(context.now), + ..ApplyOutcome::default() + }; + + let mut enrollment: Enrollment = read_record(&layout.enrollment_file())?; + if !enrollment.is_enrolled() { + if !context.may_enroll { + return Err(ServiceError::InvalidOperation( + "this user isn't enrolled; run `fastskill managed enroll`".to_string(), + )); + } + enrollment = Enrollment::new(source.as_str(), context.now); + outcome.enrolled_now = true; + } else if !source.matches(&enrollment.source) { + return Err(ServiceError::InvalidOperation(format!( + "this user is enrolled with {:?}, but the configured source is {:?}; run \ + `fastskill managed enroll` again", + enrollment.source, + source.as_str() + ))); + } + + let opened = fetch_and_open(context, &source, &enrollment, &mut outcome)?; + enrollment.recorded = enrollment.recorded.accept(&opened.state); + write_record(&layout.enrollment_file(), &enrollment)?; + + let state = &opened.state; + outcome.subject = state.subject.clone(); + outcome.issued_at = Some(state.issued_at); + outcome.expires_at = Some(state.expires_at); + outcome.expired = state.is_expired(context.now); + outcome.targets = targets(context); + + let mut ownership: Ownership = read_record(&layout.ownership_file())?; + let mut run = Run { + context, + state, + ownership: &mut ownership, + outcome: &mut outcome, + }; + if !run.outcome.expired { + run.deploy_listed(&source); + run.remove_unlisted(); + } + run.sweep_targets(); + run.check_project(); + if !outcome.expired { + let keep: Vec<&str> = state + .skills + .iter() + .map(|skill| skill.digest.as_str()) + .collect(); + if let Err(error) = ManagedStore::new(layout).retain(&keep) { + outcome + .failures + .push(("store".to_string(), error.to_string())); + } + } + + write_record(&layout.ownership_file(), &ownership)?; + outcome.completed = outcome.failures.is_empty(); + write_record(&layout.last_apply_file(), &outcome)?; + Ok(ApplyResult::Done(Box::new(outcome))) +} + +/// Open the source's state and cache it, or open the cached one when the source's can't be +/// used. +fn fetch_and_open( + context: &ApplyContext, + source: &ManagedSource, + enrollment: &Enrollment, + outcome: &mut ApplyOutcome, +) -> Result { + let fetched = fetch(source).and_then(|bytes| { + super::open(&bytes, &context.settings, &enrollment.recorded, context.now) + .map(|opened| (bytes, opened)) + .map_err(ServiceError::from) + }); + let cached = context.layout.cached_state(); + match fetched { + Ok((bytes, opened)) => { + crate::utils::atomic_write(&cached, &bytes)?; + Ok(opened) + } + Err(problem) => { + let Ok(bytes) = std::fs::read(&cached) else { + return Err(problem); + }; + // The cached state was accepted before; check it again against today's settings. + match super::open(&bytes, &context.settings, &enrollment.recorded, context.now) { + Ok(opened) => { + outcome.state_problem = Some(problem.to_string()); + Ok(opened) + } + Err(_) => Err(problem), + } + } + } +} + +/// Read the state's envelope from the source. +pub fn fetch(source: &ManagedSource) -> Result, ServiceError> { + match source { + ManagedSource::File(path) => std::fs::read(path).map_err(|error| { + ServiceError::Io(std::io::Error::new( + error.kind(), + format!("can't read the managed state {}: {error}", path.display()), + )) + }), + ManagedSource::Https(url) => Err(ServiceError::InvalidOperation(format!( + "reading the managed state from {url} isn't supported by this FastSkill yet" + ))), + } +} + +/// The Agent targets: the fewest per-user skills folders that reach the configured agents, or +/// the agents present for this user. +pub fn targets(context: &ApplyContext) -> Vec { + let detected; + let keys: Vec<&str> = match &context.settings.targets { + Some(targets) => targets.iter().map(String::as_str).collect(), + None => { + detected = aikit_sdk::detect_present_agents(&context.home); + detected.clone() + } + }; + aikit_sdk::fewest_skill_dirs(&context.home, &keys) + .into_iter() + .map(|(dir, _)| dir) + .collect() +} + +/// The digest of a target entry's content, following one link at the entry itself. +pub(crate) fn entry_digest(path: &Path) -> Option { + let resolved = std::fs::canonicalize(path).ok()?; + resolved + .is_dir() + .then(|| content_digest(&resolved).ok()) + .flatten() +} + +struct Run<'a> { + context: &'a ApplyContext, + state: &'a ManagedState, + ownership: &'a mut Ownership, + outcome: &'a mut ApplyOutcome, +} + +impl Run<'_> { + fn fail(&mut self, what: impl Into, error: impl ToString) { + self.outcome.failures.push((what.into(), error.to_string())); + } + + fn quarantine(&mut self, path: &Path, digest: Option<&str>, reason: QuarantineReason) -> bool { + match quarantine(&self.context.layout, path, digest, reason, self.context.now) { + Ok(record) => { + self.outcome.quarantined.push(record); + true + } + Err(error) => { + self.fail(path.display().to_string(), error); + false + } + } + } + + /// Whether content with `digest` found in a target must not stay there. + fn must_go(&self, digest: &str) -> Option { + if self.state.blocked(digest).is_some() { + Some(QuarantineReason::Blocked) + } else if self.state.allowed == Allowed::Listed + && self.state.exclusive_targets + && !self.state.allows(digest) + { + Some(QuarantineReason::NotAllowed) + } else { + None + } + } + + fn deploy_listed(&mut self, source: &ManagedSource) { + let store = ManagedStore::new(&self.context.layout); + let targets = self.outcome.targets.clone(); + for skill in &self.state.skills { + let published = match store.ensure(skill, source) { + Ok(path) => path, + Err(error) => { + self.fail(skill.id.clone(), error); + continue; + } + }; + for target in &targets { + self.deploy(&published, target, &skill.id, &skill.digest); + } + } + } + + fn deploy(&mut self, published: &Path, target: &Path, id: &str, digest: &str) { + let entry = target.join(id); + let present = entry.symlink_metadata().is_ok(); + let current = present.then(|| entry_digest(&entry)).flatten(); + let change = EntryChange { + path: entry.clone(), + id: id.to_string(), + digest: digest.to_string(), + }; + match self.ownership.find(target, id).cloned() { + Some(owned) if present => { + if current.as_deref() == Some(digest) { + if owned.digest != digest { + self.ownership.record(OwnedEntry { + digest: digest.to_string(), + ..owned + }); + } + return; + } + if current.as_deref() != Some(owned.digest.as_str()) + && !self.quarantine(&entry, current.as_deref(), QuarantineReason::Modified) + { + return; + } + } + Some(_) => {} + None if !present => {} + None => { + if current.as_deref() == Some(digest) { + self.ownership.record(OwnedEntry { + target: target.to_path_buf(), + id: id.to_string(), + digest: digest.to_string(), + mode: EntryMode::Adopted, + }); + self.outcome.adopted.push(change); + return; + } + let reason = current.as_deref().and_then(|found| self.must_go(found)); + match reason { + Some(reason) => { + if !self.quarantine(&entry, current.as_deref(), reason) { + return; + } + } + None => { + self.outcome.collisions.push(entry); + return; + } + } + } + } + match aikit_sdk::deploy_skill_entry(published, target, id, aikit_sdk::DeployMode::Auto) { + Ok(deployed) => { + let mode = match deployed.mode { + aikit_sdk::DeployMode::Link => EntryMode::Link, + _ => EntryMode::Copy, + }; + self.ownership.record(OwnedEntry { + target: target.to_path_buf(), + id: id.to_string(), + digest: digest.to_string(), + mode, + }); + self.outcome.deployed.push(change); + } + Err(error) => self.fail(entry.display().to_string(), error), + } + } + + /// Remove owned entries the state no longer lists, or that are in a folder that's no longer + /// a target. A changed copy is quarantined instead. + fn remove_unlisted(&mut self) { + let listed = |entry: &OwnedEntry, targets: &[PathBuf], state: &ManagedState| { + targets.contains(&entry.target) && state.skills.iter().any(|skill| skill.id == entry.id) + }; + let stale: Vec = self + .ownership + .entries + .iter() + .filter(|entry| !listed(entry, &self.outcome.targets, self.state)) + .cloned() + .collect(); + for owned in stale { + let path = owned.path(); + if path.symlink_metadata().is_ok() { + let current = entry_digest(&path); + let removed = if current.as_deref() == Some(owned.digest.as_str()) { + match remove_entry(&path) { + Ok(()) => { + self.outcome.removed.push(EntryChange { + path: path.clone(), + id: owned.id.clone(), + digest: owned.digest.clone(), + }); + true + } + Err(error) => { + self.fail(path.display().to_string(), error); + false + } + } + } else { + self.quarantine(&path, current.as_deref(), QuarantineReason::Modified) + }; + if !removed { + continue; + } + } + self.ownership.forget(&owned.target, &owned.id); + } + } + + /// Quarantine blocked content in every target, and with `allowed = listed` plus + /// `exclusive_targets` any content whose digest isn't allowed. + fn sweep_targets(&mut self) { + for target in self.outcome.targets.clone() { + for (path, digest) in entries(&target) { + if let Some(reason) = self.must_go(&digest) { + if self.quarantine(&path, Some(&digest), reason) { + if let Some(id) = path.file_name().and_then(|name| name.to_str()) { + self.ownership.forget(&target, id); + } + } + } + } + } + } + + /// Quarantine blocked project skills, and report project skills that share a managed id. + fn check_project(&mut self) { + let Some(folder) = self.context.project_skills.clone() else { + return; + }; + for (path, digest) in entries(&folder) { + if self.state.blocked(&digest).is_some() { + self.quarantine(&path, Some(&digest), QuarantineReason::Blocked); + } + } + for skill in &self.state.skills { + let path = folder.join(&skill.id); + if path.symlink_metadata().is_ok() { + self.outcome.project_clashes.push(path); + } + } + } +} + +/// The skill entries of a folder with their digests, leaving out hidden entries such as a +/// deploy's temporary ones and anything that isn't a readable skill folder. +fn entries(folder: &Path) -> Vec<(PathBuf, String)> { + let Ok(read) = std::fs::read_dir(folder) else { + return Vec::new(); + }; + let mut found: Vec<(PathBuf, String)> = read + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| !name.starts_with('.')) + }) + .filter_map(|path| entry_digest(&path).map(|digest| (path, digest))) + .collect(); + found.sort(); + found +} diff --git a/crates/fastskill-core/src/core/managed/apply_tests.rs b/crates/fastskill-core/src/core/managed/apply_tests.rs new file mode 100644 index 00000000..c5a71e10 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/apply_tests.rs @@ -0,0 +1,672 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::apply::{apply, entry_digest, fetch, targets, ApplyContext, ApplyOutcome, ApplyResult}; +use super::config::{ManagedSettings, ManagedSource}; +use super::enrollment::{enroll, status, unenroll}; +use super::layout::{read_record, write_record, ManagedLayout}; +use super::quarantine::{self, QuarantineReason}; +use super::records::{Enrollment, EntryMode, OwnedEntry, Ownership}; +use super::state::StateSkill; +use super::store::{artifact_path, ManagedStore}; +use super::tests::{pinned, sign}; +use crate::core::content_digest::content_digest; +use crate::core::service::ServiceError; +use chrono::{DateTime, Duration, Utc}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +fn now() -> DateTime { + "2026-10-09T12:00:00Z".parse().unwrap() +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, + state_file: PathBuf, + layout: ManagedLayout, + project: PathBuf, + count: usize, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::create_dir_all(root.join("home/.claude")).unwrap(); + std::fs::create_dir_all(root.join("project/.claude/skills")).unwrap(); + std::fs::create_dir_all(root.join("source")).unwrap(); + Self { + state_file: root.join("source/state.dsse"), + layout: ManagedLayout::new(root.join("data")), + project: root.join("project/.claude/skills"), + root, + _dir: dir, + count: 0, + } + } + + fn home(&self) -> PathBuf { + self.root.join("home") + } + + fn settings(&self) -> ManagedSettings { + ManagedSettings { + source: Some(ManagedSource::File(self.state_file.clone())), + keys: vec![pinned("k1", 1)], + targets: Some(vec!["claude".to_string()]), + source_from_system: true, + ..ManagedSettings::default() + } + } + + fn context(&self) -> ApplyContext { + ApplyContext { + layout: self.layout.clone(), + settings: self.settings(), + home: self.home(), + project_skills: Some(self.project.clone()), + may_enroll: true, + now: now(), + } + } + + fn target(&self) -> PathBuf { + targets(&self.context()).remove(0) + } + + /// A skill folder named `id` whose body is `body`, outside any target. + fn folder(&mut self, id: &str, body: &str) -> PathBuf { + self.count += 1; + let folder = self.root.join(format!("src/{}/{id}", self.count)); + write_skill(&folder, id, body); + folder + } + + /// A listed skill with an archive in the source folder, relative to the state file. + fn skill(&mut self, id: &str, body: &str) -> StateSkill { + let folder = self.folder(id, body); + let name = format!("{id}-{}.zip", self.count); + zip_folder(&folder, &self.root.join("source").join(&name)); + StateSkill { + id: id.to_string(), + digest: content_digest(&folder).unwrap(), + artifact: name, + } + } + + fn publish(&self, skills: &[StateSkill], extra: Value) { + let mut state = json!({ + "format_version": 1, + "issued_at": "2026-10-09T11:00:00Z", + "expires_at": "2026-10-16T11:00:00Z", + "source": self.state_file.display().to_string(), + "subject": "team-a", + "skills": skills, + "allowed": "any", + }); + for (key, value) in extra.as_object().unwrap() { + state[key] = value.clone(); + } + let envelope = sign(&serde_json::to_vec(&state).unwrap(), &[("k1", 1)]); + std::fs::write(&self.state_file, envelope).unwrap(); + } + + fn apply(&self) -> ApplyOutcome { + self.apply_with(&self.context()) + } + + fn apply_with(&self, context: &ApplyContext) -> ApplyOutcome { + match apply(context).unwrap() { + ApplyResult::Done(outcome) => *outcome, + ApplyResult::Busy => panic!("busy"), + } + } + + fn ownership(&self) -> Ownership { + read_record(&self.layout.ownership_file()).unwrap() + } +} + +fn write_skill(folder: &Path, id: &str, body: &str) { + std::fs::create_dir_all(folder).unwrap(); + std::fs::write( + folder.join("SKILL.md"), + format!("---\nname: {id}\ndescription: A test skill.\nversion: 1.0.0\n---\n{body}\n"), + ) + .unwrap(); +} + +fn zip_folder(folder: &Path, archive: &Path) { + let file = std::fs::File::create(archive).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let name = folder.file_name().unwrap().to_str().unwrap(); + let options = zip::write::SimpleFileOptions::default(); + zip.start_file(format!("{name}/SKILL.md"), options).unwrap(); + zip.write_all(&std::fs::read(folder.join("SKILL.md")).unwrap()) + .unwrap(); + zip.finish().unwrap(); +} + +#[test] +fn a_first_apply_enrolls_deploys_links_and_records_ownership() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + + let outcome = fixture.apply(); + assert!(outcome.completed, "{outcome:?}"); + assert!(outcome.enrolled_now); + assert_eq!(outcome.subject, "team-a"); + assert_eq!(outcome.deployed.len(), 1); + let entry = fixture.target().join("pdf"); + assert!(entry.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(entry_digest(&entry).as_deref(), Some(pdf.digest.as_str())); + let ownership = fixture.ownership(); + assert_eq!(ownership.entries.len(), 1); + assert_eq!(ownership.entries[0].mode, EntryMode::Link); + assert!(fixture.layout.cached_state().is_file()); + let enrollment: Enrollment = read_record(&fixture.layout.enrollment_file()).unwrap(); + assert_eq!(enrollment.recorded.subject.as_deref(), Some("team-a")); + let last: ApplyOutcome = read_record(&fixture.layout.last_apply_file()).unwrap(); + assert_eq!(last, outcome); + + // Nothing changes the second time. + let again = fixture.apply(); + assert!(again.completed && !again.enrolled_now); + assert!(again.deployed.is_empty() && again.removed.is_empty()); +} + +#[test] +fn an_unenrolled_user_is_enrolled_only_when_allowed() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + let mut context = fixture.context(); + context.may_enroll = false; + let error = apply(&context).unwrap_err(); + assert!(error.to_string().contains("managed enroll"), "{error}"); +} + +#[test] +fn a_second_apply_waits_for_none_while_one_runs() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + let _held = fixture.layout.try_lock().unwrap().unwrap(); + assert_eq!(apply(&fixture.context()).unwrap(), ApplyResult::Busy); + assert_eq!(enroll(&fixture.context()).unwrap(), ApplyResult::Busy); + let error = unenroll(&fixture.layout, &fixture.settings(), None, now()).unwrap_err(); + assert!(error.to_string().contains("apply is running"), "{error}"); +} + +#[test] +fn a_new_version_replaces_the_entry_and_the_old_one_leaves_the_store() { + let mut fixture = Fixture::new(); + let old = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&old), json!({})); + fixture.apply(); + let new = fixture.skill("pdf", "two"); + fixture.publish(std::slice::from_ref(&new), json!({})); + + let outcome = fixture.apply(); + assert!(outcome.completed, "{outcome:?}"); + assert_eq!(outcome.deployed[0].digest, new.digest); + let entry = fixture.target().join("pdf"); + assert_eq!(entry_digest(&entry).as_deref(), Some(new.digest.as_str())); + let store = ManagedStore::new(&fixture.layout); + assert!(!store.path(&old.digest).exists()); + assert!(store.path(&new.digest).is_dir()); + assert_eq!(fixture.ownership().entries[0].digest, new.digest); +} + +#[test] +fn an_unlisted_skill_is_removed_and_a_changed_copy_is_quarantined() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + let docx = fixture.skill("docx", "one"); + fixture.publish(&[pdf, docx], json!({})); + fixture.apply(); + let target = fixture.target(); + // Someone replaced the docx link with their own copy. + std::fs::remove_file(target.join("docx")).unwrap(); + write_skill(&target.join("docx"), "docx", "edited"); + fixture.publish(&[], json!({})); + + let outcome = fixture.apply(); + assert!(outcome.completed, "{outcome:?}"); + assert_eq!(outcome.removed.len(), 1); + assert_eq!(outcome.removed[0].id, "pdf"); + assert_eq!(outcome.quarantined.len(), 1); + assert_eq!(outcome.quarantined[0].reason, QuarantineReason::Modified); + assert!(target.join("pdf").symlink_metadata().is_err()); + assert!(target.join("docx").symlink_metadata().is_err()); + assert!(fixture.ownership().entries.is_empty()); + assert_eq!( + std::fs::read_dir(fixture.layout.store()).unwrap().count(), + 0 + ); + assert_eq!(quarantine::list(&fixture.layout).unwrap().len(), 1); +} + +#[test] +fn a_changed_owned_entry_is_quarantined_before_the_listed_content_is_deployed() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + fixture.apply(); + let entry = fixture.target().join("pdf"); + std::fs::remove_file(&entry).unwrap(); + write_skill(&entry, "pdf", "edited"); + + let outcome = fixture.apply(); + assert_eq!(outcome.quarantined.len(), 1); + assert_eq!(outcome.deployed.len(), 1); + assert_eq!(entry_digest(&entry).as_deref(), Some(pdf.digest.as_str())); +} + +#[test] +fn a_missing_owned_entry_is_deployed_again() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + fixture.apply(); + std::fs::remove_file(fixture.target().join("pdf")).unwrap(); + assert_eq!(fixture.apply().deployed.len(), 1); +} + +#[test] +fn identical_unowned_content_is_adopted() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + write_skill(&fixture.target().join("pdf"), "pdf", "one"); + + let outcome = fixture.apply(); + assert_eq!(outcome.adopted.len(), 1); + assert!(outcome.deployed.is_empty()); + assert_eq!(fixture.ownership().entries[0].mode, EntryMode::Adopted); +} + +#[test] +fn other_unowned_content_is_a_collision_unless_it_must_be_quarantined() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + let entry = fixture.target().join("pdf"); + write_skill(&entry, "pdf", "mine"); + + let outcome = fixture.apply(); + assert_eq!(outcome.collisions, vec![entry.clone()]); + assert!(outcome.completed); + assert_eq!(entry_digest(&entry), Some(content_digest(&entry).unwrap())); + + let mine = content_digest(&entry).unwrap(); + fixture.publish( + std::slice::from_ref(&pdf), + json!({ "blocked": [{ "digest": mine }] }), + ); + let outcome = fixture.apply(); + assert!(outcome.collisions.is_empty()); + assert_eq!(outcome.quarantined[0].reason, QuarantineReason::Blocked); + assert_eq!(entry_digest(&entry).as_deref(), Some(pdf.digest.as_str())); +} + +#[test] +fn exclusive_targets_quarantine_content_that_is_not_allowed() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + let other = fixture.folder("notes", "one"); + let allowed = fixture.folder("todo", "one"); + let target = fixture.target(); + write_skill(&target.join("notes"), "notes", "one"); + write_skill(&target.join("todo"), "todo", "one"); + write_skill(&target.join(".hidden"), "hidden", "one"); + fixture.publish( + &[pdf], + json!({ + "allowed": "listed", + "exclusive_targets": true, + "also_allowed": [content_digest(&allowed).unwrap()], + }), + ); + + let outcome = fixture.apply(); + assert_eq!(outcome.quarantined.len(), 1, "{outcome:?}"); + assert_eq!(outcome.quarantined[0].reason, QuarantineReason::NotAllowed); + assert_eq!( + outcome.quarantined[0].digest, + Some(content_digest(&other).unwrap()) + ); + assert!(target.join("todo").is_dir()); + assert!(target.join(".hidden").is_dir()); +} + +#[test] +fn blocked_project_skills_are_quarantined_and_clashes_reported() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + write_skill(&fixture.project.join("pdf"), "pdf", "local"); + write_skill(&fixture.project.join("bad"), "bad", "one"); + let bad = content_digest(&fixture.project.join("bad")).unwrap(); + fixture.publish(&[pdf], json!({ "blocked": [{ "digest": bad }] })); + + let outcome = fixture.apply(); + assert_eq!(outcome.project_clashes, vec![fixture.project.join("pdf")]); + assert_eq!(outcome.quarantined.len(), 1); + assert!(!fixture.project.join("bad").exists()); + let listed = quarantine::list(&fixture.layout).unwrap(); + assert_eq!(listed[0].former_path, fixture.project.join("bad")); + assert!(listed[0].path.join("SKILL.md").is_file()); +} + +#[test] +fn an_expired_state_changes_no_content_but_still_quarantines_blocked_content() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + fixture.apply(); + write_skill(&fixture.target().join("bad"), "bad", "one"); + let bad = content_digest(&fixture.target().join("bad")).unwrap(); + let docx = fixture.skill("docx", "one"); + fixture.publish( + &[docx], + json!({ + "issued_at": "2026-10-01T11:00:00Z", + "expires_at": "2026-10-02T11:00:00Z", + "blocked": [{ "digest": bad }], + }), + ); + // Accepting an older state needs a fresh enrollment. + std::fs::remove_file(fixture.layout.enrollment_file()).unwrap(); + + let outcome = fixture.apply(); + assert!(outcome.expired); + assert!(outcome.deployed.is_empty() && outcome.removed.is_empty()); + assert!(fixture.target().join("pdf").exists()); + assert!(!fixture.target().join("docx").exists()); + assert_eq!(outcome.quarantined.len(), 1); +} + +#[test] +fn an_unreadable_source_falls_back_to_the_cached_state() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + fixture.apply(); + std::fs::remove_file(&fixture.state_file).unwrap(); + + let outcome = fixture.apply(); + assert!(outcome.completed); + let problem = outcome.state_problem.unwrap(); + assert!( + problem.contains("can't read the managed state"), + "{problem}" + ); + + // Without a cached state the problem is the error. + std::fs::remove_file(fixture.layout.cached_state()).unwrap(); + assert!(apply(&fixture.context()).is_err()); +} + +#[test] +fn a_refused_state_falls_back_and_a_refused_cache_is_an_error() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + fixture.apply(); + fixture.publish(&[pdf], json!({ "subject": "team-b" })); + let outcome = fixture.apply(); + assert!(outcome.state_problem.unwrap().contains("team-b")); + + // The cache no longer opens under other keys either. + let mut context = fixture.context(); + context.settings.keys = vec![pinned("k2", 2)]; + let error = apply(&context).unwrap_err(); + assert!(error.to_string().contains("pinned key"), "{error}"); +} + +#[test] +fn an_artifact_with_the_wrong_digest_or_identity_fails_that_skill() { + let mut fixture = Fixture::new(); + let mut wrong_digest = fixture.skill("pdf", "one"); + wrong_digest.digest = fixture.skill("pdf", "two").digest; + let mut wrong_id = fixture.skill("docx", "one"); + wrong_id.id = "xlsx".to_string(); + let missing = StateSkill { + artifact: "missing.zip".to_string(), + ..fixture.skill("csv", "one") + }; + fixture.publish(&[wrong_digest, wrong_id, missing], json!({})); + + let outcome = fixture.apply(); + assert!(!outcome.completed); + let failures: Vec<&str> = outcome + .failures + .iter() + .map(|(_, why)| why.as_str()) + .collect(); + assert_eq!(failures.len(), 3, "{failures:?}"); + assert!(failures[0].contains("has digest"), "{failures:?}"); + assert!(failures[1].contains("is the skill"), "{failures:?}"); + assert!(outcome.deployed.is_empty()); +} + +#[test] +fn a_changed_source_or_no_source_is_refused() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + fixture.apply(); + let mut context = fixture.context(); + context.settings.source = Some(ManagedSource::File(fixture.root.join("elsewhere.dsse"))); + let error = apply(&context).unwrap_err(); + assert!(error.to_string().contains("enrolled with"), "{error}"); + context.settings.source = None; + assert!(apply(&context).is_err()); + assert!(enroll(&context).is_err()); +} + +#[test] +fn enrolling_again_keeps_the_machine_id_and_follows_a_new_subject() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + let ApplyResult::Done(first) = enroll(&fixture.context()).unwrap() else { + panic!("busy"); + }; + assert!(first.completed); + let before: Enrollment = read_record(&fixture.layout.enrollment_file()).unwrap(); + fixture.publish( + &[pdf], + json!({ "subject": "team-b", "issued_at": "2026-10-09T11:30:00Z" }), + ); + enroll(&fixture.context()).unwrap(); + let after: Enrollment = read_record(&fixture.layout.enrollment_file()).unwrap(); + assert_eq!(after.machine_id, before.machine_id); + assert_eq!(after.recorded.subject.as_deref(), Some("team-b")); +} + +#[test] +fn unenrolling_removes_everything_but_the_quarantine() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + let docx = fixture.skill("docx", "one"); + fixture.publish(&[pdf, docx], json!({})); + fixture.apply(); + let target = fixture.target(); + std::fs::remove_file(target.join("docx")).unwrap(); + write_skill(&target.join("docx"), "docx", "edited"); + let user_file = fixture.root.join("managed.toml"); + std::fs::write(&user_file, "").unwrap(); + // An owned entry that's already gone is skipped. + let mut ownership = fixture.ownership(); + ownership.record(OwnedEntry { + target: target.clone(), + id: "gone".to_string(), + digest: "x".to_string(), + mode: EntryMode::Copy, + }); + write_record(&fixture.layout.ownership_file(), &ownership).unwrap(); + + let outcome = unenroll( + &fixture.layout, + &fixture.settings(), + Some(&user_file), + now(), + ) + .unwrap(); + assert_eq!(outcome.removed, vec![target.join("pdf")]); + assert_eq!(outcome.quarantined.len(), 1); + assert_eq!(outcome.removed_settings, Some(user_file.clone())); + assert!(!user_file.exists()); + assert!(!fixture.layout.managed().exists()); + assert_eq!(quarantine::list(&fixture.layout).unwrap().len(), 1); + + let mut required = fixture.settings(); + required.required_by_system = true; + let error = unenroll(&fixture.layout, &required, None, now()).unwrap_err(); + assert!(error.to_string().contains("can't unenroll"), "{error}"); +} + +#[test] +fn status_reads_the_records_and_the_cached_state() { + let mut fixture = Fixture::new(); + let empty = status(&fixture.layout, &fixture.settings(), now()).unwrap(); + assert!(empty.enrollment.is_none() && empty.last_apply.is_none()); + + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + fixture.apply(); + let shown = status(&fixture.layout, &fixture.settings(), now()).unwrap(); + assert_eq!(shown.subject.as_deref(), Some("team-a")); + assert!(!shown.expired && shown.state_problem.is_none()); + assert_eq!(shown.owned.len(), 1); + assert!(shown.last_apply.unwrap().completed); + let later = status( + &fixture.layout, + &fixture.settings(), + now() + Duration::days(30), + ) + .unwrap(); + assert!(later.expired); + + let mut other = fixture.settings(); + other.keys = vec![pinned("k2", 2)]; + let refused = status(&fixture.layout, &other, now()).unwrap(); + assert!(refused.state_problem.is_some()); + std::fs::remove_file(fixture.layout.cached_state()).unwrap(); + let uncached = status(&fixture.layout, &fixture.settings(), now()).unwrap(); + assert_eq!( + uncached.state_problem.as_deref(), + Some("no state has been accepted yet") + ); +} + +#[test] +fn a_corrupt_store_entry_is_fetched_again() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(std::slice::from_ref(&pdf), json!({})); + fixture.apply(); + let store = ManagedStore::new(&fixture.layout); + std::fs::write(store.path(&pdf.digest).join("SKILL.md"), "tampered").unwrap(); + assert!(store.verified(&pdf.digest).unwrap().is_none()); + assert!(!store.path(&pdf.digest).exists()); + let source = ManagedSource::File(fixture.state_file.clone()); + let path = store.ensure(&pdf, &source).unwrap(); + assert_eq!(content_digest(&path).unwrap(), pdf.digest); +} + +#[test] +fn artifact_paths_and_https_sources() { + let file = ManagedSource::File(PathBuf::from("/srv/state/state.dsse")); + assert_eq!( + artifact_path("a.zip", &file).unwrap(), + PathBuf::from("/srv/state/a.zip") + ); + let absolute = std::env::temp_dir().join("a.zip"); + assert_eq!( + artifact_path(absolute.to_str().unwrap(), &file).unwrap(), + absolute + ); + assert!(artifact_path("https://example.com/a.zip", &file).is_err()); + let https = ManagedSource::parse("https://skills.example.com/state").unwrap(); + assert!(artifact_path("https://skills.example.com/a.zip", &https).is_err()); + let error = fetch(&https).unwrap_err(); + assert!(matches!(error, ServiceError::InvalidOperation(_))); +} + +#[test] +fn records_and_quarantine_listing_tolerate_what_they_can() { + let fixture = Fixture::new(); + assert!(ManagedLayout::for_current_user().is_ok()); + std::fs::create_dir_all(fixture.layout.managed()).unwrap(); + std::fs::write(fixture.layout.ownership_file(), "not json").unwrap(); + let damaged = read_record::(&fixture.layout.ownership_file()).unwrap_err(); + assert!(damaged.to_string().contains("damaged"), "{damaged}"); + + assert!(quarantine::list(&fixture.layout).unwrap().is_empty()); + let skill = fixture.root.join("loose"); + write_skill(&skill, "loose", "one"); + let file = fixture.root.join("loose.txt"); + std::fs::write(&file, "x").unwrap(); + let first = quarantine::quarantine( + &fixture.layout, + &skill, + None, + QuarantineReason::Blocked, + now(), + ) + .unwrap(); + quarantine::quarantine( + &fixture.layout, + &file, + None, + QuarantineReason::Blocked, + now(), + ) + .unwrap(); + std::fs::create_dir_all(fixture.layout.quarantine().join("stray")).unwrap(); + let listed = quarantine::list(&fixture.layout).unwrap(); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0], first); + assert!(!skill.exists() && !file.exists()); +} + +#[test] +fn quarantine_reports_what_it_cant_move_and_lists_only_complete_records() { + let fixture = Fixture::new(); + let missing = fixture.root.join("missing"); + let error = quarantine::quarantine( + &fixture.layout, + &missing, + None, + QuarantineReason::Modified, + now(), + ) + .unwrap_err(); + assert!(error.to_string().contains("into the quarantine"), "{error}"); + + let blank = fixture.layout.quarantine().join("blank"); + std::fs::create_dir_all(&blank).unwrap(); + std::fs::write(blank.join(quarantine::RECORD_FILE), "{}").unwrap(); + assert!(quarantine::list(&fixture.layout).unwrap().is_empty()); + + let stamp = now().format("%Y%m%dT%H%M%S%.3fZ").to_string(); + for n in 0..1000 { + std::fs::create_dir_all(fixture.layout.quarantine().join(format!("{stamp}-{n}"))).unwrap(); + } + let error = quarantine::quarantine( + &fixture.layout, + &missing, + None, + QuarantineReason::Modified, + now(), + ) + .unwrap_err(); + assert!( + error.to_string().contains("no free quarantine folder"), + "{error}" + ); +} diff --git a/crates/fastskill-core/src/core/managed/config.rs b/crates/fastskill-core/src/core/managed/config.rs index 161ee700..512ddc5d 100644 --- a/crates/fastskill-core/src/core/managed/config.rs +++ b/crates/fastskill-core/src/core/managed/config.rs @@ -109,6 +109,8 @@ pub struct ManagedSettings { pub targets: Option>, /// Whether the system file set `required = true`. pub required_by_system: bool, + /// Whether the system file names the source, so the first apply may enroll. + pub source_from_system: bool, /// The files the settings came from. pub files: Vec, } @@ -139,11 +141,9 @@ impl ManagedSettings { /// Read the platform system file and the user file. pub fn load() -> Result { - let user = crate::core::repository::user_config::user_config_dir() - .map(|dir| dir.join(USER_FILE_NAME)); Self::load_from( system_file_path().as_deref(), - user.as_deref(), + user_file_path().as_deref(), check_system_file, ) } @@ -191,6 +191,7 @@ fn merge( ) -> Result { let system = system.unwrap_or_default(); let required_by_system = system.required == Some(true); + let source_from_system = system.source.is_some(); let source = system.source.or(user.source); let keys = system.keys.or(user.keys).unwrap_or_default(); let credential_command = system.credential_command.or(user.credential_command); @@ -201,6 +202,7 @@ fn merge( required: system.required.or(user.required).unwrap_or(false), targets: system.targets.or(user.targets), required_by_system, + source_from_system, files: Vec::new(), }; if let Some(command) = &settings.credential_command { @@ -259,6 +261,11 @@ fn parse_keys(entries: &[KeyEntry]) -> Result, ManagedError> { .collect() } +/// The user's managed settings file, in FastSkill's configuration directory. +pub fn user_file_path() -> Option { + crate::core::repository::user_config::user_config_dir().map(|dir| dir.join(USER_FILE_NAME)) +} + /// Where device management deploys the system file on this platform. pub fn system_file_path() -> Option { if cfg!(target_os = "linux") { diff --git a/crates/fastskill-core/src/core/managed/enrollment.rs b/crates/fastskill-core/src/core/managed/enrollment.rs new file mode 100644 index 00000000..6711441d --- /dev/null +++ b/crates/fastskill-core/src/core/managed/enrollment.rs @@ -0,0 +1,179 @@ +//! Enrolling, unenrolling and the status of managed state for this user (ADR-0016 decision +//! 17). + +use super::apply::{apply, entry_digest, ApplyContext, ApplyOutcome, ApplyResult}; +use super::config::ManagedSettings; +use super::layout::{read_record, write_record, ManagedLayout}; +use super::quarantine::{self, quarantine, remove_entry, QuarantineReason, QuarantineRecord}; +use super::records::{Enrollment, OwnedEntry, Ownership}; +use super::state::Recorded; +use crate::core::service::ServiceError; +use chrono::{DateTime, Utc}; +use std::path::{Path, PathBuf}; + +/// Enroll this user with the configured source, then apply its first state. +/// +/// Enrolling again with the same source keeps the machine id and the newest accepted +/// `issued_at`, and forgets the recorded subject, so the next state may name another one. +pub fn enroll(context: &ApplyContext) -> Result { + let source = context + .settings + .source + .as_ref() + .ok_or_else(|| ServiceError::Config("no managed source is configured".to_string()))?; + let layout = &context.layout; + { + let Some(_lock) = layout.try_lock()? else { + return Ok(ApplyResult::Busy); + }; + let previous: Enrollment = read_record(&layout.enrollment_file())?; + let mut enrollment = Enrollment::new(source.as_str(), context.now); + if previous.is_enrolled() && source.matches(&previous.source) { + enrollment.machine_id = previous.machine_id; + enrollment.recorded = Recorded { + subject: None, + highest_issued_at: previous.recorded.highest_issued_at, + }; + } + write_record(&layout.enrollment_file(), &enrollment)?; + } + apply(context) +} + +/// What unenrolling did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UnenrollOutcome { + pub removed: Vec, + pub quarantined: Vec, + /// The user managed settings file, when it was removed. + pub removed_settings: Option, +} + +/// Remove what managed state put on this machine for this user: every owned target entry (a +/// changed copy is quarantined instead), the store, the cached state, the records with the +/// machine id, and the user managed settings file. The quarantine is kept. +/// +/// Refused when the system file sets `required = true`, and while an apply runs. +pub fn unenroll( + layout: &ManagedLayout, + settings: &ManagedSettings, + user_settings_file: Option<&Path>, + now: DateTime, +) -> Result { + if settings.required_by_system { + return Err(ServiceError::InvalidOperation( + "this machine's managed settings require managed state, so this user can't \ + unenroll; ask whoever manages this machine" + .to_string(), + )); + } + let lock = layout.try_lock()?.ok_or_else(|| { + ServiceError::InvalidOperation( + "an apply is running; try again when it finishes".to_string(), + ) + })?; + let mut outcome = UnenrollOutcome::default(); + let ownership: Ownership = read_record(&layout.ownership_file())?; + for owned in &ownership.entries { + remove_owned(layout, owned, now, &mut outcome)?; + } + for entry in std::fs::read_dir(layout.managed())? { + let path = entry?.path(); + if path != layout.lock_file() { + remove_entry(&path)?; + } + } + drop(lock); + // The lock file may still be open elsewhere on some platforms; leaving it is harmless. + let _ = std::fs::remove_file(layout.lock_file()); + let _ = std::fs::remove_dir(layout.managed()); + if let Some(file) = user_settings_file.filter(|file| file.is_file()) { + std::fs::remove_file(file)?; + outcome.removed_settings = Some(file.to_path_buf()); + } + Ok(outcome) +} + +fn remove_owned( + layout: &ManagedLayout, + owned: &OwnedEntry, + now: DateTime, + outcome: &mut UnenrollOutcome, +) -> Result<(), ServiceError> { + let path = owned.path(); + if path.symlink_metadata().is_err() { + return Ok(()); + } + let current = entry_digest(&path); + if current.as_deref() == Some(owned.digest.as_str()) { + remove_entry(&path)?; + outcome.removed.push(path); + } else { + let record = quarantine( + layout, + &path, + current.as_deref(), + QuarantineReason::Modified, + now, + )?; + outcome.quarantined.push(record); + } + Ok(()) +} + +/// What `managed status` shows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ManagedStatus { + /// The configured source, if any. + pub source: Option, + pub enrollment: Option, + pub subject: Option, + pub issued_at: Option>, + pub expires_at: Option>, + pub expired: bool, + /// Why the cached state can't be used, when it can't. + pub state_problem: Option, + pub owned: Vec, + pub last_apply: Option, + pub quarantine: Vec, +} + +/// Read the status from the records and the cached state, changing nothing. +pub fn status( + layout: &ManagedLayout, + settings: &ManagedSettings, + now: DateTime, +) -> Result { + let enrollment: Enrollment = read_record(&layout.enrollment_file())?; + let ownership: Ownership = read_record(&layout.ownership_file())?; + let last_apply: ApplyOutcome = read_record(&layout.last_apply_file())?; + let mut status = ManagedStatus { + source: settings + .source + .as_ref() + .map(|source| source.as_str().to_string()), + owned: ownership.entries, + last_apply: last_apply.applied_at.is_some().then_some(last_apply), + quarantine: quarantine::list(layout)?, + ..ManagedStatus::default() + }; + if enrollment.is_enrolled() { + match std::fs::read(layout.cached_state()) { + Ok(bytes) => { + // The cached state is the newest accepted, so its own issued_at is allowed. + match super::open(&bytes, settings, &enrollment.recorded, now) { + Ok(opened) => { + status.subject = Some(opened.state.subject.clone()); + status.issued_at = Some(opened.state.issued_at); + status.expires_at = Some(opened.state.expires_at); + status.expired = opened.state.is_expired(now); + } + Err(error) => status.state_problem = Some(error.to_string()), + } + } + Err(_) => status.state_problem = Some("no state has been accepted yet".to_string()), + } + status.enrollment = Some(enrollment); + } + Ok(status) +} diff --git a/crates/fastskill-core/src/core/managed/layout.rs b/crates/fastskill-core/src/core/managed/layout.rs new file mode 100644 index 00000000..8660e8ca --- /dev/null +++ b/crates/fastskill-core/src/core/managed/layout.rs @@ -0,0 +1,126 @@ +//! Where managed data lives for one user, and the per-user apply lock (ADR-0016 decisions 9, +//! 11 and 12). +//! +//! ```text +//! /managed/ +//! lock held while an apply runs +//! enrollment.json source, recorded subject and issued_at, machine id +//! state.dsse the last accepted envelope +//! ownership.json every target entry FastSkill created +//! last-apply.json what the last apply did +//! staging/ downloads and extraction, private to the user +//! store// published skills, never modified after publication +//! /quarantine/