diff --git a/Cargo.lock b/Cargo.lock index d0c076d..02a5839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -184,6 +190,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -299,6 +314,7 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.18", "tokio", @@ -307,6 +323,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", "vfs", "wiremock", ] @@ -386,6 +403,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -413,6 +439,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -459,6 +495,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -551,6 +597,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -664,6 +716,16 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -691,6 +753,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "h2" version = "0.4.13" @@ -710,6 +785,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -916,6 +1000,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -944,7 +1034,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1050,6 +1142,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" @@ -1298,6 +1396,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1753,6 +1861,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2187,6 +2306,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -2205,6 +2330,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2235,12 +2366,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vfs" version = "0.12.2" @@ -2293,6 +2441,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2352,6 +2509,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -2365,6 +2544,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -2696,6 +2887,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 29eebb3..a4b6d03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ console = "0.16.2" vfs = "0.12" thiserror = "2" rustls-native-certs = "0.8.3" +sha2 = "0.10.9" +uuid = { version = "1.18.1", features = ["v4"] } [dev-dependencies] tokio = { version = "1.49", features = ["full", "test-util"] } diff --git a/src/main.rs b/src/main.rs index 039d8e3..4183b99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,9 +118,15 @@ fn try_read_openclaw_config_path(clawshell_config_file: &Path) -> Option Result, Box> { +) -> Result, Box> { let Some(skill) = onboard::render_openclaw_email_messages_skill(ob_config) else { return Ok(None); }; @@ -129,14 +135,20 @@ fn write_onboard_openclaw_skill( let skill_dir = openclaw_root.join("skills").join(skill.name); std::fs::create_dir_all(&skill_dir)?; + let mut managed_files: Vec<(String, String)> = Vec::new(); for file in skill.files { let path = skill_dir.join(file.relative_path); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(path, file.content)?; + std::fs::write(path, &file.content)?; + managed_files.push((file.relative_path.to_string(), file.content)); } + let metadata = onboard::build_managed_skill_metadata(skill.name, &managed_files); + onboard::write_managed_skill_metadata(&skill_dir, &metadata)?; + let manifest_entry = onboard::build_managed_skill_manifest_entry(&skill_dir, &metadata); + if !align_owner_with_openclaw_path(&skill_dir, &ob_config.openclaw_config_path)? { warn!( path = %skill_dir.display(), @@ -145,7 +157,10 @@ fn write_onboard_openclaw_skill( ); } - Ok(Some(skill_dir)) + Ok(Some(WrittenOpenclawSkill { + path: skill_dir, + manifest_entry, + })) } fn resolve_openclaw_owner_spec(openclaw_path: &Path) -> Result, Box> { @@ -1061,15 +1076,16 @@ fn cmd_onboard() -> Result<(), Box> { println!(); let openclaw_skill_edit_approved = tui::prompt_confirm("Write OpenClaw skill files for email integration", true)?; - let openclaw_skill_path = if openclaw_skill_edit_approved { + let openclaw_skill = if openclaw_skill_edit_approved { write_onboard_openclaw_skill(&ob_config)? } else { None }; if openclaw_skill_edit_approved { - if let Some(path) = openclaw_skill_path.as_ref() { + if let Some(skill) = openclaw_skill.as_ref() { + onboard::upsert_managed_skill_manifest_entry(&config_file, &skill.manifest_entry)?; tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written"); - tui::print_info("OpenClaw skill", &path.display().to_string()); + tui::print_info("OpenClaw skill", &skill.path.display().to_string()); } else { tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills skipped"); } @@ -1195,8 +1211,8 @@ fn cmd_onboard() -> Result<(), Box> { } let mut openclaw_runner = openclaw_cli::RealOpenclawRunner; openclaw_cli::apply_onboard_openclaw_config(&mut openclaw_runner, &ob_config)?; - if let Some(skill_path) = openclaw_skill_path.as_ref() { - align_owner_with_openclaw_path(skill_path, openclaw_path)?; + if let Some(skill) = openclaw_skill.as_ref() { + align_owner_with_openclaw_path(&skill.path, openclaw_path)?; } tui::print_step_done(8, TOTAL_STEPS, "OpenClaw config updated"); @@ -1410,6 +1426,23 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { .join("skills") .join(onboard::OPENCLAW_EMAIL_MESSAGES_SKILL_NAME) }); + let openclaw_skill_manifest = if clawshell_config_file.exists() { + onboard::read_managed_skill_manifest_entry( + &clawshell_config_file, + onboard::OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, + ) + } else { + None + }; + let openclaw_skill_inspection = if let Some(skill_dir) = openclaw_skill_dir.as_ref() { + onboard::inspect_managed_skill_for_uninstall( + skill_dir, + onboard::OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, + openclaw_skill_manifest.as_ref(), + ) + } else { + onboard::ManagedSkillInspection::missing() + }; tui::print_warning("This will remove the following:"); tui::print_info("ClawShell", "Stop if running"); @@ -1421,7 +1454,35 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { if let Some(skill_dir) = openclaw_skill_dir.as_ref() && skill_dir.exists() { - tui::print_info("OpenClaw skill", &skill_dir.display().to_string()); + match openclaw_skill_inspection.state { + onboard::ManagedSkillUninstallState::ManagedUnchanged => { + tui::print_info( + "OpenClaw skill", + &format!("{} (managed)", skill_dir.display()), + ); + } + onboard::ManagedSkillUninstallState::ManagedModified => { + tui::print_info( + "OpenClaw skill", + &format!("{} (managed, modified)", skill_dir.display()), + ); + tui::print_warning(&format!( + "Managed OpenClaw skill has local modifications: {}", + openclaw_skill_inspection.detail + )); + } + onboard::ManagedSkillUninstallState::Unmanaged => { + tui::print_info( + "OpenClaw skill", + &format!("{} (legacy/unverified)", skill_dir.display()), + ); + tui::print_warning(&format!( + "Skill ownership is not verified: {}", + openclaw_skill_inspection.detail + )); + } + onboard::ManagedSkillUninstallState::Missing => {} + } } tui::print_info("Binary", &format!("{} (preserved)", exe_path.display())); tui::print_info("System user", "clawshell"); @@ -1463,28 +1524,78 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box> { if let Some(skill_dir) = openclaw_skill_dir.as_ref() && skill_dir.exists() { - let remove_skill = if skip_confirm { - true - } else { - let prompt = format!("Remove OpenClaw skill at {}?", skill_dir.display()); - tui::prompt_confirm(&prompt, true)? + let remove_skill_dir = |path: &Path| match std::fs::remove_dir_all(path) { + Ok(()) => tui::print_success(&format!("OpenClaw skill removed: {}", path.display())), + Err(error) => tui::print_warning(&format!( + "Failed to remove OpenClaw skill at {}: {error}", + path.display() + )), }; - if remove_skill { - match std::fs::remove_dir_all(skill_dir) { - Ok(()) => { - tui::print_success(&format!("OpenClaw skill removed: {}", skill_dir.display())) + match openclaw_skill_inspection.state { + onboard::ManagedSkillUninstallState::ManagedUnchanged => { + let remove_skill = if skip_confirm { + true + } else { + let prompt = + format!("Remove managed OpenClaw skill at {}?", skill_dir.display()); + tui::prompt_confirm(&prompt, true)? + }; + + if remove_skill { + remove_skill_dir(skill_dir); + } else { + tui::print_info( + "Skipped", + &format!("OpenClaw skill preserved at {}", skill_dir.display()), + ); } - Err(error) => tui::print_warning(&format!( - "Failed to remove OpenClaw skill at {}: {error}", - skill_dir.display() - )), } - } else { - tui::print_info( - "Skipped", - &format!("OpenClaw skill preserved at {}", skill_dir.display()), - ); + onboard::ManagedSkillUninstallState::ManagedModified => { + if skip_confirm { + tui::print_warning(&format!( + "Preserving managed-but-modified OpenClaw skill at {} (--yes does not force-delete modified skills).", + skill_dir.display() + )); + } else { + let prompt = format!( + "Managed OpenClaw skill at {} was modified. Delete anyway?", + skill_dir.display() + ); + let remove_skill = tui::prompt_confirm(&prompt, false)?; + if remove_skill { + remove_skill_dir(skill_dir); + } else { + tui::print_info( + "Skipped", + &format!("OpenClaw skill preserved at {}", skill_dir.display()), + ); + } + } + } + onboard::ManagedSkillUninstallState::Unmanaged => { + if skip_confirm { + tui::print_warning(&format!( + "Preserving unverified OpenClaw skill at {} (--yes does not force-delete unverified skills).", + skill_dir.display() + )); + } else { + let prompt = format!( + "No ClawShell ownership marker/manifest match at {}. Delete anyway? (high risk)", + skill_dir.display() + ); + let remove_skill = tui::prompt_confirm(&prompt, false)?; + if remove_skill { + remove_skill_dir(skill_dir); + } else { + tui::print_info( + "Skipped", + &format!("OpenClaw skill preserved at {}", skill_dir.display()), + ); + } + } + } + onboard::ManagedSkillUninstallState::Missing => {} } } diff --git a/src/onboard/managed_skills.rs b/src/onboard/managed_skills.rs new file mode 100644 index 0000000..4b428f3 --- /dev/null +++ b/src/onboard/managed_skills.rs @@ -0,0 +1,425 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const MANAGED_BY_CLAWSHELL: &str = "clawshell"; +const MANAGED_SKILL_SCHEMA_VERSION: u32 = 1; +const MANIFEST_CONFIG_KEY: &str = "managed_openclaw_skills"; +pub const MANAGED_SKILL_METADATA_FILE_NAME: &str = ".clawshell-skill.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedSkillMetadata { + pub managed_by: String, + pub schema_version: u32, + pub skill_name: String, + pub install_id: String, + pub created_at_unix_seconds: u64, + pub files: Vec, + pub hashes: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ManagedSkillManifestEntry { + pub schema_version: u32, + pub skill_name: String, + pub skill_dir: String, + pub install_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagedSkillUninstallState { + Missing, + ManagedUnchanged, + ManagedModified, + Unmanaged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedSkillInspection { + pub state: ManagedSkillUninstallState, + pub detail: String, +} + +impl ManagedSkillInspection { + pub fn missing() -> Self { + Self { + state: ManagedSkillUninstallState::Missing, + detail: "skill directory missing".to_string(), + } + } +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn hash_content(content: &[u8]) -> String { + let digest = Sha256::digest(content); + format!("{digest:x}") +} + +fn as_unmanaged(detail: impl Into) -> ManagedSkillInspection { + ManagedSkillInspection { + state: ManagedSkillUninstallState::Unmanaged, + detail: detail.into(), + } +} + +fn as_managed_modified(detail: impl Into) -> ManagedSkillInspection { + ManagedSkillInspection { + state: ManagedSkillUninstallState::ManagedModified, + detail: detail.into(), + } +} + +fn canonicalize_or_original(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +pub fn build_managed_skill_metadata( + skill_name: &str, + managed_files: &[(String, String)], +) -> ManagedSkillMetadata { + let mut files = Vec::with_capacity(managed_files.len()); + let mut hashes = BTreeMap::new(); + for (relative_path, content) in managed_files { + files.push(relative_path.clone()); + hashes.insert(relative_path.clone(), hash_content(content.as_bytes())); + } + files.sort(); + files.dedup(); + + ManagedSkillMetadata { + managed_by: MANAGED_BY_CLAWSHELL.to_string(), + schema_version: MANAGED_SKILL_SCHEMA_VERSION, + skill_name: skill_name.to_string(), + install_id: Uuid::new_v4().to_string(), + created_at_unix_seconds: now_unix_seconds(), + files, + hashes, + } +} + +pub fn write_managed_skill_metadata( + skill_dir: &Path, + metadata: &ManagedSkillMetadata, +) -> Result> { + let metadata_path = skill_dir.join(MANAGED_SKILL_METADATA_FILE_NAME); + let payload = serde_json::to_string_pretty(metadata)?; + std::fs::write(&metadata_path, payload)?; + Ok(metadata_path) +} + +pub fn build_managed_skill_manifest_entry( + skill_dir: &Path, + metadata: &ManagedSkillMetadata, +) -> ManagedSkillManifestEntry { + ManagedSkillManifestEntry { + schema_version: metadata.schema_version, + skill_name: metadata.skill_name.clone(), + skill_dir: skill_dir.to_string_lossy().to_string(), + install_id: metadata.install_id.clone(), + } +} + +pub fn upsert_managed_skill_manifest_entry( + clawshell_config_path: &Path, + entry: &ManagedSkillManifestEntry, +) -> Result<(), Box> { + let content = std::fs::read_to_string(clawshell_config_path)?; + let mut json = serde_json::from_str::(&content)?; + let root = json.as_object_mut().ok_or_else(|| { + format!( + "Invalid ClawShell config JSON at {}: expected an object", + clawshell_config_path.display() + ) + })?; + + let manifest_node = root + .entry(MANIFEST_CONFIG_KEY) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + if !manifest_node.is_array() { + *manifest_node = serde_json::Value::Array(Vec::new()); + } + + let manifest_items = manifest_node + .as_array_mut() + .expect("manifest_node must be array"); + manifest_items.retain(|item| { + item.get("skill_name") + .and_then(|value| value.as_str()) + .is_none_or(|skill_name| skill_name != entry.skill_name) + }); + manifest_items.push(serde_json::to_value(entry)?); + + std::fs::write(clawshell_config_path, serde_json::to_string_pretty(&json)?)?; + Ok(()) +} + +pub fn read_managed_skill_manifest_entry( + clawshell_config_path: &Path, + skill_name: &str, +) -> Option { + let content = std::fs::read_to_string(clawshell_config_path).ok()?; + let json = serde_json::from_str::(&content).ok()?; + let manifest = json.get(MANIFEST_CONFIG_KEY)?.as_array()?; + for item in manifest { + if let Ok(parsed) = serde_json::from_value::(item.clone()) + && parsed.skill_name == skill_name + { + return Some(parsed); + } + } + None +} + +pub fn inspect_managed_skill_for_uninstall( + skill_dir: &Path, + expected_skill_name: &str, + manifest_entry: Option<&ManagedSkillManifestEntry>, +) -> ManagedSkillInspection { + if !skill_dir.exists() { + return ManagedSkillInspection::missing(); + } + + let metadata_path = skill_dir.join(MANAGED_SKILL_METADATA_FILE_NAME); + if !metadata_path.exists() { + return as_unmanaged(format!( + "ownership marker missing ({})", + metadata_path.display() + )); + } + + let metadata_content = match std::fs::read_to_string(&metadata_path) { + Ok(content) => content, + Err(error) => { + return as_unmanaged(format!( + "failed to read ownership marker {}: {error}", + metadata_path.display() + )); + } + }; + + let metadata = match serde_json::from_str::(&metadata_content) { + Ok(metadata) => metadata, + Err(error) => { + return as_unmanaged(format!( + "ownership marker parse failed at {}: {error}", + metadata_path.display() + )); + } + }; + + if metadata.managed_by != MANAGED_BY_CLAWSHELL { + return as_unmanaged(format!( + "ownership marker managed_by mismatch: expected '{MANAGED_BY_CLAWSHELL}', got '{}'", + metadata.managed_by + )); + } + if metadata.schema_version != MANAGED_SKILL_SCHEMA_VERSION { + return as_unmanaged(format!( + "ownership marker schema mismatch: expected {}, got {}", + MANAGED_SKILL_SCHEMA_VERSION, metadata.schema_version + )); + } + if metadata.skill_name != expected_skill_name { + return as_unmanaged(format!( + "ownership marker skill_name mismatch: expected '{expected_skill_name}', got '{}'", + metadata.skill_name + )); + } + + let Some(manifest) = manifest_entry else { + return as_unmanaged("manifest entry missing in ClawShell config"); + }; + if manifest.schema_version != metadata.schema_version { + return as_unmanaged(format!( + "manifest schema mismatch: marker={}, manifest={}", + metadata.schema_version, manifest.schema_version + )); + } + if manifest.skill_name != metadata.skill_name { + return as_unmanaged(format!( + "manifest skill_name mismatch: marker='{}', manifest='{}'", + metadata.skill_name, manifest.skill_name + )); + } + if manifest.install_id != metadata.install_id { + return as_unmanaged(format!( + "manifest install_id mismatch: marker='{}', manifest='{}'", + metadata.install_id, manifest.install_id + )); + } + + let manifest_dir = PathBuf::from(&manifest.skill_dir); + if canonicalize_or_original(skill_dir) != canonicalize_or_original(&manifest_dir) { + return as_unmanaged(format!( + "manifest path mismatch: found '{}', manifest '{}'", + skill_dir.display(), + manifest.skill_dir + )); + } + + for relative_path in &metadata.files { + let Some(expected_hash) = metadata.hashes.get(relative_path) else { + return as_unmanaged(format!( + "ownership marker missing hash for managed file '{relative_path}'" + )); + }; + + let full_path = skill_dir.join(relative_path); + let content = match std::fs::read(&full_path) { + Ok(content) => content, + Err(error) => { + return as_managed_modified(format!( + "managed file changed or missing '{}': {error}", + full_path.display() + )); + } + }; + let current_hash = hash_content(&content); + if current_hash != *expected_hash { + return as_managed_modified(format!( + "managed file hash changed for '{}'", + full_path.display() + )); + } + } + + ManagedSkillInspection { + state: ManagedSkillUninstallState::ManagedUnchanged, + detail: "ownership marker and manifest verified".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_files(skill_dir: &Path, files: &[(String, String)]) { + for (relative_path, content) in files { + let full_path = skill_dir.join(relative_path); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(full_path, content).unwrap(); + } + } + + #[test] + fn test_inspect_managed_skill_unchanged() { + let temp_dir = tempdir().unwrap(); + let skill_dir = temp_dir.path().join("skills/get-email-messages"); + std::fs::create_dir_all(&skill_dir).unwrap(); + let managed_files = vec![ + ("SKILL.md".to_string(), "hello".to_string()), + ( + "references/api-usage.md".to_string(), + "reference".to_string(), + ), + ]; + write_files(&skill_dir, &managed_files); + + let metadata = build_managed_skill_metadata("get-email-messages", &managed_files); + write_managed_skill_metadata(&skill_dir, &metadata).unwrap(); + let manifest = build_managed_skill_manifest_entry(&skill_dir, &metadata); + + let inspection = + inspect_managed_skill_for_uninstall(&skill_dir, "get-email-messages", Some(&manifest)); + assert_eq!( + inspection.state, + ManagedSkillUninstallState::ManagedUnchanged + ); + } + + #[test] + fn test_inspect_managed_skill_modified() { + let temp_dir = tempdir().unwrap(); + let skill_dir = temp_dir.path().join("skills/get-email-messages"); + std::fs::create_dir_all(&skill_dir).unwrap(); + let managed_files = vec![("SKILL.md".to_string(), "before".to_string())]; + write_files(&skill_dir, &managed_files); + + let metadata = build_managed_skill_metadata("get-email-messages", &managed_files); + write_managed_skill_metadata(&skill_dir, &metadata).unwrap(); + let manifest = build_managed_skill_manifest_entry(&skill_dir, &metadata); + + std::fs::write(skill_dir.join("SKILL.md"), "after").unwrap(); + + let inspection = + inspect_managed_skill_for_uninstall(&skill_dir, "get-email-messages", Some(&manifest)); + assert_eq!( + inspection.state, + ManagedSkillUninstallState::ManagedModified + ); + assert!(inspection.detail.contains("hash changed")); + } + + #[test] + fn test_inspect_managed_skill_unmanaged_without_marker() { + let temp_dir = tempdir().unwrap(); + let skill_dir = temp_dir.path().join("skills/get-email-messages"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), "content").unwrap(); + + let inspection = + inspect_managed_skill_for_uninstall(&skill_dir, "get-email-messages", None); + assert_eq!(inspection.state, ManagedSkillUninstallState::Unmanaged); + assert!(inspection.detail.contains("ownership marker missing")); + } + + #[test] + fn test_inspect_managed_skill_unmanaged_on_manifest_mismatch() { + let temp_dir = tempdir().unwrap(); + let skill_dir = temp_dir.path().join("skills/get-email-messages"); + std::fs::create_dir_all(&skill_dir).unwrap(); + let managed_files = vec![("SKILL.md".to_string(), "hello".to_string())]; + write_files(&skill_dir, &managed_files); + + let metadata = build_managed_skill_metadata("get-email-messages", &managed_files); + write_managed_skill_metadata(&skill_dir, &metadata).unwrap(); + let mut manifest = build_managed_skill_manifest_entry(&skill_dir, &metadata); + manifest.install_id = Uuid::new_v4().to_string(); + + let inspection = + inspect_managed_skill_for_uninstall(&skill_dir, "get-email-messages", Some(&manifest)); + assert_eq!(inspection.state, ManagedSkillUninstallState::Unmanaged); + assert!(inspection.detail.contains("install_id mismatch")); + } + + #[test] + fn test_upsert_and_read_manifest_entry() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("config.json"); + std::fs::write( + &config_path, + r#"{"real_api_key":"x","virtual_api_key":"y","provider":"openai","model":"gpt"}"#, + ) + .unwrap(); + + let entry = ManagedSkillManifestEntry { + schema_version: 1, + skill_name: "get-email-messages".to_string(), + skill_dir: "/tmp/.openclaw/skills/get-email-messages".to_string(), + install_id: Uuid::new_v4().to_string(), + }; + upsert_managed_skill_manifest_entry(&config_path, &entry).unwrap(); + + let parsed = read_managed_skill_manifest_entry(&config_path, "get-email-messages").unwrap(); + assert_eq!(parsed, entry); + + let mut replacement = parsed.clone(); + replacement.install_id = Uuid::new_v4().to_string(); + upsert_managed_skill_manifest_entry(&config_path, &replacement).unwrap(); + let updated = + read_managed_skill_manifest_entry(&config_path, "get-email-messages").unwrap(); + assert_eq!(updated, replacement); + } +} diff --git a/src/onboard/mod.rs b/src/onboard/mod.rs index a1aec77..e9b370b 100644 --- a/src/onboard/mod.rs +++ b/src/onboard/mod.rs @@ -3,6 +3,7 @@ mod backup; mod config_render; mod credentials; mod interactive; +mod managed_skills; mod openclaw_json; mod skills; mod types; @@ -20,6 +21,12 @@ pub use credentials::{ cleanup_openclaw_provider_credentials, preview_openclaw_provider_credential_cleanup, }; pub use interactive::collect_onboard_config_tui; +pub use managed_skills::{ + ManagedSkillInspection, ManagedSkillManifestEntry, ManagedSkillUninstallState, + build_managed_skill_manifest_entry, build_managed_skill_metadata, + inspect_managed_skill_for_uninstall, read_managed_skill_manifest_entry, + upsert_managed_skill_manifest_entry, write_managed_skill_metadata, +}; pub use openclaw_json::{patch_openclaw_config_for_clawshell, remove_clawshell_openclaw_entries}; pub use skills::render_openclaw_email_messages_skill; pub use types::{OPENCLAW_EMAIL_MESSAGES_SKILL_NAME, OnboardConfig, OpenclawFileRemovalPreview};