diff --git a/Cargo.lock b/Cargo.lock index a39f6947..faa7d7fa 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=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" +source = "git+https://github.com/goaikit/aikit.git?rev=b894e4f0d0da42465c316efd1b3eb4e9582219c8#b894e4f0d0da42465c316efd1b3eb4e9582219c8" 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=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" +source = "git+https://github.com/goaikit/aikit.git?rev=b894e4f0d0da42465c316efd1b3eb4e9582219c8#b894e4f0d0da42465c316efd1b3eb4e9582219c8" 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=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" +source = "git+https://github.com/goaikit/aikit.git?rev=b894e4f0d0da42465c316efd1b3eb4e9582219c8#b894e4f0d0da42465c316efd1b3eb4e9582219c8" dependencies = [ "aikit-agent", "claude-agent-sdk", @@ -103,7 +103,7 @@ dependencies = [ [[package]] name = "aikit-skillopt" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" +source = "git+https://github.com/goaikit/aikit.git?rev=b894e4f0d0da42465c316efd1b3eb4e9582219c8#b894e4f0d0da42465c316efd1b3eb4e9582219c8" dependencies = [ "aikit-evals", "aikit-sdk", @@ -116,7 +116,7 @@ dependencies = [ [[package]] name = "aikit-textgrad" version = "0.1.0" -source = "git+https://github.com/goaikit/aikit.git?rev=9de6b40db1703c921d1be327d0e417be1f734c03#9de6b40db1703c921d1be327d0e417be1f734c03" +source = "git+https://github.com/goaikit/aikit.git?rev=b894e4f0d0da42465c316efd1b3eb4e9582219c8#b894e4f0d0da42465c316efd1b3eb4e9582219c8" dependencies = [ "aikit-evals", "aikit-sdk", @@ -497,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", @@ -694,7 +694,7 @@ dependencies = [ [[package]] name = "cli-framework" version = "0.5.8" -source = "git+https://github.com/aroff/cli-framework?rev=971134fd4257578ba19e95faae5d7a1c1bc74bde#971134fd4257578ba19e95faae5d7a1c1bc74bde" +source = "git+https://github.com/aroff/cli-framework?rev=ed0b3521f3f24b3b23713a810b2bbd73b84c5cc6#ed0b3521f3f24b3b23713a810b2bbd73b84c5cc6" dependencies = [ "aikit-sdk", "ailoop-core", diff --git a/Cargo.toml b/Cargo.toml index 0b794408..ea356d21 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 = "9de6b40db1703c921d1be327d0e417be1f734c03" } +aikit-sdk = { git = "https://github.com/goaikit/aikit.git", rev = "b894e4f0d0da42465c316efd1b3eb4e9582219c8" } # aikit-evals provides the generic eval runner infrastructure -aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "9de6b40db1703c921d1be327d0e417be1f734c03" } +aikit-evals = { git = "https://github.com/goaikit/aikit.git", rev = "b894e4f0d0da42465c316efd1b3eb4e9582219c8" } # aikit-skillopt provides the text-gradient skill optimization loop -aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "9de6b40db1703c921d1be327d0e417be1f734c03" } +aikit-skillopt = { git = "https://github.com/goaikit/aikit.git", rev = "b894e4f0d0da42465c316efd1b3eb4e9582219c8" } # 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 = "971134fd4257578ba19e95faae5d7a1c1bc74bde", default-features = false, features = ["api-server"] } +cli-framework = { git = "https://github.com/aroff/cli-framework", rev = "ed0b3521f3f24b3b23713a810b2bbd73b84c5cc6", 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/managed.rs b/crates/fastskill-cli/src/commands/managed.rs index ad42887b..c89c2eb0 100644 --- a/crates/fastskill-cli/src/commands/managed.rs +++ b/crates/fastskill-cli/src/commands/managed.rs @@ -1,7 +1,7 @@ //! `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 +//! `enroll`, `apply` and `unenroll` are writes, `status` and `hooks` are reads (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}; @@ -11,14 +11,54 @@ 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, + self, ApplyContext, ApplyOutcome, ApplyResult, HookSetup, ManagedLayout, ManagedSettings, + ManagedStatus, TimerSetup, UnenrollOutcome, }; use serde_json::{json, Value}; use std::collections::HashMap; use std::io::IsTerminal; use std::path::PathBuf; +/// Register the `managed` group and its commands. +pub fn register( + builder: cli_framework::prelude::AppBuilder, +) -> anyhow::Result { + use crate::registration::AppBuilderExt; + use cli_framework::path; + builder + .register_group(&path!["managed"], group_metadata())? + .register_out( + path!["managed", "enroll"], + |_ctx, args: EnrollArgs| async move { + execute_enroll(args).await.map_err(anyhow::Error::from) + }, + )? + .register_out( + path!["managed", "apply"], + |_ctx, args: ApplyArgs| async move { + execute_apply(args).await.map_err(anyhow::Error::from) + }, + )? + .register_out( + path!["managed", "status"], + |_ctx, args: StatusArgs| async move { + execute_status(args).await.map_err(anyhow::Error::from) + }, + )? + .register_out( + path!["managed", "hooks"], + |_ctx, args: HooksArgs| async move { + execute_hooks(args).await.map_err(anyhow::Error::from) + }, + )? + .register_out( + path!["managed", "unenroll"], + |_ctx, args: UnenrollArgs| async move { + execute_unenroll(args).await.map_err(anyhow::Error::from) + }, + ) +} + /// The `managed` group. pub fn group_metadata() -> GroupMetadata { GroupMetadata { @@ -36,6 +76,12 @@ pub struct ManagedArgs { /// `status` only: include what the last apply did and the report body; with `--json`, print /// only the report body. pub report: bool, + /// `apply` only: run as the session-start hook with this id. + pub hook: Option, + /// `apply` only: wait up to 10 minutes at random first, as the timer does. + pub timer: bool, + /// `hooks` only: print the administrator-level hooks and machine-wide timer. + pub system: bool, } fn json_arg() -> ArgSpec { @@ -50,6 +96,18 @@ fn json_arg() -> ArgSpec { } } +fn flag(name: &'static str, help: &'static str) -> ArgSpec { + ArgSpec { + name, + kind: ArgKind::Flag, + long: Some(name), + value_type: ArgValueType::Bool, + cardinality: Cardinality::Optional, + help, + ..Default::default() + } +} + fn spec(summary: &'static str, syntax: &'static str, examples: Vec<&'static str>) -> CommandSpec { CommandSpec { summary, @@ -66,6 +124,12 @@ impl FromArgValueMap for ManagedArgs { Self { json: matches!(map.get("json"), Some(ArgValue::Bool(true))), report: matches!(map.get("report"), Some(ArgValue::Bool(true))), + hook: match map.get("hook") { + Some(ArgValue::Str(id)) => Some(id.clone()), + _ => None, + }, + timer: matches!(map.get("timer"), Some(ArgValue::Bool(true))), + system: matches!(map.get("system"), Some(ArgValue::Bool(true))), } } } @@ -97,14 +161,29 @@ managed_args!( vec!["fastskill managed enroll"], ) ); -managed_args!( - ApplyArgs, - spec( +managed_args!(ApplyArgs, { + let mut spec = spec( "Apply the managed state to this user's agents", - "managed apply [--json]", + "managed apply [--json] [--hook ] [--timer]", vec!["fastskill managed apply", "fastskill managed apply --json"], - ) -); + ); + spec.args.push(ArgSpec { + name: "hook", + kind: ArgKind::Option, + long: Some("hook"), + value_type: ArgValueType::String, + cardinality: Cardinality::Optional, + conflicts_with: vec!["json", "timer"], + help: "Run as the session-start hook with this id: start an apply in the background, \ + print a notice only when the user must act, and always succeed", + ..Default::default() + }); + spec.args.push(flag( + "timer", + "Wait up to 10 minutes at random before applying, as the timer does", + )); + spec +}); managed_args!(StatusArgs, { let mut spec = spec( "Show the managed source, state, targets, collisions and quarantine", @@ -115,16 +194,27 @@ managed_args!(StatusArgs, { "fastskill managed status --json --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 and the report body; with --json, print \ - only the report body", - ..Default::default() - }); + spec.args.push(flag( + "report", + "Include everything the last apply did and the report body; with --json, print only \ + the report body", + )); + spec +}); +managed_args!(HooksArgs, { + let mut spec = spec( + "Show this user's session-start hooks and timer, or the administrator-level ones", + "managed hooks [--system] [--json]", + vec![ + "fastskill managed hooks", + "fastskill managed hooks --system", + ], + ); + spec.args.push(flag( + "system", + "Print the administrator-level hook entry for each supported agent and a machine-wide \ + timer; nothing is written", + )); spec }); managed_args!( @@ -147,12 +237,31 @@ pub struct Environment { pub interactive: bool, /// FastSkill's configuration folder, where the credential command runs. pub config_dir: PathBuf, + /// The FastSkill executable hooks and the timer run. + pub exe: PathBuf, + /// The session-start hooks an apply registers; `None` registers none. + pub hooks: Option, + /// The timer enrolling installs; `None` installs none. + pub timer: Option, + /// Starts `managed apply` in the background, for a hook. + pub background: fn(&std::path::Path) -> std::io::Result<()>, } +/// Set to `off` to install no timer, for machines without a user service manager. +pub const TIMER_ENV: &str = "FASTSKILL_MANAGED_TIMER"; + impl Environment { /// This user's environment. pub fn current() -> CliResult { let settings = ManagedSettings::load().map_err(fastskill_core::ServiceError::from)?; + let home = dirs::home_dir() + .ok_or_else(|| CliError::Config("can't determine the home folder".to_string()))?; + let exe = std::env::current_exe() + .map_err(|error| CliError::Config(format!("can't find this executable: {error}")))?; + let timer = match std::env::var(TIMER_ENV) { + Ok(value) if value.eq_ignore_ascii_case("off") => None, + _ => TimerSetup::current(&home, &exe), + }; Ok(Self { layout: ManagedLayout::for_current_user()?, settings, @@ -160,10 +269,13 @@ impl Environment { .and_then(|file| file.parent().map(PathBuf::from)) .unwrap_or_else(std::env::temp_dir), user_settings_file: user_file_path(), - home: dirs::home_dir() - .ok_or_else(|| CliError::Config("can't determine the home folder".to_string()))?, + home, project_skills: crate::config::resolve_skills_storage_directory(false).ok(), interactive: std::io::stdout().is_terminal(), + hooks: Some(HookSetup::new(managed::hooks::quote_program(&exe))), + timer, + exe, + background: super::managed_hooks::start_background_apply, }) } @@ -180,6 +292,8 @@ impl Environment { now: chrono::Utc::now(), interactive: self.interactive, config_dir: self.config_dir.clone(), + hooks: self.hooks.clone(), + timer: self.timer.clone(), }) } } @@ -197,9 +311,26 @@ pub async fn execute_enroll(args: EnrollArgs) -> CliResult<()> { } pub async fn execute_apply(args: ApplyArgs) -> CliResult<()> { + if let Some(id) = args.0.hook { + // A hook never fails and never says more than one notice. + if let Ok(env) = Environment::current() { + if let Some(notice) = super::managed_hooks::run_hook(&env, &id) { + crate::outln!("{notice}"); + } + } + return Ok(()); + } + if args.0.timer { + tokio::time::sleep(managed::timer::random_delay()).await; + } emit(off_runtime(move || run_apply(&Environment::current()?, &args.0)).await?) } +pub async fn execute_hooks(args: HooksArgs) -> CliResult<()> { + let env = Environment::current()?; + emit(Ok(Some(super::managed_hooks::run_hooks(&env, &args.0)?))) +} + /// Run an apply on a blocking thread: it makes blocking https requests and runs the /// credential command. async fn off_runtime( @@ -338,6 +469,15 @@ pub fn render_outcome(outcome: &ApplyOutcome) -> String { for warning in &outcome.warnings { lines.push(format!(" warning {warning}")); } + if !outcome.hooks.is_empty() { + lines.push(format!(" hooks {}", outcome.hooks.join(", "))); + } + for note in &outcome.hook_approvals { + lines.push(format!(" approve {note}")); + } + if let Some(timer) = &outcome.timer { + lines.push(format!(" timer {timer}")); + } if let Some(report) = &outcome.report { lines.push(match (&report.problem, report.accepted) { (_, true) => format!(" report #{} accepted", report.sequence), @@ -382,6 +522,7 @@ fn status_json(status: &ManagedStatus, report: bool) -> Value { "report_to": status.report_note, "warnings": last.map(|l| l.warnings.clone()).unwrap_or_default(), "targets": last.map(|l| l.targets.clone()).unwrap_or_default(), + "hooks": last.map(|l| l.hooks.clone()).unwrap_or_default(), "collisions": last.map(|l| l.collisions.clone()).unwrap_or_default(), "owned": status.owned, "quarantine": status.quarantine.iter().map(|record| json!({ @@ -442,6 +583,9 @@ pub fn render_status(status: &ManagedStatus, report: bool) -> String { for target in &last.targets { lines.push(format!(" target {}", target.display())); } + if !last.hooks.is_empty() { + lines.push(format!(" hooks {}", last.hooks.join(", "))); + } for path in &last.collisions { lines.push(format!(" collision {}", path.display())); } @@ -478,6 +622,8 @@ pub fn run_unenroll(env: &Environment, args: &ManagedArgs) -> CliResult &env.layout, &env.settings, env.user_settings_file.as_deref(), + &env.home, + env.timer.as_ref(), chrono::Utc::now(), )?; if args.json { @@ -497,6 +643,15 @@ pub fn run_unenroll(env: &Environment, args: &ManagedArgs) -> CliResult if let Some(file) = &outcome.removed_settings { lines.push(format!(" removed {}", file.display())); } + for agent in &outcome.removed_hooks { + lines.push(format!(" removed the {agent} session hook")); + } + if outcome.removed_timer { + lines.push(" removed the timer".to_string()); + } + for warning in &outcome.warnings { + lines.push(format!(" warning {warning}")); + } Ok(lines.join("\n")) } @@ -508,6 +663,9 @@ fn unenroll_json(outcome: &UnenrollOutcome) -> Value { "former_path": record.former_path, })).collect::>(), "removed_settings": outcome.removed_settings, + "removed_hooks": outcome.removed_hooks, + "removed_timer": outcome.removed_timer, + "warnings": outcome.warnings, }) } diff --git a/crates/fastskill-cli/src/commands/managed_hooks.rs b/crates/fastskill-cli/src/commands/managed_hooks.rs new file mode 100644 index 00000000..e3bac94a --- /dev/null +++ b/crates/fastskill-cli/src/commands/managed_hooks.rs @@ -0,0 +1,200 @@ +//! `fastskill managed apply --hook ` and `fastskill managed hooks`: the session-start hook +//! and what is registered (ADR-0016 decision 16). +//! +//! A hook starts an apply in the background and returns at once. It prints nothing unless the +//! last apply left something the user must act on, and then one notice in the agent's form. + +use super::managed::{Environment, ManagedArgs}; +use crate::error::CliResult; +use fastskill_core::core::managed::hooks::{self, agent_of, hook_id, quote_program, HookSetup}; +use fastskill_core::core::managed::timer::{self, TimerOs}; +use fastskill_core::core::managed::{self, ManagedStatus}; +use serde_json::{json, Value}; +use std::path::Path; +use std::process::{Command, Stdio}; + +/// Run as the hook with `id`: start an apply in the background when this user is enrolled or +/// would be, and return the notice to print, if any. Never fails. +pub fn run_hook(env: &Environment, id: &str) -> Option { + let agent = agent_of(id)?; + if !env.settings.is_configured() { + return None; + } + let status = managed::status(&env.layout, &env.settings, chrono::Utc::now()).ok()?; + if status.enrollment.is_none() && !env.settings.source_from_system { + return None; + } + // The per-user lock keeps this from overlapping a running apply. + let _ = (env.background)(&env.exe); + aikit_sdk::format_notice(agent, ¬ice(&status)?) +} + +/// What the user must act on after the last apply, if anything. +pub fn notice(status: &ManagedStatus) -> Option { + status.enrollment.as_ref()?; + if status.sign_in_needed { + Some( + "FastSkill: sign-in needed to update managed skills. Run `fastskill managed apply` \ + in a terminal." + .to_string(), + ) + } else if status.expired { + Some( + "FastSkill: the managed state has expired, so managed skills aren't updated. Run \ + `fastskill managed status`." + .to_string(), + ) + } else if status.subject.is_none() { + Some( + "FastSkill: no managed skills have been applied for this user yet. Run `fastskill \ + managed status`." + .to_string(), + ) + } else { + None + } +} + +/// Start `managed apply` detached from this process, with no terminal. +pub fn start_background_apply(exe: &Path) -> std::io::Result<()> { + let mut command = Command::new(exe); + command + .args(["managed", "apply"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } + command.spawn().map(drop) +} + +/// `managed hooks`: this user's hooks and timer, or with `--system` the administrator-level +/// hooks and the machine-wide timer. Writes nothing. +pub fn run_hooks(env: &Environment, args: &ManagedArgs) -> CliResult { + let setup = env + .hooks + .clone() + .unwrap_or_else(|| HookSetup::new(quote_program(&env.exe))); + let value = if args.system { + system_json(&setup, TimerOs::current(), &env.exe) + } else { + user_json(env, &setup) + }; + if args.json { + return Ok(serde_json::to_string_pretty(&value).unwrap_or_default()); + } + Ok(if args.system { + render_system(&value) + } else { + render_user(&value) + }) +} + +fn user_json(env: &Environment, setup: &HookSetup) -> Value { + let agents: Vec = aikit_sdk::session_hook_agents() + .into_iter() + .map(|key| { + let id = hook_id(key); + let user = aikit_sdk::has_session_hook(&env.home, key, &id); + json!({ + "agent": key, + "user": user.as_ref().ok().copied().unwrap_or(false), + "problem": user.err().map(|error| error.to_string()), + "administrator": (setup.admin_present)(key, &id), + }) + }) + .collect(); + let timer = env.timer.as_ref().map(|setup| { + json!({ + "name": timer::describe(setup), + "installed": timer::remove_plan(setup).remove.iter().any(|path| path.exists()), + "machine_wide": (setup.system_present)(setup.os), + }) + }); + json!({ "agents": agents, "timer": timer }) +} + +fn render_user(value: &Value) -> String { + let mut lines = vec!["Session-start hooks:".to_string()]; + for agent in value["agents"].as_array().into_iter().flatten() { + let name = agent["agent"].as_str().unwrap_or_default(); + let state = match ( + agent["administrator"].as_bool(), + agent["user"].as_bool(), + agent["problem"].as_str(), + ) { + (Some(true), _, _) => "administrator-level".to_string(), + (_, Some(true), _) => "user-level".to_string(), + (_, _, Some(problem)) => format!("unknown: {problem}"), + _ => "none".to_string(), + }; + lines.push(format!(" {name:<8} {state}")); + } + let timer = &value["timer"]; + lines.push(match timer["name"].as_str() { + None => "Timer: none on this platform".to_string(), + Some(_) if timer["machine_wide"] == true => "Timer: machine-wide".to_string(), + Some(name) if timer["installed"] == true => format!("Timer: {name}"), + Some(_) => "Timer: none; `fastskill managed enroll` installs it".to_string(), + }); + lines.join("\n") +} + +fn system_json(setup: &HookSetup, os: Option, exe: &Path) -> Value { + json!({ + "hooks": hooks::admin_hooks(setup), + "timer": os.map(|os| timer::system_plan(os, exe)), + }) +} + +fn render_system(value: &Value) -> String { + let mut lines = Vec::new(); + for hook in value["hooks"].as_array().into_iter().flatten() { + let file = hook["file"].as_str().unwrap_or("(no administrator file)"); + lines.push(format!( + "{}: merge into {file}{}", + hook["agent"].as_str().unwrap_or_default(), + if hook["present"] == true { + " (already present)" + } else { + "" + } + )); + lines.push(serde_json::to_string_pretty(&hook["entry"]).unwrap_or_default()); + lines.push(String::new()); + } + let plan = &value["timer"]; + if plan.is_null() { + lines.push("Machine-wide timer: none on this platform".to_string()); + return lines.join("\n"); + } + lines.push("Machine-wide timer:".to_string()); + for file in plan["write"].as_array().into_iter().flatten() { + lines.push(format!("{}:", file["path"].as_str().unwrap_or_default())); + lines.push(file["contents"].as_str().unwrap_or_default().to_string()); + } + for command in plan["commands"].as_array().into_iter().flatten() { + let args: Vec<&str> = command["args"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + lines.push(format!("then run: {}", args.join(" "))); + } + lines.join("\n") +} + +#[cfg(test)] +#[path = "managed_hooks_tests.rs"] +mod tests; diff --git a/crates/fastskill-cli/src/commands/managed_hooks_tests.rs b/crates/fastskill-cli/src/commands/managed_hooks_tests.rs new file mode 100644 index 00000000..23f85f1b --- /dev/null +++ b/crates/fastskill-cli/src/commands/managed_hooks_tests.rs @@ -0,0 +1,247 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use fastskill_core::core::managed::timer::TimerSetup; +use fastskill_core::core::managed::{Enrollment, ManagedLayout, ManagedSettings, ManagedSource}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tempfile::TempDir; + +static STARTED: AtomicUsize = AtomicUsize::new(0); + +fn count(_: &Path) -> std::io::Result<()> { + STARTED.fetch_add(1, Ordering::SeqCst); + Ok(()) +} + +fn no_admin(_: &str, _: &str) -> bool { + false +} + +fn claude_admin(key: &str, _: &str) -> bool { + key == "claude" +} + +fn ok(_: &[String]) -> Result<(), String> { + Ok(()) +} + +fn absent(_: TimerOs) -> bool { + false +} + +fn present(_: TimerOs) -> bool { + true +} + +fn environment(dir: &TempDir, configured: bool, from_system: bool) -> Environment { + Environment { + layout: ManagedLayout::new(dir.path().join("data")), + settings: ManagedSettings { + source: configured.then(|| ManagedSource::File(dir.path().join("state.dsse"))), + source_from_system: from_system, + ..ManagedSettings::default() + }, + user_settings_file: None, + home: dir.path().join("home"), + project_skills: None, + interactive: false, + config_dir: dir.path().to_path_buf(), + exe: dir.path().join("fastskill"), + hooks: None, + timer: None, + background: count, + } +} + +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 enrolled() -> ManagedStatus { + ManagedStatus { + enrollment: Some(Enrollment::new("file:/x", chrono::Utc::now())), + subject: Some("team-a".to_string()), + ..ManagedStatus::default() + } +} + +fn args(json: bool, system: bool) -> ManagedArgs { + ManagedArgs { + json, + system, + ..ManagedArgs::default() + } +} + +fn hook_setup(admin_present: fn(&str, &str) -> bool) -> HookSetup { + HookSetup { + program: "fastskill".to_string(), + admin_present, + } +} + +#[test] +fn a_hook_starts_an_apply_and_prints_what_the_user_must_act_on() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, false); + enroll_record(&env); + let before = STARTED.load(Ordering::SeqCst); + let printed = run_hook(&env, "fastskill-managed-claude").unwrap(); + assert!(STARTED.load(Ordering::SeqCst) > before); + let value: Value = serde_json::from_str(&printed).unwrap(); + assert!(value["systemMessage"] + .as_str() + .unwrap() + .contains("no managed skills have been applied")); +} + +#[test] +fn a_hook_does_nothing_when_there_is_nothing_to_do() { + let dir = TempDir::new().unwrap(); + // Not one of FastSkill's ids, not configured, or not enrolled with a user source. + assert_eq!(run_hook(&environment(&dir, true, true), "other"), None); + assert_eq!( + run_hook(&environment(&dir, false, true), "fastskill-managed-claude"), + None + ); + assert_eq!( + run_hook(&environment(&dir, true, false), "fastskill-managed-claude"), + None + ); + + // An administrator source enrolls by first apply, so the hook starts one and, with nothing + // enrolled yet, prints nothing. + let before = STARTED.load(Ordering::SeqCst); + let env = environment(&dir, true, true); + assert_eq!(run_hook(&env, "fastskill-managed-gemini"), None); + assert!(STARTED.load(Ordering::SeqCst) > before); +} + +#[test] +fn the_notice_names_what_to_do() { + assert_eq!(notice(&ManagedStatus::default()), None); + assert_eq!(notice(&enrolled()), None); + let sign_in = ManagedStatus { + sign_in_needed: true, + expired: true, + ..enrolled() + }; + assert!(notice(&sign_in).unwrap().contains("sign-in needed")); + let expired = ManagedStatus { + expired: true, + ..enrolled() + }; + assert!(notice(&expired).unwrap().contains("has expired")); + let none = ManagedStatus { + subject: None, + ..enrolled() + }; + assert!(notice(&none).unwrap().contains("no managed skills")); +} + +#[test] +fn a_background_apply_that_cannot_start_is_an_error() { + let dir = TempDir::new().unwrap(); + assert!(start_background_apply(&dir.path().join("missing")).is_err()); +} + +#[cfg(unix)] +#[test] +fn a_background_apply_starts_the_program() { + assert!(start_background_apply(Path::new("/bin/true")).is_ok()); +} + +#[test] +fn hooks_show_each_agent_and_the_timer() { + let dir = TempDir::new().unwrap(); + let mut env = environment(&dir, true, false); + let setup = hook_setup(no_admin); + aikit_sdk::register_session_hook(&env.home, "claude", &setup.hook("claude")).unwrap(); + std::fs::create_dir_all(env.home.join(".gemini")).unwrap(); + std::fs::write(env.home.join(".gemini/settings.json"), "not json").unwrap(); + env.hooks = Some(setup); + + let text = run_hooks(&env, &args(false, false)).unwrap(); + assert!(text.contains("claude user-level"), "{text}"); + assert!(text.contains("gemini unknown: "), "{text}"); + assert!(text.contains("Timer: none on this platform"), "{text}"); + + let mut timer = TimerSetup { + os: TimerOs::Linux, + home: env.home.clone(), + exe: env.exe.clone(), + user: "u".to_string(), + run: ok, + system_present: absent, + }; + env.timer = Some(timer.clone()); + let text = run_hooks(&env, &args(false, false)).unwrap(); + assert!( + text.contains("`fastskill managed enroll` installs it"), + "{text}" + ); + + timer::install(&timer).unwrap(); + let value: Value = serde_json::from_str(&run_hooks(&env, &args(true, false)).unwrap()).unwrap(); + assert_eq!(value["agents"][0]["user"], true); + assert_eq!(value["timer"]["installed"], true); + let text = run_hooks(&env, &args(false, false)).unwrap(); + assert!( + text.contains("Timer: systemd user timer fastskill-managed-apply.timer"), + "{text}" + ); + + timer.system_present = present; + env.timer = Some(timer); + env.hooks = Some(hook_setup(claude_admin)); + let text = run_hooks(&env, &args(false, false)).unwrap(); + assert!(text.contains("claude administrator-level"), "{text}"); + assert!(text.contains("Timer: machine-wide"), "{text}"); +} + +#[test] +fn an_agent_without_a_settings_file_has_no_hook() { + let dir = TempDir::new().unwrap(); + let mut env = environment(&dir, true, false); + env.hooks = Some(hook_setup(no_admin)); + let text = run_hooks(&env, &args(false, false)).unwrap(); + assert!(text.contains("claude none"), "{text}"); +} + +#[test] +fn system_hooks_print_what_an_administrator_installs() { + let dir = TempDir::new().unwrap(); + let env = environment(&dir, true, false); + let value: Value = serde_json::from_str(&run_hooks(&env, &args(true, true)).unwrap()).unwrap(); + assert_eq!(value["hooks"][0]["agent"], "claude"); + assert_eq!(value["timer"].is_null(), TimerOs::current().is_none()); + // Without a setup, the hooks run this executable. + assert!(value["hooks"][0]["entry"] + .to_string() + .contains("managed apply --hook fastskill-managed-claude")); + + let setup = hook_setup(claude_admin); + for os in [TimerOs::Linux, TimerOs::MacOs, TimerOs::Windows] { + let text = render_system(&system_json(&setup, Some(os), &env.exe)); + assert!(text.contains("claude: merge into "), "{text}"); + assert!(text.contains("(already present)"), "{text}"); + assert!(text.contains("Machine-wide timer:\n"), "{text}"); + // A machine-wide launch agent loads at each user's next login. + assert_eq!(text.contains("then run: "), os != TimerOs::MacOs, "{text}"); + } + let text = render_system(&system_json(&setup, None, &env.exe)); + assert!( + text.ends_with("Machine-wide timer: none on this platform"), + "{text}" + ); + assert!(!run_hooks(&env, &args(false, true)).unwrap().is_empty()); +} diff --git a/crates/fastskill-cli/src/commands/managed_tests.rs b/crates/fastskill-cli/src/commands/managed_tests.rs index d3fb9725..a45eb056 100644 --- a/crates/fastskill-cli/src/commands/managed_tests.rs +++ b/crates/fastskill-cli/src/commands/managed_tests.rs @@ -23,6 +23,10 @@ fn environment(dir: &TempDir, configured: bool, interactive: bool) -> Environmen project_skills: None, interactive, config_dir: dir.path().to_path_buf(), + exe: dir.path().join("fastskill"), + hooks: None, + timer: None, + background: |_| Ok(()), } } @@ -33,7 +37,7 @@ fn human() -> ManagedArgs { fn json_args() -> ManagedArgs { ManagedArgs { json: true, - report: false, + ..ManagedArgs::default() } } @@ -80,6 +84,9 @@ fn outcome(completed: bool) -> ApplyOutcome { } else { vec![("one".to_string(), "digest mismatch".to_string())] }, + hooks: vec!["claude".to_string()], + hook_approvals: vec!["claude: approve it in /hooks".to_string()], + timer: Some("systemd user timer fastskill-managed-apply.timer".to_string()), ..ApplyOutcome::default() } } @@ -106,6 +113,25 @@ fn args_read_the_flags() { 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); + + let mut map = HashMap::new(); + map.insert( + "hook".to_string(), + ArgValue::Str("fastskill-managed-claude".into()), + ); + map.insert("timer".to_string(), ArgValue::Bool(true)); + let args = ApplyArgs::from_arg_value_map(&map).0; + assert_eq!(args.hook.as_deref(), Some("fastskill-managed-claude")); + assert!(args.timer); + let mut map = HashMap::new(); + map.insert("system".to_string(), ArgValue::Bool(true)); + assert!(HooksArgs::from_arg_value_map(&map).0.system); + let names: Vec<_> = HooksArgs::command_spec() + .args + .iter() + .map(|arg| arg.name) + .collect(); + assert_eq!(names, ["json", "system"]); } #[test] @@ -162,6 +188,9 @@ fn a_completed_apply_is_reported() { "quarantined /home/u/.claude/skills/bad", "collision", "clash", + "hooks claude", + "approve claude: approve it in /hooks", + "timer systemd user timer", ] { assert!(text.contains(expected), "{expected} in {text}"); } @@ -245,6 +274,7 @@ fn status_of_an_enrolled_user() { let report = ManagedArgs { json: false, report: true, + ..ManagedArgs::default() }; assert!(run_status(&env, &report).unwrap().contains("deployed")); @@ -255,6 +285,7 @@ fn status_of_an_enrolled_user() { let report = ManagedArgs { json: true, report: true, + ..ManagedArgs::default() }; let value: Value = serde_json::from_str(&run_status(&env, &report).unwrap()).unwrap(); assert_eq!(value["format_version"], 1); @@ -352,6 +383,7 @@ fn status_shows_the_state_and_an_empty_quarantine() { "Subject: team-a", "(expired)", "(completed)", + "hooks claude", "Quarantine: empty", ] { assert!(text.contains(expected), "{expected} in {text}"); @@ -364,7 +396,33 @@ fn unenroll_removes_the_records_and_settings() { let env = environment(&dir, true, true); enroll_record(&env); std::fs::write(env.user_settings_file.as_ref().unwrap(), "").unwrap(); + let hook = managed::hooks::hook_for("fastskill", "claude"); + aikit_sdk::register_session_hook(&env.home, "claude", &hook).unwrap(); + let timer = TimerSetup { + os: managed::TimerOs::Linux, + home: env.home.clone(), + exe: env.exe.clone(), + user: "u".to_string(), + run: |_| Ok(()), + system_present: |_| false, + }; + managed::timer::install(&timer).unwrap(); + std::fs::create_dir_all(env.home.join(".gemini")).unwrap(); + std::fs::write(env.home.join(".gemini/settings.json"), "not json").unwrap(); + let env = Environment { + timer: Some(timer), + ..env + }; let text = run_unenroll(&env, &human()).unwrap(); + assert!( + text.contains("removed the claude session hook"), + "{text}" + ); + assert!(text.contains("removed the timer"), "{text}"); + assert!( + text.contains("warning can't remove the gemini"), + "{text}" + ); assert!(text.starts_with("Unenrolled this user."), "{text}"); assert!(text.contains("managed.toml"), "{text}"); assert!(!env.layout.enrollment_file().exists()); @@ -384,6 +442,9 @@ fn unenroll_lists_removed_and_quarantined_entries() { ..QuarantineRecord::default() }], removed_settings: None, + removed_hooks: vec!["claude".to_string()], + removed_timer: true, + warnings: vec!["can't remove the gemini session hook".to_string()], }; let value = unenroll_json(&outcome); assert_eq!( diff --git a/crates/fastskill-cli/src/commands/mod.rs b/crates/fastskill-cli/src/commands/mod.rs index 48eb8376..926bff52 100644 --- a/crates/fastskill-cli/src/commands/mod.rs +++ b/crates/fastskill-cli/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod init; pub mod install; pub mod list; pub mod managed; +pub mod managed_hooks; 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 ba9439eb..1a69bb17 100644 --- a/crates/fastskill-cli/src/main.rs +++ b/crates/fastskill-cli/src/main.rs @@ -553,40 +553,7 @@ fn build_app(builder: AppBuilder, state: Arc) -> anyhow::Result>(); let expected = CANONICAL_PATHS.iter().copied().collect::>(); assert_eq!(actual, expected); - assert_eq!(actual.len(), 59); + assert_eq!(actual.len(), 60); } #[test] diff --git a/crates/fastskill-cli/tests/managed_cli_test.rs b/crates/fastskill-cli/tests/managed_cli_test.rs index f36dd982..32b72556 100644 --- a/crates/fastskill-cli/tests/managed_cli_test.rs +++ b/crates/fastskill-cli/tests/managed_cli_test.rs @@ -13,6 +13,8 @@ fn run(root: &Path, args: &[&str]) -> Output { .env("HOME", root.join("home")) .env("XDG_CONFIG_HOME", root.join("config")) .env("XDG_DATA_HOME", root.join("data")) + // Keep enrolling from touching this machine's service manager. + .env("FASTSKILL_MANAGED_TIMER", "off") .output() .unwrap() } @@ -76,3 +78,28 @@ fn with_user_settings_enroll_status_and_unenroll() { assert!(value["removed_settings"].as_str().is_some(), "{value}"); assert!(!settings.exists()); } + +#[test] +fn a_hook_without_settings_prints_nothing_and_succeeds() { + let root = TempDir::new().unwrap(); + let hook = run( + root.path(), + &["managed", "apply", "--hook", "fastskill-managed-claude"], + ); + assert!(hook.status.success(), "{}", text(&hook.stderr)); + assert!(hook.stdout.is_empty(), "{}", text(&hook.stdout)); +} + +#[test] +fn hooks_list_this_user_and_the_administrator_entries() { + let root = TempDir::new().unwrap(); + let user = run(root.path(), &["managed", "hooks"]); + assert!(user.status.success(), "{}", text(&user.stderr)); + assert!(text(&user.stdout).contains("Session-start hooks:")); + assert!(text(&user.stdout).contains("Timer: none on this platform")); + + let system = run(root.path(), &["managed", "hooks", "--system", "--json"]); + assert!(system.status.success(), "{}", text(&system.stderr)); + let value: serde_json::Value = serde_json::from_slice(&system.stdout).unwrap(); + assert_eq!(value["hooks"][0]["agent"], "claude"); +} diff --git a/crates/fastskill-core/src/core/managed/apply.rs b/crates/fastskill-core/src/core/managed/apply.rs index 86ef8b65..fdb42e2d 100644 --- a/crates/fastskill-core/src/core/managed/apply.rs +++ b/crates/fastskill-core/src/core/managed/apply.rs @@ -10,7 +10,9 @@ //! 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, and sends the report when the +//! 5. registers the session-start hooks of the covered agents, and the timer when enrolling +//! (decision 16); +//! 6. records ownership, the accepted state and what happened, and sends the report when the //! state names one on an `https://` source's origin. //! //! An `https://` source is fetched with the token its credential command prints (decision 6). @@ -18,6 +20,7 @@ //! needed. Apply makes blocking requests: call it from a plain thread. use super::config::{ManagedSettings, ManagedSource}; +use super::hooks::HookSetup; use super::layout::{read_record, write_record, ManagedLayout}; use super::quarantine::{quarantine, remove_entry, QuarantineReason, QuarantineRecord}; use super::records::{Enrollment, EntryMode, OwnedEntry, Ownership}; @@ -25,6 +28,7 @@ use super::remote::{run_credential_command, Remote, RemoteError}; use super::report::{ReportDelivery, ReportSnapshot}; use super::state::{Allowed, ManagedState}; use super::store::ManagedStore; +use super::timer::TimerSetup; use super::OpenedState; use crate::core::content_digest::content_digest; use crate::core::service::ServiceError; @@ -48,6 +52,10 @@ pub struct ApplyContext { pub interactive: bool, /// FastSkill's configuration folder, where the credential command runs. pub config_dir: PathBuf, + /// The session-start hooks to register; `None` registers none. + pub hooks: Option, + /// The timer to install when enrolling; `None` installs none. + pub timer: Option, } /// Whether an apply ran. @@ -104,10 +112,27 @@ pub struct ApplyOutcome { /// What the report is built from. #[serde(default)] pub snapshot: ReportSnapshot, + /// Covered agents with a session-start hook. + #[serde(default)] + pub hooks: Vec, + /// How to approve hooks just added, for agents that ask first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hook_approvals: Vec, + /// The timer installed by this apply, when it enrolled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timer: Option, } /// Apply the configured managed state. pub fn apply(context: &ApplyContext) -> Result { + run_apply(context, false) +} + +/// Apply, installing the timer when `enrolling` or when this apply enrolls. +pub(crate) fn run_apply( + context: &ApplyContext, + enrolling: bool, +) -> Result { let source = context .settings .source @@ -155,6 +180,12 @@ pub fn apply(context: &ApplyContext) -> Result { outcome .warnings .extend(super::report::state_warnings(state, &source)); + register( + context, + &covered, + enrolling || outcome.enrolled_now, + &mut outcome, + ); let mut ownership: Ownership = read_record(&layout.ownership_file())?; let mut run = Run { @@ -197,6 +228,33 @@ pub fn apply(context: &ApplyContext) -> Result { Ok(ApplyResult::Done(Box::new(outcome))) } +/// Register the covered agents' hooks, and the timer when `with_timer`. A failure is a warning. +fn register( + context: &ApplyContext, + covered: &[(PathBuf, Vec<&'static str>)], + with_timer: bool, + outcome: &mut ApplyOutcome, +) { + if let Some(setup) = &context.hooks { + let agents: Vec<&str> = covered + .iter() + .flat_map(|(_, agents)| agents.iter().copied()) + .collect(); + let sync = super::hooks::sync(&context.home, setup, &agents); + outcome.hooks = sync.hooked; + outcome.hook_approvals = sync.approvals; + outcome.warnings.extend(sync.warnings); + } + if let (true, Some(timer)) = (with_timer, &context.timer) { + match super::timer::install(timer) { + Ok(installed) => outcome.timer = installed, + Err(error) => outcome + .warnings + .push(format!("can't install the timer: {error}")), + } + } +} + /// The https client for this run, with the credential command's token when one is configured. /// A failed command leaves no token and marks sign-in as needed. fn connect( diff --git a/crates/fastskill-core/src/core/managed/apply_tests.rs b/crates/fastskill-core/src/core/managed/apply_tests.rs index d1edffe1..9737323f 100644 --- a/crates/fastskill-core/src/core/managed/apply_tests.rs +++ b/crates/fastskill-core/src/core/managed/apply_tests.rs @@ -69,6 +69,8 @@ impl Fixture { now: now(), interactive: false, config_dir: self.root.clone(), + hooks: None, + timer: None, } } @@ -197,7 +199,15 @@ fn a_second_apply_waits_for_none_while_one_runs() { 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(); + let error = unenroll( + &fixture.layout, + &fixture.settings(), + None, + &fixture.home(), + None, + now(), + ) + .unwrap_err(); assert!(error.to_string().contains("apply is running"), "{error}"); } @@ -514,6 +524,8 @@ fn unenrolling_removes_everything_but_the_quarantine() { &fixture.layout, &fixture.settings(), Some(&user_file), + &fixture.home(), + None, now(), ) .unwrap(); @@ -526,7 +538,15 @@ fn unenrolling_removes_everything_but_the_quarantine() { let mut required = fixture.settings(); required.required_by_system = true; - let error = unenroll(&fixture.layout, &required, None, now()).unwrap_err(); + let error = unenroll( + &fixture.layout, + &required, + None, + &fixture.home(), + None, + now(), + ) + .unwrap_err(); assert!(error.to_string().contains("can't unenroll"), "{error}"); } diff --git a/crates/fastskill-core/src/core/managed/enrollment.rs b/crates/fastskill-core/src/core/managed/enrollment.rs index b1faf75e..3972d9bd 100644 --- a/crates/fastskill-core/src/core/managed/enrollment.rs +++ b/crates/fastskill-core/src/core/managed/enrollment.rs @@ -1,18 +1,21 @@ //! 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::apply::{entry_digest, run_apply, ApplyContext, ApplyOutcome, ApplyResult}; use super::config::{ManagedSettings, ManagedSource}; +use super::hooks; 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::report::{self, Report, ReportInput}; use super::state::{ManagedState, Recorded}; +use super::timer::TimerSetup; 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. +/// Enroll this user with the configured source, then apply its first state, registering the +/// session-start hooks and the timer. /// /// 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. @@ -39,7 +42,7 @@ pub fn enroll(context: &ApplyContext) -> Result { } write_record(&layout.enrollment_file(), &enrollment)?; } - apply(context) + run_apply(context, true) } /// What unenrolling did. @@ -49,17 +52,26 @@ pub struct UnenrollOutcome { pub quarantined: Vec, /// The user managed settings file, when it was removed. pub removed_settings: Option, + /// Agents whose session-start hook was removed. + pub removed_hooks: Vec, + /// The timer was there and was removed. + pub removed_timer: bool, + /// Hooks or the timer that couldn't be removed, with why. + pub warnings: Vec, } /// 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. +/// machine id, the session-start hooks, the timer 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>, + home: &Path, + timer: Option<&TimerSetup>, now: DateTime, ) -> Result { if settings.required_by_system { @@ -93,6 +105,15 @@ pub fn unenroll( std::fs::remove_file(file)?; outcome.removed_settings = Some(file.to_path_buf()); } + (outcome.removed_hooks, outcome.warnings) = hooks::remove_all(home); + if let Some(timer) = timer { + match super::timer::remove(timer) { + Ok(removed) => outcome.removed_timer = removed, + Err(error) => outcome + .warnings + .push(format!("can't remove the timer: {error}")), + } + } Ok(outcome) } diff --git a/crates/fastskill-core/src/core/managed/hooks.rs b/crates/fastskill-core/src/core/managed/hooks.rs new file mode 100644 index 00000000..f4eef3b5 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/hooks.rs @@ -0,0 +1,154 @@ +//! Session-start hooks for the covered agents (ADR-0016 decisions 16 and 17). +//! +//! Each covered agent that can run a command when a session starts gets a user-level hook +//! running `managed apply --hook `, where `` is [`hook_id`] for that agent. Every apply +//! registers hooks for newly covered agents and removes those of agents no longer covered. An +//! agent whose administrator-level settings already hold FastSkill's hook gets no user-level +//! one. `unenroll` removes them all. + +use aikit_sdk::{HookChange, SessionHook}; +use std::path::Path; + +/// The prefix of every hook id; the agent key follows it. +pub const HOOK_ID_PREFIX: &str = "fastskill-managed-"; + +/// How to register hooks for one apply. +#[derive(Debug, Clone)] +pub struct HookSetup { + /// The program part of the hook command, already quoted for the agent's shell. + pub program: String, + /// Whether the administrator-level settings of agent `key` hold the hook with id `id`. + pub admin_present: fn(&str, &str) -> bool, +} + +impl HookSetup { + /// Hooks that run `program`, checking the real administrator-level settings. + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + admin_present: aikit_sdk::admin_hook_present, + } + } + + /// The hook for agent `key`. + pub fn hook(&self, key: &str) -> SessionHook { + hook_for(&self.program, key) + } +} + +/// The hook id for agent `key`. +pub fn hook_id(key: &str) -> String { + format!("{HOOK_ID_PREFIX}{key}") +} + +/// The agent key a hook id names, when it is one of FastSkill's. +pub fn agent_of(id: &str) -> Option<&str> { + id.strip_prefix(HOOK_ID_PREFIX) + .filter(|key| !key.is_empty()) +} + +/// The hook running `program` for agent `key`. +pub fn hook_for(program: &str, key: &str) -> SessionHook { + let id = hook_id(key); + SessionHook { + command: format!("{program} managed apply --hook {id}"), + id, + } +} + +/// Quote `path` as the program of a hook command. +pub fn quote_program(path: &Path) -> String { + format!("\"{}\"", path.display().to_string().replace('"', "\\\"")) +} + +/// What registering hooks did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HookSync { + /// Covered agents with a hook, user- or administrator-level. + pub hooked: Vec, + /// Notes for hooks just added to an agent that needs the user to approve them. + pub approvals: Vec, + /// Hooks that couldn't be registered or removed, with why. + pub warnings: Vec, +} + +/// Register the hook for every covered agent that supports one, and remove FastSkill's +/// user-level hook from every other supported agent. +pub fn sync(home: &Path, setup: &HookSetup, covered: &[&str]) -> HookSync { + let mut sync = HookSync::default(); + for key in aikit_sdk::session_hook_agents() { + let id = hook_id(key); + let admin = covered.contains(&key) && (setup.admin_present)(key, &id); + if covered.contains(&key) && !admin { + match aikit_sdk::register_session_hook(home, key, &setup.hook(key)) { + Ok(change) => { + sync.hooked.push(key.to_string()); + if change == HookChange::Added { + if let Some(note) = aikit_sdk::session_hook_support(key) + .and_then(|support| support.approval_note) + { + sync.approvals.push(format!("{key}: {note}")); + } + } + } + Err(error) => sync + .warnings + .push(format!("can't register the {key} session hook: {error}")), + } + continue; + } + if admin { + sync.hooked.push(key.to_string()); + } + if let Err(error) = aikit_sdk::unregister_session_hook(home, key, &id) { + sync.warnings + .push(format!("can't remove the {key} session hook: {error}")); + } + } + sync +} + +/// Remove FastSkill's user-level hook from every supported agent; the agents it was removed +/// from, and why any couldn't be. +pub fn remove_all(home: &Path) -> (Vec, Vec) { + let mut removed = Vec::new(); + let mut warnings = Vec::new(); + for key in aikit_sdk::session_hook_agents() { + match aikit_sdk::unregister_session_hook(home, key, &hook_id(key)) { + Ok(HookChange::Removed) => removed.push(key.to_string()), + Ok(_) => {} + Err(error) => warnings.push(format!("can't remove the {key} session hook: {error}")), + } + } + (removed, warnings) +} + +/// One agent's administrator-level hook: where it goes and what to merge there. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct AdminHook { + pub agent: String, + pub file: Option, + pub entry: serde_json::Value, + pub present: bool, +} + +/// The administrator-level hook entry for every supported agent, writing nothing. +pub fn admin_hooks(setup: &HookSetup) -> Vec { + aikit_sdk::session_hook_agents() + .into_iter() + .filter_map(|key| { + let hook = setup.hook(key); + let entry = aikit_sdk::admin_hook_entry(key, &hook).ok()?; + Some(AdminHook { + agent: key.to_string(), + file: aikit_sdk::admin_hook_file(key), + present: (setup.admin_present)(key, &hook.id), + entry, + }) + }) + .collect() +} + +#[cfg(test)] +#[path = "hooks_tests.rs"] +mod tests; diff --git a/crates/fastskill-core/src/core/managed/hooks_tests.rs b/crates/fastskill-core/src/core/managed/hooks_tests.rs new file mode 100644 index 00000000..b9a38780 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/hooks_tests.rs @@ -0,0 +1,220 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +fn setup(admin_present: fn(&str, &str) -> bool) -> HookSetup { + HookSetup { + program: quote_program(Path::new("/opt/fast skill/fastskill")), + admin_present, + } +} + +fn no_admin(_: &str, _: &str) -> bool { + false +} + +fn claude_admin(key: &str, id: &str) -> bool { + key == "claude" && id == "fastskill-managed-claude" +} + +fn has(home: &Path, key: &str) -> bool { + aikit_sdk::has_session_hook(home, key, &hook_id(key)).unwrap() +} + +#[test] +fn a_hook_names_its_agent_and_runs_apply_in_hook_mode() { + let hook = setup(no_admin).hook("claude"); + assert_eq!(hook.id, "fastskill-managed-claude"); + assert_eq!( + hook.command, + "\"/opt/fast skill/fastskill\" managed apply --hook fastskill-managed-claude" + ); + assert_eq!(agent_of("fastskill-managed-gemini"), Some("gemini")); + assert_eq!(agent_of("fastskill-managed-"), None); + assert_eq!(agent_of("other"), None); + assert_eq!(quote_program(Path::new("a\"b")), "\"a\\\"b\""); + assert_eq!(HookSetup::new("x").program, "x"); +} + +#[test] +fn sync_registers_covered_agents_and_removes_the_others() { + let home = tempfile::tempdir().unwrap(); + let setup = setup(no_admin); + + let first = sync(home.path(), &setup, &["claude", "gemini", "codex"]); + assert_eq!(first.hooked, vec!["claude", "gemini"]); + assert!(first.warnings.is_empty(), "{:?}", first.warnings); + assert_eq!(first.approvals.len(), 1); + assert!(first.approvals[0].starts_with("claude: ")); + assert!(has(home.path(), "claude") && has(home.path(), "gemini")); + + let again = sync(home.path(), &setup, &["claude", "gemini"]); + assert_eq!(again.hooked, vec!["claude", "gemini"]); + assert!(again.approvals.is_empty()); + + let fewer = sync(home.path(), &setup, &["claude"]); + assert_eq!(fewer.hooked, vec!["claude"]); + assert!(has(home.path(), "claude")); + assert!(!has(home.path(), "gemini")); +} + +#[test] +fn an_administrator_hook_replaces_the_user_one() { + let home = tempfile::tempdir().unwrap(); + sync(home.path(), &setup(no_admin), &["claude"]); + assert!(has(home.path(), "claude")); + + let synced = sync(home.path(), &setup(claude_admin), &["claude"]); + assert_eq!(synced.hooked, vec!["claude"]); + assert!(!has(home.path(), "claude")); +} + +#[test] +fn a_settings_file_that_cannot_be_changed_is_a_warning() { + let home = tempfile::tempdir().unwrap(); + for folder in [".claude", ".gemini"] { + std::fs::create_dir_all(home.path().join(folder)).unwrap(); + std::fs::write(home.path().join(folder).join("settings.json"), "not json").unwrap(); + } + let synced = sync(home.path(), &setup(no_admin), &["claude"]); + assert!(synced.hooked.is_empty()); + assert_eq!(synced.warnings.len(), 2, "{:?}", synced.warnings); + assert!(synced.warnings[0].contains("can't register the claude")); + assert!(synced.warnings[1].contains("can't remove the gemini")); + + let (removed, warnings) = remove_all(home.path()); + assert!(removed.is_empty()); + assert_eq!(warnings.len(), 2); +} + +#[test] +fn remove_all_removes_every_user_hook() { + let home = tempfile::tempdir().unwrap(); + sync(home.path(), &setup(no_admin), &["claude", "gemini"]); + let (removed, warnings) = remove_all(home.path()); + assert_eq!(removed, vec!["claude", "gemini"]); + assert!(warnings.is_empty()); + assert!(!has(home.path(), "claude") && !has(home.path(), "gemini")); + assert_eq!(remove_all(home.path()).0, Vec::::new()); +} + +#[test] +fn admin_hooks_give_each_agent_its_entry_without_writing() { + let hooks = admin_hooks(&setup(claude_admin)); + let agents: Vec<&str> = hooks.iter().map(|hook| hook.agent.as_str()).collect(); + assert_eq!(agents, vec!["claude", "gemini"]); + assert!(hooks[0].present && !hooks[1].present); + let text = hooks[1].entry.to_string(); + assert!(text.contains("SessionStart"), "{text}"); + assert!(text.contains("--hook fastskill-managed-gemini"), "{text}"); +} + +mod with_apply { + use super::super::super::apply::ApplyResult; + use super::super::super::apply_tests::{now, Fixture}; + use super::super::super::enrollment::{enroll, unenroll}; + use super::super::super::timer::{TimerOs, TimerSetup}; + use super::*; + use serde_json::json; + + fn ok(_: &[String]) -> Result<(), String> { + Ok(()) + } + + fn broken(_: &[String]) -> Result<(), String> { + Err("no systemd".to_string()) + } + + fn absent(_: TimerOs) -> bool { + false + } + + fn timer(fixture: &Fixture, run: fn(&[String]) -> Result<(), String>) -> TimerSetup { + TimerSetup { + os: TimerOs::Linux, + home: fixture.home(), + exe: "/opt/fastskill".into(), + user: "u".to_string(), + run, + system_present: absent, + } + } + + #[test] + fn enrolling_registers_hooks_and_the_timer_and_unenroll_removes_them() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + let mut context = fixture.context(); + context.hooks = Some(setup(no_admin)); + context.timer = Some(timer(&fixture, ok)); + + let first = fixture.apply_with(&context); + assert!(first.enrolled_now && first.completed, "{first:?}"); + assert_eq!(first.hooks, vec!["claude"]); + assert_eq!(first.hook_approvals.len(), 1); + assert_eq!( + first.timer.as_deref(), + Some("systemd user timer fastskill-managed-apply.timer") + ); + assert!(first.snapshot.agents.iter().all(|agent| agent.hook)); + assert!(has(&fixture.home(), "claude")); + + let again = fixture.apply_with(&context); + assert_eq!(again.hooks, vec!["claude"]); + assert!(again.timer.is_none() && again.hook_approvals.is_empty()); + + let ApplyResult::Done(enrolled) = enroll(&context).unwrap() else { + panic!("busy") + }; + assert!(enrolled.timer.is_some()); + + let removed = unenroll( + &fixture.layout, + &fixture.settings(), + None, + &fixture.home(), + context.timer.as_ref(), + now(), + ) + .unwrap(); + assert_eq!(removed.removed_hooks, vec!["claude"]); + assert!(removed.removed_timer && removed.warnings.is_empty()); + assert!(!has(&fixture.home(), "claude")); + } + + #[test] + fn a_timer_that_cannot_be_installed_or_removed_is_a_warning() { + let mut fixture = Fixture::new(); + let pdf = fixture.skill("pdf", "one"); + fixture.publish(&[pdf], json!({})); + let mut context = fixture.context(); + context.timer = Some(timer(&fixture, broken)); + let outcome = fixture.apply_with(&context); + assert!(outcome.timer.is_none() && outcome.hooks.is_empty()); + assert!( + outcome + .warnings + .iter() + .any(|warning| warning.contains("can't install the timer")), + "{:?}", + outcome.warnings + ); + assert!(!outcome.snapshot.agents.iter().any(|agent| agent.hook)); + + let units = fixture.home().join(".config/systemd/user"); + std::fs::remove_dir_all(&units).unwrap(); + std::fs::create_dir_all(units.join("fastskill-managed-apply.timer/x")).unwrap(); + let removed = unenroll( + &fixture.layout, + &fixture.settings(), + None, + &fixture.home(), + context.timer.as_ref(), + now(), + ) + .unwrap(); + assert!(!removed.removed_timer); + assert!(removed.warnings[0].contains("can't remove the timer")); + } +} diff --git a/crates/fastskill-core/src/core/managed/mod.rs b/crates/fastskill-core/src/core/managed/mod.rs index 319ff54a..7423a3dc 100644 --- a/crates/fastskill-core/src/core/managed/mod.rs +++ b/crates/fastskill-core/src/core/managed/mod.rs @@ -23,6 +23,9 @@ //! command's token, and [`report`]: the fixed report an apply sends (decisions 5, 6, 15 and //! 21). //! +//! - [`hooks`] and [`timer`]: the session-start hooks and the per-user timer that keep the +//! targets current (decision 16). +//! //! [`open`] is the one entry point that turns envelope bytes into a state FastSkill may use. pub mod apply; @@ -30,6 +33,7 @@ pub mod config; pub mod enrollment; pub mod envelope; pub mod gate; +pub mod hooks; pub mod layout; pub mod quarantine; pub mod records; @@ -39,6 +43,7 @@ pub mod state; pub mod store; #[cfg(test)] mod test_https; +pub mod timer; #[cfg(test)] mod apply_tests; @@ -50,6 +55,7 @@ pub use config::{ManagedSettings, ManagedSource, PinnedKey, SYSTEM_FILE_NAME, US pub use enrollment::{enroll, status, unenroll, ManagedStatus, UnenrollOutcome}; pub use envelope::{verify_envelope, Verified, PAYLOAD_TYPE}; pub use gate::{Candidate, CommandPolicy, ManagedGate, ManagedSkillStatus, Situation}; +pub use hooks::HookSetup; pub use layout::{ApplyLock, ManagedLayout}; pub use quarantine::{QuarantineReason, QuarantineRecord}; pub use records::{Enrollment, EntryMode, OwnedEntry, Ownership}; @@ -58,6 +64,7 @@ pub use state::{ Allowed, BlockedDigest, Editable, ManagedState, Recorded, StateSkill, FORMAT_VERSION, MAX_CLOCK_SKEW, }; +pub use timer::{TimerOs, TimerSetup}; use chrono::{DateTime, Utc}; diff --git a/crates/fastskill-core/src/core/managed/report.rs b/crates/fastskill-core/src/core/managed/report.rs index 018efbfb..a4bec55b 100644 --- a/crates/fastskill-core/src/core/managed/report.rs +++ b/crates/fastskill-core/src/core/managed/report.rs @@ -275,8 +275,8 @@ pub(crate) fn snapshot( agents: names .into_iter() .map(|name| ReportAgent { + hook: outcome.hooks.iter().any(|hooked| hooked == name), name: name.to_string(), - hook: false, }) .collect(), skills, diff --git a/crates/fastskill-core/src/core/managed/timer.rs b/crates/fastskill-core/src/core/managed/timer.rs new file mode 100644 index 00000000..29917356 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/timer.rs @@ -0,0 +1,492 @@ +//! The per-user timer that runs `managed apply` at login and then hourly, each run shifted by +//! up to 10 minutes at random (ADR-0016 decision 16). +//! +//! - Linux: a systemd user timer, `~/.config/systemd/user/fastskill-managed-apply.timer`. +//! - macOS: a launchd agent, `~/Library/LaunchAgents/dev.fastskill.managed-apply.plist`. launchd +//! can't shift runs, so it runs `managed apply --timer`, which waits first. +//! - Windows: a scheduled task, `\FastSkill\managed-apply-`. +//! +//! What to write and run is planned by pure functions, so every platform's plan is testable +//! anywhere; [`install`] and [`remove`] carry a plan out with an injectable command runner. +//! When a machine-wide timer is already installed, no per-user one is. + +use std::path::{Path, PathBuf}; + +/// The systemd unit name, without its suffix. +pub const UNIT_NAME: &str = "fastskill-managed-apply"; +/// The launchd label. +pub const LAUNCHD_LABEL: &str = "dev.fastskill.managed-apply"; +/// The scheduled task folder. +pub const TASK_FOLDER: &str = r"\FastSkill\"; +/// The most a run is shifted, in seconds. +pub const MAX_DELAY_SECS: u64 = 600; + +/// The platforms with a timer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TimerOs { + Linux, + MacOs, + Windows, +} + +impl TimerOs { + /// This platform, when it has a timer. + pub fn current() -> Option { + if cfg!(target_os = "linux") { + Some(Self::Linux) + } else if cfg!(target_os = "macos") { + Some(Self::MacOs) + } else if cfg!(windows) { + Some(Self::Windows) + } else { + None + } + } +} + +/// A file a plan writes. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct TimerFile { + pub path: PathBuf, + pub contents: String, +} + +/// A command a plan runs; a command that isn't `required` may fail. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct TimerCommand { + pub args: Vec, + pub required: bool, +} + +/// What installing or removing a timer writes, removes and runs, in that order. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct TimerPlan { + pub write: Vec, + pub remove: Vec, + pub commands: Vec, +} + +/// Runs one command; `Err` says why it failed. +pub type CommandRunner = fn(&[String]) -> Result<(), String>; + +/// How to install or remove this user's timer. +#[derive(Debug, Clone)] +pub struct TimerSetup { + pub os: TimerOs, + pub home: PathBuf, + /// The FastSkill executable the timer runs. + pub exe: PathBuf, + /// The user name, which keeps Windows task names apart. + pub user: String, + pub run: CommandRunner, + /// Whether a machine-wide timer is installed. + pub system_present: fn(TimerOs) -> bool, +} + +impl TimerSetup { + /// This user's timer on this platform, run with real commands. + pub fn current(home: &Path, exe: &Path) -> Option { + Some(Self { + os: TimerOs::current()?, + home: home.to_path_buf(), + exe: exe.to_path_buf(), + user: std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .unwrap_or_else(|_| "user".to_string()), + run: run_command, + system_present: system_timer_present, + }) + } +} + +fn args(items: &[&str]) -> Vec { + items.iter().map(|item| item.to_string()).collect() +} + +fn required(items: &[&str]) -> TimerCommand { + TimerCommand { + args: args(items), + required: true, + } +} + +fn optional(items: &[&str]) -> TimerCommand { + TimerCommand { + args: args(items), + required: false, + } +} + +fn quoted(path: &Path) -> String { + format!("\"{}\"", path.display().to_string().replace('"', "\\\"")) +} + +fn systemd_service(exe: &Path) -> String { + format!( + "[Unit]\nDescription=Apply the FastSkill managed state\n\n[Service]\nType=oneshot\n\ + ExecStart={} managed apply\n", + quoted(exe) + ) +} + +fn systemd_timer(install_target: &str) -> String { + format!( + "[Unit]\nDescription=Apply the FastSkill managed state at login and hourly\n\n\ + [Timer]\nOnStartupSec=2min\nOnActiveSec=2min\nOnUnitActiveSec=1h\n\ + RandomizedDelaySec={}\n\n[Install]\nWantedBy={install_target}\n", + MAX_DELAY_SECS + ) +} + +fn xml_escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn launchd_plist(exe: &Path) -> String { + format!( + "\n\ + \n\ + \n\n\ + \tLabel\n\t{LAUNCHD_LABEL}\n\ + \tProgramArguments\n\t\n\ + \t\t{}\n\t\tmanaged\n\t\tapply\n\ + \t\t--timer\n\t\n\ + \tRunAtLoad\n\t\n\ + \tStartInterval\n\t3600\n\ + \tProcessType\n\tBackground\n\ + \n\n", + xml_escape(&exe.display().to_string()) + ) +} + +/// The task definition: at logon of `user` (or any user when `None`) and hourly, each run +/// delayed by up to [`MAX_DELAY_SECS`]. +fn task_xml(exe: &Path, user: Option<&str>) -> String { + let delay = format!("PT{}M", MAX_DELAY_SECS / 60); + let (logon_user, principal) = match user { + Some(user) => ( + format!("{}", xml_escape(user)), + format!( + "{}InteractiveToken\ + LeastPrivilege", + xml_escape(user) + ), + ), + None => ( + String::new(), + "S-1-5-32-545\ + LeastPrivilege" + .to_string(), + ), + }; + format!( + "\n\ + \n\ + Apply the FastSkill managed state at logon and hourly\ + \n\ + \n\ + true{logon_user}{delay}\n\ + true2000-01-01T00:00:00\ + PT1H{delay}\ + \n\ + \n\ + {principal}\n\ + IgnoreNew\ + false\ + false\ + truetrue\n\ + {}managed apply\ + \n\ + \n", + xml_escape(&exe.display().to_string()) + ) +} + +fn task_name(user: &str) -> String { + let user: String = user + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + format!("{TASK_FOLDER}managed-apply-{user}") +} + +fn systemd_user_dir(home: &Path) -> PathBuf { + home.join(".config/systemd/user") +} + +fn plist_path(home: &Path) -> PathBuf { + home.join("Library/LaunchAgents") + .join(format!("{LAUNCHD_LABEL}.plist")) +} + +fn task_file(home: &Path) -> PathBuf { + home.join("AppData/Local/fastskill/managed-apply-task.xml") +} + +/// What installs this user's timer. +pub fn install_plan(setup: &TimerSetup) -> TimerPlan { + let home = &setup.home; + match setup.os { + TimerOs::Linux => { + let dir = systemd_user_dir(home); + let timer = format!("{UNIT_NAME}.timer"); + TimerPlan { + write: vec![ + TimerFile { + path: dir.join(format!("{UNIT_NAME}.service")), + contents: systemd_service(&setup.exe), + }, + TimerFile { + path: dir.join(&timer), + contents: systemd_timer("timers.target"), + }, + ], + remove: Vec::new(), + commands: vec![ + required(&["systemctl", "--user", "daemon-reload"]), + required(&["systemctl", "--user", "enable", "--now", &timer]), + ], + } + } + TimerOs::MacOs => { + let plist = plist_path(home).display().to_string(); + TimerPlan { + write: vec![TimerFile { + path: plist_path(home), + contents: launchd_plist(&setup.exe), + }], + remove: Vec::new(), + commands: vec![ + optional(&["launchctl", "unload", &plist]), + required(&["launchctl", "load", "-w", &plist]), + ], + } + } + TimerOs::Windows => { + let file = task_file(home); + let path = file.display().to_string(); + TimerPlan { + write: vec![TimerFile { + path: file, + contents: task_xml(&setup.exe, Some(&setup.user)), + }], + remove: Vec::new(), + commands: vec![required(&[ + "schtasks", + "/Create", + "/TN", + &task_name(&setup.user), + "/XML", + &path, + "/F", + ])], + } + } + } +} + +/// What removes this user's timer. +pub fn remove_plan(setup: &TimerSetup) -> TimerPlan { + let home = &setup.home; + match setup.os { + TimerOs::Linux => { + let dir = systemd_user_dir(home); + let timer = format!("{UNIT_NAME}.timer"); + TimerPlan { + write: Vec::new(), + remove: vec![dir.join(&timer), dir.join(format!("{UNIT_NAME}.service"))], + commands: vec![ + optional(&["systemctl", "--user", "disable", "--now", &timer]), + optional(&["systemctl", "--user", "daemon-reload"]), + ], + } + } + TimerOs::MacOs => TimerPlan { + write: Vec::new(), + remove: vec![plist_path(home)], + commands: vec![optional(&[ + "launchctl", + "unload", + "-w", + &plist_path(home).display().to_string(), + ])], + }, + TimerOs::Windows => TimerPlan { + write: Vec::new(), + remove: vec![task_file(home)], + commands: vec![optional(&[ + "schtasks", + "/Delete", + "/TN", + &task_name(&setup.user), + "/F", + ])], + }, + } +} + +/// The machine-wide timer an administrator installs for every user: the files to place and +/// the commands to run. Nothing is written. +pub fn system_plan(os: TimerOs, exe: &Path) -> TimerPlan { + match os { + TimerOs::Linux => { + let dir = PathBuf::from("/etc/systemd/user"); + let timer = format!("{UNIT_NAME}.timer"); + TimerPlan { + write: vec![ + TimerFile { + path: dir.join(format!("{UNIT_NAME}.service")), + contents: systemd_service(exe), + }, + TimerFile { + path: dir.join(&timer), + contents: systemd_timer("timers.target"), + }, + ], + remove: Vec::new(), + commands: vec![required(&["systemctl", "--global", "enable", &timer])], + } + } + TimerOs::MacOs => TimerPlan { + write: vec![TimerFile { + path: PathBuf::from(format!("/Library/LaunchAgents/{LAUNCHD_LABEL}.plist")), + contents: launchd_plist(exe), + }], + remove: Vec::new(), + commands: Vec::new(), + }, + TimerOs::Windows => { + let file = PathBuf::from(r"C:\ProgramData\fastskill\managed-apply-task.xml"); + let path = file.display().to_string(); + TimerPlan { + write: vec![TimerFile { + path: file, + contents: task_xml(exe, None), + }], + remove: Vec::new(), + commands: vec![required(&[ + "schtasks", + "/Create", + "/TN", + &format!("{TASK_FOLDER}managed-apply"), + "/XML", + &path, + "/F", + ])], + } + } + } +} + +/// Whether the machine-wide timer is installed. Windows can't tell without querying the task +/// scheduler, so it answers `false`. +pub fn system_timer_present(os: TimerOs) -> bool { + match os { + TimerOs::Windows => false, + _ => system_plan(os, Path::new("fastskill")) + .write + .iter() + .any(|file| file.path.is_file()), + } +} + +/// Install this user's timer: `Ok(Some(what))` when it was, `Ok(None)` when a machine-wide one +/// is installed instead. +pub fn install(setup: &TimerSetup) -> Result, String> { + if (setup.system_present)(setup.os) { + return Ok(None); + } + carry_out(setup, &install_plan(setup))?; + Ok(Some(describe(setup))) +} + +/// Remove this user's timer; `true` when anything was there. +pub fn remove(setup: &TimerSetup) -> Result { + let plan = remove_plan(setup); + let present = plan.remove.iter().any(|path| path.exists()); + carry_out(setup, &plan)?; + Ok(present) +} + +/// What the timer is called on this platform. +pub fn describe(setup: &TimerSetup) -> String { + match setup.os { + TimerOs::Linux => format!("systemd user timer {UNIT_NAME}.timer"), + TimerOs::MacOs => format!("launchd agent {LAUNCHD_LABEL}"), + TimerOs::Windows => format!("scheduled task {}", task_name(&setup.user)), + } +} + +fn carry_out(setup: &TimerSetup, plan: &TimerPlan) -> Result<(), String> { + for file in &plan.write { + if let Some(parent) = file.path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("can't create {}: {error}", parent.display()))?; + } + let bytes = if setup.os == TimerOs::Windows { + utf16_with_bom(&file.contents) + } else { + file.contents.clone().into_bytes() + }; + std::fs::write(&file.path, bytes) + .map_err(|error| format!("can't write {}: {error}", file.path.display()))?; + } + for path in &plan.remove { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("can't remove {}: {error}", path.display())), + } + } + for command in &plan.commands { + if let Err(error) = (setup.run)(&command.args) { + if command.required { + return Err(format!("`{}` failed: {error}", command.args.join(" "))); + } + } + } + Ok(()) +} + +/// The task scheduler reads task definitions as UTF-16. +fn utf16_with_bom(text: &str) -> Vec { + let mut bytes = vec![0xFF, 0xFE]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + bytes +} + +/// Run a command without a terminal and wait for it. +pub fn run_command(args: &[String]) -> Result<(), String> { + let Some((program, rest)) = args.split_first() else { + return Err("empty command".to_string()); + }; + let status = std::process::Command::new(program) + .args(rest) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|error| format!("couldn't start: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("exited with {status}")) + } +} + +/// A random delay of up to [`MAX_DELAY_SECS`], for a timer that can't shift runs itself. +pub fn random_delay() -> std::time::Duration { + let random = uuid::Uuid::new_v4().as_u128() as u64; + std::time::Duration::from_secs(random % (MAX_DELAY_SECS + 1)) +} + +#[cfg(test)] +#[path = "timer_tests.rs"] +mod tests; diff --git a/crates/fastskill-core/src/core/managed/timer_tests.rs b/crates/fastskill-core/src/core/managed/timer_tests.rs new file mode 100644 index 00000000..6012c605 --- /dev/null +++ b/crates/fastskill-core/src/core/managed/timer_tests.rs @@ -0,0 +1,211 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use std::sync::Mutex; + +static RAN: Mutex> = Mutex::new(Vec::new()); + +fn record(args: &[String]) -> Result<(), String> { + RAN.lock().unwrap().push(args.join(" ")); + Ok(()) +} + +fn fail(args: &[String]) -> Result<(), String> { + Err(format!("no {}", args[0])) +} + +fn absent(_: TimerOs) -> bool { + false +} + +fn present(_: TimerOs) -> bool { + true +} + +fn setup(os: TimerOs, home: &Path, run: CommandRunner) -> TimerSetup { + TimerSetup { + os, + home: home.to_path_buf(), + exe: PathBuf::from("/opt/fastskill"), + user: "a b".to_string(), + run, + system_present: absent, + } +} + +#[test] +fn linux_installs_and_removes_a_systemd_user_timer() { + let home = tempfile::tempdir().unwrap(); + let setup = setup(TimerOs::Linux, home.path(), record); + let installed = install(&setup).unwrap(); + assert_eq!( + installed.as_deref(), + Some("systemd user timer fastskill-managed-apply.timer") + ); + let dir = home.path().join(".config/systemd/user"); + let service = std::fs::read_to_string(dir.join("fastskill-managed-apply.service")).unwrap(); + assert!(service.contains("ExecStart=\"/opt/fastskill\" managed apply\n")); + let timer = std::fs::read_to_string(dir.join("fastskill-managed-apply.timer")).unwrap(); + for line in [ + "OnStartupSec=2min", + "OnActiveSec=2min", + "OnUnitActiveSec=1h", + "RandomizedDelaySec=600", + "WantedBy=timers.target", + ] { + assert!(timer.contains(line), "{line}: {timer}"); + } + + assert!(remove(&setup).unwrap()); + assert!(!dir.join("fastskill-managed-apply.timer").exists()); + assert!(!remove(&setup).unwrap()); + let ran = RAN.lock().unwrap().clone(); + assert!(ran.contains(&"systemctl --user enable --now fastskill-managed-apply.timer".into())); + assert!(ran.contains(&"systemctl --user disable --now fastskill-managed-apply.timer".into())); +} + +#[test] +fn macos_runs_apply_with_its_own_delay_from_a_launch_agent() { + let home = tempfile::tempdir().unwrap(); + let setup = setup(TimerOs::MacOs, home.path(), record); + let plan = install_plan(&setup); + let plist = &plan.write[0]; + assert_eq!( + plist.path, + home.path() + .join("Library/LaunchAgents/dev.fastskill.managed-apply.plist") + ); + for part in [ + "/opt/fastskill", + "--timer", + "RunAtLoad\n\t", + "3600", + ] { + assert!(plist.contents.contains(part), "{part}"); + } + assert!(!plan.commands[0].required && plan.commands[1].required); + assert_eq!( + install(&setup).unwrap().as_deref(), + Some("launchd agent dev.fastskill.managed-apply") + ); + assert!(remove(&setup).unwrap()); + assert_eq!(remove_plan(&setup).commands[0].args[1], "unload"); +} + +#[test] +fn windows_registers_a_task_from_a_utf16_definition() { + let home = tempfile::tempdir().unwrap(); + let setup = setup(TimerOs::Windows, home.path(), record); + let plan = install_plan(&setup); + let task = &plan.write[0].contents; + for part in [ + "truea bPT10M", + "PT1H", + "PT10M", + "/opt/fastskill", + ] { + assert!(task.contains(part), "{part}"); + } + assert_eq!( + plan.commands[0].args[..4], + args(&[ + "schtasks", + "/Create", + "/TN", + r"\FastSkill\managed-apply-a-b" + ]) + ); + assert_eq!( + install(&setup).unwrap().as_deref(), + Some(r"scheduled task \FastSkill\managed-apply-a-b") + ); + let bytes = std::fs::read(&plan.write[0].path).unwrap(); + assert_eq!(&bytes[..4], &[0xFF, 0xFE, b'<', 0]); + assert!(remove(&setup).unwrap()); + assert_eq!(remove_plan(&setup).commands[0].args[1], "/Delete"); +} + +#[test] +fn a_machine_wide_timer_means_no_user_timer() { + let home = tempfile::tempdir().unwrap(); + let mut setup = setup(TimerOs::Linux, home.path(), fail); + setup.system_present = present; + assert_eq!(install(&setup).unwrap(), None); + assert!(!home.path().join(".config").exists()); +} + +#[test] +fn a_required_command_that_fails_is_an_error_and_optional_ones_are_not() { + let home = tempfile::tempdir().unwrap(); + let setup = setup(TimerOs::Linux, home.path(), fail); + let error = install(&setup).unwrap_err(); + assert!( + error.contains("`systemctl --user daemon-reload` failed: no systemctl"), + "{error}" + ); + assert!(remove(&setup).unwrap()); +} + +#[test] +fn files_that_cannot_be_written_or_removed_are_errors() { + let home = tempfile::tempdir().unwrap(); + std::fs::write(home.path().join(".config"), "a file").unwrap(); + let setup = setup(TimerOs::Linux, home.path(), record); + assert!(install(&setup).unwrap_err().contains("can't create")); + + let home = tempfile::tempdir().unwrap(); + let dir = home + .path() + .join("Library/LaunchAgents/dev.fastskill.managed-apply.plist"); + std::fs::create_dir_all(dir.join("inside")).unwrap(); + let setup = self::setup(TimerOs::MacOs, home.path(), record); + assert!(install(&setup).unwrap_err().contains("can't write")); + assert!(remove(&setup).unwrap_err().contains("can't remove")); +} + +#[test] +fn the_machine_wide_plan_is_for_every_user() { + let exe = Path::new("/usr/bin/fastskill"); + let linux = system_plan(TimerOs::Linux, exe); + assert_eq!( + linux.write[1].path, + PathBuf::from("/etc/systemd/user/fastskill-managed-apply.timer") + ); + assert_eq!( + linux.commands[0].args.join(" "), + "systemctl --global enable fastskill-managed-apply.timer" + ); + let macos = system_plan(TimerOs::MacOs, exe); + assert!(macos.write[0].path.starts_with("/Library/LaunchAgents")); + let windows = system_plan(TimerOs::Windows, exe); + assert!(windows.write[0] + .contents + .contains("S-1-5-32-545")); + assert!(!windows.write[0].contents.contains("")); + assert!(!system_timer_present(TimerOs::Windows)); + assert!(!system_timer_present(TimerOs::MacOs)); +} + +#[test] +fn the_real_runner_reports_how_a_command_ended() { + assert!(run_command(&[]).is_err()); + assert!(run_command(&args(&["fastskill-no-such-program"])) + .unwrap_err() + .contains("couldn't start")); + #[cfg(unix)] + { + assert!(run_command(&args(&["true"])).is_ok()); + assert!(run_command(&args(&["false"])) + .unwrap_err() + .contains("exited with")); + } +} + +#[test] +fn the_current_setup_and_delay_stay_in_bounds() { + let current = TimerSetup::current(Path::new("/home/u"), Path::new("/bin/fastskill")); + assert_eq!(current.map(|setup| setup.os), TimerOs::current()); + for _ in 0..20 { + assert!(random_delay().as_secs() <= MAX_DELAY_SECS); + } +} diff --git a/crates/fastskill-core/src/write_ops.rs b/crates/fastskill-core/src/write_ops.rs index bcc6b568..a9cb2600 100644 --- a/crates/fastskill-core/src/write_ops.rs +++ b/crates/fastskill-core/src/write_ops.rs @@ -81,6 +81,7 @@ pub static READ_ONLY_COMMAND_PATHS: &[&[&str]] = &[ &["eval", "report"], &["eval", "score"], &["eval", "validate"], + &["managed", "hooks"], &["managed", "status"], &["mcp", "list"], &["optimization", "inspect"], diff --git a/webdocs/cli-reference/managed-command.mdx b/webdocs/cli-reference/managed-command.mdx index 1c29cae3..1082b4dd 100644 --- a/webdocs/cli-reference/managed-command.mdx +++ b/webdocs/cli-reference/managed-command.mdx @@ -112,7 +112,8 @@ sending it. ### enroll Enroll this user with the configured source and apply its first state. Enrolling again with the -same source keeps the machine id. +same source keeps the machine id. Enrolling also registers the session-start hooks and installs +the timer (see [Keeping up to date](#keeping-up-to-date)). ```bash fastskill managed enroll @@ -128,6 +129,8 @@ quarantined. ```bash fastskill managed apply fastskill managed apply --json +fastskill managed apply --hook fastskill-managed-claude # as the agent's session-start hook +fastskill managed apply --timer # as the timer: wait up to 10 minutes first ``` Only one apply runs at a time for a user. A second apply started from a terminal says so; one @@ -152,15 +155,47 @@ fastskill managed status --json --report # only the body the next report carri Remove what managed state put on this machine for this user: the skills FastSkill deployed (a deployed copy that was changed is quarantined instead), the stored skills, the cached state, the records and the user settings file. The quarantine is kept. Refused when the system file -sets `required = true`, and while an apply runs. +sets `required = true`, and while an apply runs. The session-start hooks and the timer are +removed too. ```bash fastskill managed unenroll ``` +### hooks + +Show this user's session-start hook for each agent that supports one, and the timer. With +`--system`, print instead what an administrator installs for every user: the entry to merge +into each agent's administrator-level settings, and the machine-wide timer files and commands. +Nothing is written. + +```bash +fastskill managed hooks +fastskill managed hooks --system +fastskill managed hooks --system --json +``` + **Options** (every subcommand): - `--json`: Print the result as JSON +## Keeping up to date + +Two things run `managed apply` without anyone typing it: + +- **Session-start hooks.** Every apply registers a hook in the user settings of each covered + agent that can run a command when a session starts (Claude Code and Gemini CLI), and removes + it from agents no longer covered. The hook starts an apply in the background and returns at + once. It prints nothing unless the user must act, such as to sign in, and then one notice. + Claude Code asks the user to approve a new hook once; the apply that adds it says so. An + agent whose administrator-level settings already hold the hook gets no user-level one. +- **A timer.** Enrolling, including the first apply that enrolls, installs a timer that applies + shortly after login and then every hour, each time after a random wait of up to 10 minutes: + a systemd user timer on Linux, a launchd agent on macOS, and a scheduled task on Windows. No + user timer is installed when a machine-wide one is present. Set `FASTSKILL_MANAGED_TIMER=off` + to install none, for example on a machine without a user service manager. + +A hook or timer that can't be installed is a warning in the apply's report, not a failure. + ## Installs and other commands While this user follows a state, every command that adds or replaces skill content checks it