1use std::process::Command;
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use crate::domain::ownership::Sha256;
15use crate::domain::paths::{
16 AgentId, LEGACY_SHARED_ROOT, OFFLINE_VAR, SKILL_FILE, SKILL_RECEIPT_FILE, SKILL_REFERENCES_DIR,
17 UserEnv,
18};
19use crate::domain::skill_record::SkillRecord;
20use crate::services::skill_installer::home;
21
22fn agent_roots() -> Vec<Utf8PathBuf> {
28 UserEnv::from_process()
29 .agent_roots(&[AgentId::Claude, AgentId::Agents])
30 .into_iter()
31 .map(|entry| entry.path)
32 .collect()
33}
34
35fn resolved_state_root() -> Option<Utf8PathBuf> {
37 UserEnv::from_process().state_root().map(|entry| entry.path)
38}
39
40fn receipt() -> SkillRecord {
42 let env = UserEnv::from_process();
43 let Some(state) = env.state_root() else {
44 return SkillRecord::new();
45 };
46 let legacy = env
47 .legacy_state_root()
48 .map_or_else(|| state.path.clone(), |root| root.join(SKILL_RECEIPT_FILE));
49 SkillRecord::load_with_fallback(&state.path.join(SKILL_RECEIPT_FILE), &legacy)
50}
51
52fn installed_packages() -> Vec<(Utf8PathBuf, &'static [u8])> {
55 let mut planned = Vec::new();
56 for root in agent_roots() {
57 if !root.is_dir() {
60 continue;
61 }
62 for name in crate::embedded::skill_names() {
63 let Some(package) = crate::embedded::skill_package(name) else {
64 continue;
65 };
66 for (relative, bytes) in package {
67 planned.push((root.join(name).join(relative), bytes));
68 }
69 }
70 }
71 planned
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum ProbeClass {
78 Hard,
80 Soft,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum ProbeStatus {
88 Ok,
90 Failed,
92}
93
94#[derive(Debug, Serialize)]
96pub struct ProbeResult {
97 pub id: &'static str,
99 pub class: ProbeClass,
101 pub status: ProbeStatus,
103 pub message: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub remediation: Option<String>,
108}
109
110impl ProbeResult {
111 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
112 Self {
113 id,
114 class,
115 status: ProbeStatus::Ok,
116 message: message.into(),
117 remediation: None,
118 }
119 }
120
121 fn failed(
122 id: &'static str,
123 class: ProbeClass,
124 message: impl Into<String>,
125 remediation: impl Into<String>,
126 ) -> Self {
127 Self {
128 id,
129 class,
130 status: ProbeStatus::Failed,
131 message: message.into(),
132 remediation: Some(remediation.into()),
133 }
134 }
135}
136
137pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
143
144#[must_use]
146pub fn run_all() -> Vec<ProbeResult> {
147 vec![
148 state_root(),
149 skill_roots(),
150 skill_gate(),
151 skill_payload(),
152 registry(),
153 tool(
154 "git",
155 "SDD_GIT_BIN",
156 "git",
157 "git; retiring a migrated document is safe only where version control restores it",
158 &["--version"],
159 ),
160 tool(
161 "pre-commit",
162 "SDD_PRE_COMMIT_BIN",
163 "pre-commit",
164 "pre-commit; the delivered gates run through it",
165 &["--version"],
166 ),
167 ]
168}
169
170fn tool(
174 id: &'static str,
175 env_override: &str,
176 default_bin: &str,
177 label: &str,
178 args: &[&str],
179) -> ProbeResult {
180 let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
181 match Command::new(&bin).args(args).output() {
182 Ok(out) if out.status.success() => {
183 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
184 }
185 Ok(_) => ProbeResult::failed(
186 id,
187 ProbeClass::Soft,
188 format!("{default_bin} does not answer {}", args.join(" ")),
189 format!("repair {label}"),
190 ),
191 Err(_) => ProbeResult::failed(
192 id,
193 ProbeClass::Soft,
194 format!("{default_bin} is not on PATH"),
195 format!("install {label}"),
196 ),
197 }
198}
199
200fn registry() -> ProbeResult {
208 let id = "release-registry";
209 let url = format!("{}/config.json", crate::self_depend::registry::INDEX_ROOT);
210 if crate::domain::paths::variable(OFFLINE_VAR).is_some() {
213 return ProbeResult::ok(
214 id,
215 ProbeClass::Soft,
216 "SDD_OFFLINE is set, so no release beyond the embedded one is planned",
217 );
218 }
219 let agent: ureq::Agent = ureq::Agent::config_builder()
223 .timeout_connect(Some(std::time::Duration::from_secs(3)))
224 .timeout_global(Some(std::time::Duration::from_secs(5)))
225 .user_agent(format!("sdd/{}", env!("CARGO_PKG_VERSION")))
226 .build()
227 .into();
228 match agent.get(&url).call() {
229 Ok(response) if response.status().as_u16() == 200 => {
230 ProbeResult::ok(id, ProbeClass::Soft, format!("{url} answers"))
231 }
232 Ok(response) => ProbeResult::failed(
233 id,
234 ProbeClass::Soft,
235 format!("{url} answered {}", response.status().as_u16()),
236 "plan toward the embedded release, or retry when the registry answers",
237 ),
238 Err(source) => ProbeResult::failed(
239 id,
240 ProbeClass::Soft,
241 format!("{url} could not be read: {source}"),
242 "plan toward the embedded release; only a plan toward another release needs the registry",
243 ),
244 }
245}
246
247fn state_root() -> ProbeResult {
250 let id = "state-root";
251 let Some(root) = resolved_state_root() else {
252 return ProbeResult::failed(
253 id,
254 ProbeClass::Hard,
255 "HOME is not set, so no state root resolves",
256 "export HOME",
257 );
258 };
259 let probe = root.join(format!(".probe-{}", std::process::id()));
260 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
261 let _ = std::fs::remove_file(&probe);
262 match written {
263 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{root} is writable")),
264 Err(source) => ProbeResult::failed(
265 id,
266 ProbeClass::Hard,
267 format!("{root} is not writable: {source}"),
268 format!("make {root} writable"),
269 ),
270 }
271}
272
273fn skill_roots() -> ProbeResult {
283 let id = SKILL_PROBES[0];
284 let Ok(home) = home() else {
285 return ProbeResult::failed(
286 id,
287 ProbeClass::Soft,
288 "HOME is not set, so no skill root resolves",
289 "export HOME",
290 );
291 };
292 let mut refused = Vec::new();
293 let mut roots = agent_roots();
294 roots.extend(resolved_state_root());
295 for root in roots {
296 let Some(existing) = nearest_existing(&root) else {
297 refused.push(format!("no ancestor of {root} exists"));
298 continue;
299 };
300 if let Err(source) = accepts_a_write(&existing) {
301 refused.push(format!("{existing} is not writable: {source}"));
302 }
303 }
304 if refused.is_empty() {
305 ProbeResult::ok(
306 id,
307 ProbeClass::Soft,
308 format!("the skill roots under {home} accept writes"),
309 )
310 } else {
311 ProbeResult::failed(
312 id,
313 ProbeClass::Soft,
314 refused.join("; "),
315 format!("make the skill roots under {home} writable"),
316 )
317 }
318}
319
320fn skill_gate() -> ProbeResult {
329 let id = SKILL_PROBES[1];
330 let Ok(home) = home() else {
331 return ProbeResult::failed(
332 id,
333 ProbeClass::Soft,
334 "HOME is not set, so no skill package resolves",
335 "export HOME",
336 );
337 };
338 let record = receipt();
339 let references: Vec<(Utf8PathBuf, &'static [u8])> = installed_packages()
340 .into_iter()
341 .filter(|(path, _)| {
342 path.parent()
343 .is_some_and(|parent| parent.file_name() == Some(SKILL_REFERENCES_DIR))
344 })
345 .filter(|(path, _)| {
348 path.parent()
349 .and_then(Utf8Path::parent)
350 .is_some_and(|package| package.join(SKILL_FILE).is_file())
351 })
352 .collect();
353 if references.is_empty() {
354 return ProbeResult::failed(
355 id,
356 ProbeClass::Soft,
357 format!("no installed skill package under {home} carries its gates"),
358 "sdd skill install --apply",
359 );
360 }
361 let found = judge(references, &record);
362 if let Some(first) = found.missing.first() {
363 return ProbeResult::failed(
367 id,
368 ProbeClass::Soft,
369 format!("a gate every skill reads before acting is not installed: {first}"),
370 reinstall(found.all_recorded),
371 );
372 }
373 if !found.differing.is_empty() {
374 return ProbeResult::failed(
375 id,
376 ProbeClass::Soft,
377 format!(
378 "{} installed gate reference(s) are not this binary's",
379 found.differing.len()
380 ),
381 reinstall(found.all_recorded),
382 );
383 }
384 if let Some(leftover) = retired_shared_leftover(&home, &record) {
385 return ProbeResult::failed(
386 id,
387 ProbeClass::Soft,
388 format!("the retired shared root holds a file no receipt vouches for: {leftover}"),
389 format!("read {leftover}, then remove it; every skill now carries its own gates"),
390 );
391 }
392 ProbeResult::ok(
393 id,
394 ProbeClass::Soft,
395 format!(
396 "{} installed gate reference(s) are this binary's",
397 found.matching
398 ),
399 )
400}
401
402fn retired_shared_leftover(home: &Utf8Path, record: &SkillRecord) -> Option<Utf8PathBuf> {
408 let retired = home.join(LEGACY_SHARED_ROOT);
409 let mut found: Vec<Utf8PathBuf> = walkdir::WalkDir::new(retired.as_std_path())
410 .into_iter()
411 .filter_map(Result::ok)
412 .filter(|entry| entry.file_type().is_file())
413 .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.into_path()).ok())
414 .filter(|path| {
415 !std::fs::read(path).is_ok_and(|held| record.wrote(path, &Sha256::of(&held)))
416 })
417 .collect();
418 found.sort();
419 found.into_iter().next()
420}
421
422fn skill_payload() -> ProbeResult {
430 let id = SKILL_PROBES[2];
431 let Ok(home) = home() else {
432 return ProbeResult::failed(
433 id,
434 ProbeClass::Soft,
435 "HOME is not set, so no agent root resolves",
436 "export HOME",
437 );
438 };
439 let record = receipt();
440 let planned = installed_packages();
441 if planned.is_empty() {
442 return ProbeResult::failed(
443 id,
444 ProbeClass::Soft,
445 format!("no agent skill root exists under {home}"),
446 "sdd skill install --apply",
447 );
448 }
449 let found = judge(planned, &record);
450 if let Some(first) = found.missing.first() {
451 return ProbeResult::failed(
454 id,
455 ProbeClass::Soft,
456 format!(
457 "{} of this binary's package files are not installed, the first at {first}",
458 found.missing.len()
459 ),
460 reinstall(found.all_recorded),
461 );
462 }
463 if !found.differing.is_empty() {
464 return ProbeResult::failed(
465 id,
466 ProbeClass::Soft,
467 format!(
468 "{} installed package file(s) are not this binary's; sdd is {}",
469 found.differing.len(),
470 env!("CARGO_PKG_VERSION")
471 ),
472 reinstall(found.all_recorded),
473 );
474 }
475 ProbeResult::ok(
476 id,
477 ProbeClass::Soft,
478 format!(
479 "{} installed skill destination(s) are this binary's",
480 found.matching
481 ),
482 )
483}
484
485struct Installed {
487 missing: Vec<Utf8PathBuf>,
489 differing: Vec<Utf8PathBuf>,
491 matching: usize,
493 all_recorded: bool,
497}
498
499fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &SkillRecord) -> Installed {
501 let mut found = Installed {
502 missing: Vec::new(),
503 differing: Vec::new(),
504 matching: 0,
505 all_recorded: true,
506 };
507 for (destination, bytes) in planned {
508 match std::fs::read(&destination) {
509 Ok(held) if held == bytes => found.matching += 1,
510 Ok(held) => {
511 if !record.wrote(&destination, &Sha256::of(&held)) {
512 found.all_recorded = false;
513 }
514 found.differing.push(destination);
515 }
516 Err(_) => found.missing.push(destination),
517 }
518 }
519 found
520}
521
522const fn reinstall(all_recorded: bool) -> &'static str {
526 if all_recorded {
527 "sdd skill install --apply"
528 } else {
529 "sdd skill install --apply --force"
530 }
531}
532
533fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
536 let mut current = Some(path);
537 while let Some(dir) = current {
538 if dir.is_dir() {
539 return Some(dir.to_owned());
540 }
541 current = dir.parent();
542 }
543 None
544}
545
546fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
548 let probe = dir.join(format!(".sdd-probe-{}", std::process::id()));
549 let written = std::fs::write(&probe, b"probe");
550 let _ = std::fs::remove_file(&probe);
551 written
552}
553
554#[cfg(test)]
555mod tests {
556 #![allow(
557 clippy::unwrap_used,
558 reason = "a test panics as its failure signal, not as control flow"
559 )]
560
561 use super::*;
562
563 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
564 Utf8PathBuf::from(dir.path().to_str().unwrap())
565 }
566
567 #[test]
568 fn nearest_existing_walks_up_to_the_first_directory() {
569 let dir = tempfile::tempdir().unwrap();
570 let root = utf8(&dir);
571 assert_eq!(nearest_existing(&root).as_deref(), Some(root.as_path()));
572 assert_eq!(
573 nearest_existing(&root.join("a/b/c")).as_deref(),
574 Some(root.as_path())
575 );
576 }
577
578 #[test]
579 fn a_write_probe_leaves_nothing_behind() {
580 let dir = tempfile::tempdir().unwrap();
581 let root = utf8(&dir);
582 accepts_a_write(&root).unwrap();
583 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
584 }
585
586 #[test]
589 fn the_judge_tells_stale_bytes_from_the_users_own() {
590 let dir = tempfile::tempdir().unwrap();
591 let root = utf8(&dir);
592 let matching = root.join("matching.md");
593 let stale = root.join("stale.md");
594 let edited = root.join("edited.md");
595 let missing = root.join("missing.md");
596 std::fs::write(&matching, b"payload").unwrap();
597 std::fs::write(&stale, b"older release").unwrap();
598 std::fs::write(&edited, b"the user's own").unwrap();
599
600 let mut record = SkillRecord::new();
601 record
602 .written
603 .insert(stale.clone(), Sha256::of(b"older release"));
604
605 let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![
606 (matching, b"payload"),
607 (stale, b"payload"),
608 (missing.clone(), b"payload"),
609 ];
610 let found = judge(planned, &record);
611 assert_eq!(found.matching, 1);
612 assert_eq!(found.differing.len(), 1);
613 assert_eq!(found.missing, vec![missing]);
614 assert!(found.all_recorded, "the record vouches for the stale copy");
615
616 let planned: Vec<(Utf8PathBuf, &'static [u8])> = vec![(edited, b"payload")];
617 let found = judge(planned, &record);
618 assert!(
619 !found.all_recorded,
620 "bytes the record cannot account for are the user's"
621 );
622 }
623
624 #[test]
625 fn the_reinstall_needs_force_only_over_the_users_bytes() {
626 assert_eq!(reinstall(true), "sdd skill install --apply");
627 assert_eq!(reinstall(false), "sdd skill install --apply --force");
628 }
629}