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..8bc3e91b --- /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. + quarantine::remove_entry(&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"); + quarantine::remove_entry(&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(); + quarantine::remove_entry(&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(); + quarantine::remove_entry(&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/