From 2d637d10d022b1d7f1cb5eb5447b9abd75890cc4 Mon Sep 17 00:00:00 2001 From: Alex Oliveira <4482374+aroff@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:21:57 +0000 Subject: [PATCH] feat(managed): https sources, the credential command and the apply report A managed source can now be an https:// URL. Apply fetches the state and each listed archive over https, with size limits and only https redirects. - credential_command: run without a shell from the configuration folder, 30 s at most; its first line is a bearer token sent only to the source's origin. A failure or a 401 falls back to the cached state and reports that a sign-in is needed. Without a terminal, FASTSKILL_INTERACTIVE=0. - report_url: each apply sends a fixed JSON report (agents, skill entries and outcomes, quarantine, refusals, project skills) with a rising sequence; an accepted report clears the refusals it carried. - request_url and report_url on another origin are ignored with a warning; a file source ignores editable, request_url and report_url, and managed status names them. - managed status shows sign-in needed, ignored fields and where reports go; managed status --json --report prints the next report body. --- Cargo.lock | 36 +- crates/fastskill-cli/src/commands/managed.rs | 71 ++- .../src/commands/managed_tests.rs | 80 ++- crates/fastskill-cli/src/registration.rs | 4 + crates/fastskill-core/Cargo.toml | 5 +- .../fastskill-core/src/core/managed/apply.rs | 127 ++++- .../src/core/managed/apply_tests.rs | 65 ++- .../src/core/managed/enrollment.rs | 58 ++- .../fastskill-core/src/core/managed/gate.rs | 111 ++++- .../fastskill-core/src/core/managed/layout.rs | 7 + crates/fastskill-core/src/core/managed/mod.rs | 9 + .../src/core/managed/records.rs | 4 + .../fastskill-core/src/core/managed/remote.rs | 370 ++++++++++++++ .../src/core/managed/remote_tests.rs | 231 +++++++++ .../fastskill-core/src/core/managed/report.rs | 459 ++++++++++++++++++ .../src/core/managed/report_tests.rs | 409 ++++++++++++++++ .../fastskill-core/src/core/managed/store.rs | 27 +- .../src/core/managed/test_https.rs | 171 +++++++ webdocs/cli-reference/managed-command.mdx | 69 ++- 19 files changed, 2225 insertions(+), 88 deletions(-) create mode 100644 crates/fastskill-core/src/core/managed/remote.rs create mode 100644 crates/fastskill-core/src/core/managed/remote_tests.rs create mode 100644 crates/fastskill-core/src/core/managed/report.rs create mode 100644 crates/fastskill-core/src/core/managed/report_tests.rs create mode 100644 crates/fastskill-core/src/core/managed/test_https.rs diff --git a/Cargo.lock b/Cargo.lock index 3647b18e..d78eb247 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -497,7 +497,7 @@ version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1373,10 +1373,12 @@ dependencies = [ "once_cell", "predicates", "proptest", + "rcgen", "regex", "reqwest 0.12.28", "ring", "rusqlite", + "rustls", "semver", "serde", "serde_json", @@ -2794,6 +2796,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3217,6 +3229,19 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5426,6 +5451,15 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/fastskill-cli/src/commands/managed.rs b/crates/fastskill-cli/src/commands/managed.rs index cb36d9dc..ad42887b 100644 --- a/crates/fastskill-cli/src/commands/managed.rs +++ b/crates/fastskill-cli/src/commands/managed.rs @@ -33,7 +33,8 @@ pub fn group_metadata() -> GroupMetadata { #[derive(Debug, Default)] pub struct ManagedArgs { pub json: bool, - /// `status` only: include the last apply's full report. + /// `status` only: include what the last apply did and the report body; with `--json`, print + /// only the report body. pub report: bool, } @@ -111,6 +112,7 @@ managed_args!(StatusArgs, { vec![ "fastskill managed status", "fastskill managed status --report", + "fastskill managed status --json --report", ], ); spec.args.push(ArgSpec { @@ -119,7 +121,8 @@ managed_args!(StatusArgs, { long: Some("report"), value_type: ArgValueType::Bool, cardinality: Cardinality::Optional, - help: "Include everything the last apply did", + help: "Include everything the last apply did and the report body; with --json, print \ + only the report body", ..Default::default() }); spec @@ -142,6 +145,8 @@ pub struct Environment { pub project_skills: Option, /// Whether a person is watching; a hook runs without a terminal. pub interactive: bool, + /// FastSkill's configuration folder, where the credential command runs. + pub config_dir: PathBuf, } impl Environment { @@ -151,6 +156,9 @@ impl Environment { Ok(Self { layout: ManagedLayout::for_current_user()?, settings, + config_dir: user_file_path() + .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()))?, @@ -170,6 +178,8 @@ impl Environment { project_skills: self.project_skills.clone(), may_enroll, now: chrono::Utc::now(), + interactive: self.interactive, + config_dir: self.config_dir.clone(), }) } } @@ -183,13 +193,21 @@ fn not_configured() -> CliError { } pub async fn execute_enroll(args: EnrollArgs) -> CliResult<()> { - let env = Environment::current()?; - emit(run_enroll(&env, &args.0)?) + emit(off_runtime(move || run_enroll(&Environment::current()?, &args.0)).await?) } pub async fn execute_apply(args: ApplyArgs) -> CliResult<()> { - let env = Environment::current()?; - emit(run_apply(&env, &args.0)?) + emit(off_runtime(move || run_apply(&Environment::current()?, &args.0)).await?) +} + +/// Run an apply on a blocking thread: it makes blocking https requests and runs the +/// credential command. +async fn off_runtime( + work: impl FnOnce() -> CliResult + Send + 'static, +) -> CliResult { + tokio::task::spawn_blocking(work) + .await + .map_err(|error| CliError::Config(format!("the apply stopped unexpectedly: {error}")))? } pub async fn execute_status(args: StatusArgs) -> CliResult<()> { @@ -276,6 +294,9 @@ pub fn render_outcome(outcome: &ApplyOutcome) -> String { if let Some(problem) = &outcome.state_problem { lines.push(format!("Used the last accepted state: {problem}")); } + if outcome.sign_in_needed { + lines.push(SIGN_IN_NEEDED.to_string()); + } if outcome.targets.is_empty() { lines.push("No agent targets were found for this user.".to_string()); } @@ -314,11 +335,29 @@ pub fn render_outcome(outcome: &ApplyOutcome) -> String { for (what, why) in &outcome.failures { lines.push(format!(" failed {what}: {why}")); } + for warning in &outcome.warnings { + lines.push(format!(" warning {warning}")); + } + if let Some(report) = &outcome.report { + lines.push(match (&report.problem, report.accepted) { + (_, true) => format!(" report #{} accepted", report.sequence), + (Some(problem), _) => format!(" report not accepted: {problem}"), + (None, false) => " report not accepted".to_string(), + }); + } lines.join("\n") } +const SIGN_IN_NEEDED: &str = + "Sign-in needed: the credential command didn't give a token the source accepts. Run \ + `fastskill managed apply` in a terminal to sign in."; + pub fn run_status(env: &Environment, args: &ManagedArgs) -> CliResult { let status = managed::status(&env.layout, &env.settings, chrono::Utc::now())?; + if args.json && args.report { + // The exact body the next report carries, and nothing else (decision 15). + return Ok(serde_json::to_string_pretty(&status.report).unwrap_or_default()); + } if args.json { return Ok( serde_json::to_string_pretty(&status_json(&status, args.report)).unwrap_or_default(), @@ -338,6 +377,10 @@ fn status_json(status: &ManagedStatus, report: bool) -> Value { "expires_at": status.expires_at, "expired": status.expired, "state_problem": status.state_problem, + "sign_in_needed": status.sign_in_needed, + "ignored": status.ignored, + "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(), "collisions": last.map(|l| l.collisions.clone()).unwrap_or_default(), "owned": status.owned, @@ -377,6 +420,15 @@ pub fn render_status(status: &ManagedStatus, report: bool) -> String { if let Some(problem) = &status.state_problem { lines.push(format!("State: {problem}")); } + if status.sign_in_needed { + lines.push(SIGN_IN_NEEDED.to_string()); + } + for ignored in &status.ignored { + lines.push(format!("Ignored: {ignored}")); + } + if let Some(note) = &status.report_note { + lines.push(format!("Report: {note}")); + } if let Some(last) = &status.last_apply { lines.push(format!( "Last apply: {} ({})", @@ -393,10 +445,17 @@ pub fn render_status(status: &ManagedStatus, report: bool) -> String { for path in &last.collisions { lines.push(format!(" collision {}", path.display())); } + for warning in &last.warnings { + lines.push(format!(" warning {warning}")); + } if report { lines.push(render_outcome(last)); } } + if let (true, Some(body)) = (report, &status.report) { + lines.push("Report body:".to_string()); + lines.push(serde_json::to_string_pretty(body).unwrap_or_default()); + } lines.push(format!("Managed entries: {}", status.owned.len())); if status.quarantine.is_empty() { lines.push("Quarantine: empty".to_string()); diff --git a/crates/fastskill-cli/src/commands/managed_tests.rs b/crates/fastskill-cli/src/commands/managed_tests.rs index ac33a7d7..d3fb9725 100644 --- a/crates/fastskill-cli/src/commands/managed_tests.rs +++ b/crates/fastskill-cli/src/commands/managed_tests.rs @@ -1,6 +1,7 @@ use super::*; use fastskill_core::core::managed::{ Enrollment, EntryChange, ManagedSource, PinnedKey, QuarantineReason, QuarantineRecord, + ReportDelivery, }; use tempfile::TempDir; @@ -21,6 +22,7 @@ fn environment(dir: &TempDir, configured: bool, interactive: bool) -> Environmen home: dir.path().join("home"), project_skills: None, interactive, + config_dir: dir.path().to_path_buf(), } } @@ -255,7 +257,83 @@ fn status_of_an_enrolled_user() { report: true, }; let value: Value = serde_json::from_str(&run_status(&env, &report).unwrap()).unwrap(); - assert_eq!(value["last_apply"]["subject"], "team-a"); + assert_eq!(value["format_version"], 1); + assert_eq!(value["sequence"], 1); + assert_eq!(value["machine_id"], value_of_machine(&env)); + assert!(value.get("last_apply").is_none(), "{value}"); +} + +fn value_of_machine(env: &Environment) -> String { + let enrollment: Enrollment = + serde_json::from_slice(&std::fs::read(env.layout.enrollment_file()).unwrap()).unwrap(); + enrollment.machine_id +} + +#[test] +fn sign_in_warnings_and_the_report_are_shown() { + let mut last = outcome(true); + last.sign_in_needed = true; + last.warnings = vec!["report_url is elsewhere".to_string()]; + for (delivery, expected) in [ + ( + ReportDelivery { + sequence: 4, + accepted: true, + problem: None, + }, + "report #4 accepted", + ), + ( + ReportDelivery { + sequence: 4, + accepted: false, + problem: Some("HTTP 500".to_string()), + }, + "report not accepted: HTTP 500", + ), + (ReportDelivery::default(), "report not accepted"), + ] { + last.report = Some(delivery); + let text = render_outcome(&last); + for expected in [ + expected, + "Sign-in needed", + "warning report_url is elsewhere", + ] { + assert!(text.contains(expected), "{expected} in {text}"); + } + } + + let status = ManagedStatus { + source: Some("/s/state.dsse".to_string()), + enrollment: Some(Enrollment::new("/s/state.dsse", chrono::Utc::now())), + sign_in_needed: true, + ignored: vec!["report_url: a file source never gets a report".to_string()], + report_note: Some("none: a file source never gets a report".to_string()), + report: Some(managed::Report::build(managed::report::ReportInput { + source: &ManagedSource::File(PathBuf::from("/s/state.dsse")), + enrollment: &Enrollment::new("/s/state.dsse", chrono::Utc::now()), + issued_at: None, + completed: true, + snapshot: &last.snapshot, + quarantine: &[], + refusals: &managed::report::Refusals::default(), + })), + last_apply: Some(last), + ..ManagedStatus::default() + }; + let text = render_status(&status, true); + for expected in [ + "Sign-in needed", + "Ignored: report_url", + "Report: none: a file source", + " warning report_url is elsewhere", + "Report body:", + "\"format_version\": 1", + ] { + assert!(text.contains(expected), "{expected} in {text}"); + } + assert!(!render_status(&status, false).contains("Report body:")); } #[test] diff --git a/crates/fastskill-cli/src/registration.rs b/crates/fastskill-cli/src/registration.rs index 65bae1e0..2e419b7f 100644 --- a/crates/fastskill-cli/src/registration.rs +++ b/crates/fastskill-cli/src/registration.rs @@ -63,6 +63,7 @@ where let id: Arc = Arc::from(path.leaf().unwrap_or("")); let handler = Arc::new(handler); let managed = follows_managed_policy(path); + let command_name: Arc = Arc::from(path.0.join(" ")); Command { id, @@ -78,8 +79,11 @@ where // `'static` and does not hold the borrow, which frees `ctx` for the // drain below. let fut = handler(ctx, typed); + let command_name = command_name.clone(); Box::pin(async move { if managed { + // A refused install is kept for the managed report under this name. + fastskill_core::core::managed::set_current_command(&command_name); managed_policy(Situation::for_current_user(Utc::now()).command_policy())?; } match output::mode() { diff --git a/crates/fastskill-core/Cargo.toml b/crates/fastskill-core/Cargo.toml index 9013a371..fb52cca4 100644 --- a/crates/fastskill-core/Cargo.toml +++ b/crates/fastskill-core/Cargo.toml @@ -47,7 +47,7 @@ walkdir.workspace = true zip.workspace = true # HTTP client for embedding API (rustls for musl compatibility) -reqwest.workspace = true +reqwest = { workspace = true, features = ["blocking"] } # HTTP server axum.workspace = true @@ -97,6 +97,9 @@ insta = "1.47" assert_cmd = "2.2" predicates = "3.0" proptest = "1.11" +# A local https server for the managed source tests +rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring", "pem"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } [features] default = ["filesystem-storage"] diff --git a/crates/fastskill-core/src/core/managed/apply.rs b/crates/fastskill-core/src/core/managed/apply.rs index e2135938..86ef8b65 100644 --- a/crates/fastskill-core/src/core/managed/apply.rs +++ b/crates/fastskill-core/src/core/managed/apply.rs @@ -10,12 +10,19 @@ //! 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. +//! 5. 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). +//! When that command fails, the last accepted state is used and the outcome says sign-in is +//! needed. Apply makes blocking requests: call it from a plain thread. 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::remote::{run_credential_command, Remote, RemoteError}; +use super::report::{ReportDelivery, ReportSnapshot}; use super::state::{Allowed, ManagedState}; use super::store::ManagedStore; use super::OpenedState; @@ -37,6 +44,10 @@ pub struct ApplyContext { /// Enroll when this user isn't enrolled yet. pub may_enroll: bool, pub now: DateTime, + /// Whether a person can answer the credential command's prompts. + pub interactive: bool, + /// FastSkill's configuration folder, where the credential command runs. + pub config_dir: PathBuf, } /// Whether an apply ran. @@ -81,6 +92,18 @@ pub struct ApplyOutcome { pub project_clashes: Vec, /// Skills or entries that failed, with why. pub failures: Vec<(String, String)>, + /// The credential command failed or the source refused the token: the user must sign in. + #[serde(default)] + pub sign_in_needed: bool, + /// Problems that don't stop an apply, such as a `report_url` on another origin. + #[serde(default)] + pub warnings: Vec, + /// What happened to the report, when one was due. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub report: Option, + /// What the report is built from. + #[serde(default)] + pub snapshot: ReportSnapshot, } /// Apply the configured managed state. @@ -117,7 +140,8 @@ pub fn apply(context: &ApplyContext) -> Result { ))); } - let opened = fetch_and_open(context, &source, &enrollment, &mut outcome)?; + let remote = connect(context, &source, &mut outcome); + let opened = fetch_and_open(context, &source, remote.as_ref(), &enrollment, &mut outcome)?; enrollment.recorded = enrollment.recorded.accept(&opened.state); write_record(&layout.enrollment_file(), &enrollment)?; @@ -126,7 +150,11 @@ pub fn apply(context: &ApplyContext) -> Result { 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 covered = covered_targets(context); + outcome.targets = covered.iter().map(|(dir, _)| dir.clone()).collect(); + outcome + .warnings + .extend(super::report::state_warnings(state, &source)); let mut ownership: Ownership = read_record(&layout.ownership_file())?; let mut run = Run { @@ -136,7 +164,7 @@ pub fn apply(context: &ApplyContext) -> Result { outcome: &mut outcome, }; if !run.outcome.expired { - run.deploy_listed(&source); + run.deploy_listed(&source, remote.as_ref()); run.remove_unlisted(); } run.sweep_targets(); @@ -156,19 +184,71 @@ pub fn apply(context: &ApplyContext) -> Result { write_record(&layout.ownership_file(), &ownership)?; outcome.completed = outcome.failures.is_empty(); + outcome.snapshot = super::report::snapshot(context, &covered, &ownership, &outcome); + super::report::send( + context, + &source, + state, + remote.as_ref(), + &mut enrollment, + &mut outcome, + )?; write_record(&layout.last_apply_file(), &outcome)?; Ok(ApplyResult::Done(Box::new(outcome))) } +/// 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( + context: &ApplyContext, + source: &ManagedSource, + outcome: &mut ApplyOutcome, +) -> Option { + let token = match (&context.settings.credential_command, source) { + (Some(command), ManagedSource::Https(_)) => { + match run_credential_command(command, &context.config_dir, context.interactive) { + Ok(token) => Some(token), + Err(error) => { + outcome.sign_in_needed = true; + outcome.state_problem = Some(format!("sign-in needed: {error}")); + None + } + } + } + _ => None, + }; + match Remote::new(source, token) { + Ok(remote) => Some(remote), + Err(error) => { + outcome + .failures + .push(("https".to_string(), error.to_string())); + None + } + } +} + /// 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, + remote: Option<&Remote>, enrollment: &Enrollment, outcome: &mut ApplyOutcome, ) -> Result { - let fetched = fetch(source).and_then(|bytes| { + let fetched = if outcome.sign_in_needed { + Err(RemoteError { + message: outcome.state_problem.clone().unwrap_or_default(), + sign_in: true, + }) + } else { + fetch(source, remote) + }; + if fetched.as_ref().is_err_and(|error| error.sign_in) { + outcome.sign_in_needed = true; + } + let fetched = fetched.map_err(ServiceError::from).and_then(|bytes| { super::open(&bytes, &context.settings, &enrollment.recorded, context.now) .map(|opened| (bytes, opened)) .map_err(ServiceError::from) @@ -196,23 +276,31 @@ fn fetch_and_open( } /// 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()), - )) +pub fn fetch(source: &ManagedSource, remote: Option<&Remote>) -> Result, RemoteError> { + match (source, remote) { + (ManagedSource::File(path), _) => std::fs::read(path).map_err(|error| RemoteError { + message: format!("can't read the managed state {}: {error}", path.display()), + sign_in: false, + }), + (ManagedSource::Https(_), Some(remote)) => remote.get_state(), + (ManagedSource::Https(url), None) => Err(RemoteError { + message: format!("can't fetch the managed state from {url}: https isn't available"), + sign_in: false, }), - 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 { + covered_targets(context) + .into_iter() + .map(|(dir, _)| dir) + .collect() +} + +/// The Agent targets with the agents each one covers. +pub fn covered_targets(context: &ApplyContext) -> Vec<(PathBuf, Vec<&'static str>)> { let detected; let keys: Vec<&str> = match &context.settings.targets { Some(targets) => targets.iter().map(String::as_str).collect(), @@ -222,9 +310,6 @@ pub fn targets(context: &ApplyContext) -> Vec { } }; 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. @@ -275,11 +360,11 @@ impl Run<'_> { } } - fn deploy_listed(&mut self, source: &ManagedSource) { + fn deploy_listed(&mut self, source: &ManagedSource, remote: Option<&Remote>) { 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) { + let published = match store.ensure(skill, source, remote) { Ok(path) => path, Err(error) => { self.fail(skill.id.clone(), error); @@ -443,7 +528,7 @@ impl Run<'_> { /// 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)> { +pub(crate) fn entries(folder: &Path) -> Vec<(PathBuf, String)> { let Ok(read) = std::fs::read_dir(folder) else { return Vec::new(); }; diff --git a/crates/fastskill-core/src/core/managed/apply_tests.rs b/crates/fastskill-core/src/core/managed/apply_tests.rs index 8bc3e91b..d1edffe1 100644 --- a/crates/fastskill-core/src/core/managed/apply_tests.rs +++ b/crates/fastskill-core/src/core/managed/apply_tests.rs @@ -10,27 +10,26 @@ 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 { +pub(super) 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, +pub(super) struct Fixture { + pub(super) _dir: tempfile::TempDir, + pub(super) root: PathBuf, + pub(super) state_file: PathBuf, + pub(super) layout: ManagedLayout, + pub(super) project: PathBuf, + pub(super) count: usize, } impl Fixture { - fn new() -> Self { + pub(super) fn new() -> Self { let dir = tempfile::tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); std::fs::create_dir_all(root.join("home/.claude")).unwrap(); @@ -46,11 +45,11 @@ impl Fixture { } } - fn home(&self) -> PathBuf { + pub(super) fn home(&self) -> PathBuf { self.root.join("home") } - fn settings(&self) -> ManagedSettings { + pub(super) fn settings(&self) -> ManagedSettings { ManagedSettings { source: Some(ManagedSource::File(self.state_file.clone())), keys: vec![pinned("k1", 1)], @@ -60,7 +59,7 @@ impl Fixture { } } - fn context(&self) -> ApplyContext { + pub(super) fn context(&self) -> ApplyContext { ApplyContext { layout: self.layout.clone(), settings: self.settings(), @@ -68,15 +67,17 @@ impl Fixture { project_skills: Some(self.project.clone()), may_enroll: true, now: now(), + interactive: false, + config_dir: self.root.clone(), } } - fn target(&self) -> PathBuf { + pub(super) 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 { + pub(super) 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); @@ -84,7 +85,7 @@ impl Fixture { } /// A listed skill with an archive in the source folder, relative to the state file. - fn skill(&mut self, id: &str, body: &str) -> StateSkill { + pub(super) 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)); @@ -95,7 +96,7 @@ impl Fixture { } } - fn publish(&self, skills: &[StateSkill], extra: Value) { + pub(super) fn publish(&self, skills: &[StateSkill], extra: Value) { let mut state = json!({ "format_version": 1, "issued_at": "2026-10-09T11:00:00Z", @@ -112,23 +113,23 @@ impl Fixture { std::fs::write(&self.state_file, envelope).unwrap(); } - fn apply(&self) -> ApplyOutcome { + pub(super) fn apply(&self) -> ApplyOutcome { self.apply_with(&self.context()) } - fn apply_with(&self, context: &ApplyContext) -> ApplyOutcome { + pub(super) fn apply_with(&self, context: &ApplyContext) -> ApplyOutcome { match apply(context).unwrap() { ApplyResult::Done(outcome) => *outcome, ApplyResult::Busy => panic!("busy"), } } - fn ownership(&self) -> Ownership { + pub(super) fn ownership(&self) -> Ownership { read_record(&self.layout.ownership_file()).unwrap() } } -fn write_skill(folder: &Path, id: &str, body: &str) { +pub(super) fn write_skill(folder: &Path, id: &str, body: &str) { std::fs::create_dir_all(folder).unwrap(); std::fs::write( folder.join("SKILL.md"), @@ -137,7 +138,7 @@ fn write_skill(folder: &Path, id: &str, body: &str) { .unwrap(); } -fn zip_folder(folder: &Path, archive: &Path) { +pub(super) 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(); @@ -574,7 +575,7 @@ fn a_corrupt_store_entry_is_fetched_again() { 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(); + let path = store.ensure(&pdf, &source, None).unwrap(); assert_eq!(content_digest(&path).unwrap(), pdf.digest); } @@ -593,8 +594,22 @@ fn artifact_paths_and_https_sources() { 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(_))); + let error = fetch(&https, None).unwrap_err(); + assert!(error.message.contains("https isn't available"), "{error}"); + assert!(!error.sign_in); + let listed = StateSkill { + id: "pdf".to_string(), + digest: format!("sha256-tree-v2:{}", "0".repeat(64)), + artifact: "https://skills.example.com/a.zip".to_string(), + }; + let fixture = Fixture::new(); + let error = ManagedStore::new(&fixture.layout) + .ensure(&listed, &https, None) + .unwrap_err(); + assert!( + error.to_string().contains("https isn't available"), + "{error}" + ); } #[test] diff --git a/crates/fastskill-core/src/core/managed/enrollment.rs b/crates/fastskill-core/src/core/managed/enrollment.rs index ef9941d6..b1faf75e 100644 --- a/crates/fastskill-core/src/core/managed/enrollment.rs +++ b/crates/fastskill-core/src/core/managed/enrollment.rs @@ -2,11 +2,12 @@ //! 17). use super::apply::{apply, entry_digest, ApplyContext, ApplyOutcome, ApplyResult}; -use super::config::ManagedSettings; +use super::config::{ManagedSettings, ManagedSource}; 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 super::report::{self, Report, ReportInput}; +use super::state::{ManagedState, Recorded}; use crate::core::service::ServiceError; use chrono::{DateTime, Utc}; use std::path::{Path, PathBuf}; @@ -30,6 +31,7 @@ pub fn enroll(context: &ApplyContext) -> Result { 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.report_sequence = previous.report_sequence; enrollment.recorded = Recorded { subject: None, highest_issued_at: previous.recorded.highest_issued_at, @@ -136,6 +138,14 @@ pub struct ManagedStatus { pub owned: Vec, pub last_apply: Option, pub quarantine: Vec, + /// The last apply found that the user must sign in (decision 6). + pub sign_in_needed: bool, + /// State fields this source ignores, each with why (decision 21). + pub ignored: Vec, + /// The body the next report would carry, built from the last apply. + pub report: Option, + /// Where reports go, or why none is sent. + pub report_note: Option, } /// Read the status from the records and the cached state, changing nothing. @@ -153,10 +163,14 @@ pub fn status( .as_ref() .map(|source| source.as_str().to_string()), owned: ownership.entries, + sign_in_needed: last_apply.sign_in_needed, last_apply: last_apply.applied_at.is_some().then_some(last_apply), quarantine: quarantine::list(layout)?, ..ManagedStatus::default() }; + let Some(source) = settings.source.as_ref() else { + return Ok(status); + }; if enrollment.is_enrolled() { match open_cached(layout, settings, &enrollment, now) { Ok(opened) => { @@ -164,14 +178,54 @@ pub fn status( status.issued_at = Some(opened.state.issued_at); status.expires_at = Some(opened.state.expires_at); status.expired = opened.state.is_expired(now); + status.ignored = ignored_fields(&opened.state, source); + status.report_note = Some( + match report::destination(opened.state.report_url.as_deref(), source) { + Ok(Some(url)) => format!("sent to {url}"), + Ok(None) => "none: the state names no report_url".to_string(), + Err(problem) => format!("none: {problem}"), + }, + ); } Err(problem) => status.state_problem = Some(problem), } + let last = status.last_apply.clone().unwrap_or_default(); + status.report = Some(Report::build(ReportInput { + source, + enrollment: &enrollment, + issued_at: last.issued_at, + completed: last.completed, + snapshot: &last.snapshot, + quarantine: &status.quarantine, + refusals: &report::refusals(layout)?, + })); status.enrollment = Some(enrollment); } Ok(status) } +/// The fields a file source's state sets that only an `https://` source honors (decision 21). +fn ignored_fields(state: &ManagedState, source: &ManagedSource) -> Vec { + if !matches!(source, ManagedSource::File(_)) { + return Vec::new(); + } + let mut ignored = Vec::new(); + if state.editable.is_some() { + ignored.push( + "editable: a file source always uses blocked-only, so editable local skills skip \ + the allow-list" + .to_string(), + ); + } + if state.request_url.is_some() { + ignored.push("request_url: a file source has no origin to send people to".to_string()); + } + if state.report_url.is_some() { + ignored.push("report_url: a file source never gets a report".to_string()); + } + ignored +} + /// Verify the cached state again, or say why it can't be used. pub(crate) fn open_cached( layout: &ManagedLayout, diff --git a/crates/fastskill-core/src/core/managed/gate.rs b/crates/fastskill-core/src/core/managed/gate.rs index 61be410c..dc2832d7 100644 --- a/crates/fastskill-core/src/core/managed/gate.rs +++ b/crates/fastskill-core/src/core/managed/gate.rs @@ -159,10 +159,37 @@ impl Situation { {APPLY_HINT}", state.expires_at ))), - Self::State { state, source, .. } => check_state(state, source, candidate), + Self::State { state, source, .. } => { + check_state(state, source, candidate).map_err(|refusal| refused(refusal.message)) + } } } + /// [`Situation::check`], keeping a refusal of a known digest in `layout` for the next + /// report when the source is `https://` (decisions 15 and 21). + pub fn check_recording( + &self, + candidate: Candidate<'_>, + layout: &ManagedLayout, + ) -> Result<(), ServiceError> { + let Self::State { + state, + source: source @ ManagedSource::Https(_), + expired: false, + } = self + else { + return self.check(candidate); + }; + check_state(state, source, candidate).map_err(|refusal| { + if let Some(digest) = &refusal.digest { + if let Err(error) = super::report::record_refusal(layout, digest) { + tracing::warn!("couldn't keep the refusal for the managed report: {error}"); + } + } + refused(refusal.message) + }) + } + /// Check several candidates, stopping at the first refusal. pub fn check_all<'a>( &self, @@ -221,28 +248,51 @@ impl Situation { } } +/// Why a candidate was refused, with its digest when it was known. +struct Refusal { + message: String, + digest: Option, +} + +impl Refusal { + fn new(message: String, digest: Option<&str>) -> Self { + Self { + message, + digest: digest.map(str::to_string), + } + } +} + fn check_state( state: &ManagedState, source: &ManagedSource, candidate: Candidate<'_>, -) -> Result<(), ServiceError> { +) -> Result<(), Refusal> { if candidate.editable && state.allowed == Allowed::Listed && state.editable(source) == Editable::Refused { - return Err(refused(format!( - "{} is an editable local skill, and the managed state refuses those", - candidate.id - ))); + return Err(Refusal::new( + format!( + "{} is an editable local skill, and the managed state refuses those", + candidate.id + ), + None, + )); } let digest = match (candidate.digest, candidate.path) { (Some(digest), _) => digest.to_string(), - (None, Some(path)) => digest_of(path)?, + (None, Some(path)) => { + digest_of(path).map_err(|error| Refusal::new(error.to_string(), None))? + } (None, None) => { - return Err(refused(format!( - "{} has no content digest to check against the managed state", - candidate.id - ))) + return Err(Refusal::new( + format!( + "{} has no content digest to check against the managed state", + candidate.id + ), + None, + )) } }; if let Some(blocked) = state.blocked(&digest) { @@ -251,20 +301,26 @@ fn check_state( .as_deref() .map(|message| format!(": {message}")) .unwrap_or_default(); - return Err(refused(format!( - "{} ({digest}) is blocked by the managed state{message}", - candidate.id - ))); + return Err(Refusal::new( + format!( + "{} ({digest}) is blocked by the managed state{message}", + candidate.id + ), + Some(&digest), + )); } if not_allowed(state, source, &digest, candidate.editable) { let link = state .request_link(source, &digest) .map(|link| format!("; request it at {link}")) .unwrap_or_default(); - return Err(refused(format!( - "{} ({digest}) isn't allowed by the managed state{link}", - candidate.id - ))); + return Err(Refusal::new( + format!( + "{} ({digest}) isn't allowed by the managed state{link}", + candidate.id + ), + Some(&digest), + )); } Ok(()) } @@ -312,9 +368,10 @@ impl ManagedGate { } } - /// Check one candidate against the situation now. + /// Check one candidate against the situation now. The current user's gate keeps refusals + /// for the next report; a fixed one doesn't. pub fn check(&self, candidate: Candidate<'_>) -> Result<(), ServiceError> { - self.situation().check(candidate) + self.check_all([candidate]) } /// Check several candidates against one reading of the situation. @@ -322,7 +379,17 @@ impl ManagedGate { &self, candidates: impl IntoIterator>, ) -> Result<(), ServiceError> { - self.situation().check_all(candidates) + let situation = self.situation(); + let layout = match &self.fixed { + Some(_) => None, + None => ManagedLayout::for_current_user().ok(), + }; + candidates + .into_iter() + .try_for_each(|candidate| match &layout { + Some(layout) => situation.check_recording(candidate, layout), + None => situation.check(candidate), + }) } } diff --git a/crates/fastskill-core/src/core/managed/layout.rs b/crates/fastskill-core/src/core/managed/layout.rs index 8660e8ca..2013716f 100644 --- a/crates/fastskill-core/src/core/managed/layout.rs +++ b/crates/fastskill-core/src/core/managed/layout.rs @@ -8,6 +8,7 @@ //! state.dsse the last accepted envelope //! ownership.json every target entry FastSkill created //! last-apply.json what the last apply did +//! refusals.json refused installs not yet reported, with refusals.lock //! staging/ downloads and extraction, private to the user //! store// published skills, never modified after publication //! /quarantine/