diff --git a/CHANGELOG.md b/CHANGELOG.md index a53581c..f4d5821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ version tags such as `v0.3.0`. ## Unreleased -- No unreleased changes. +- Group copied skills from the same verified v3 `well-known` installer source into one display-only Installed Source Collection, while keeping source matching strict and per-skill import safety unchanged. ## 0.9.1 diff --git a/Cargo.lock b/Cargo.lock index c33331a..b7925fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3236,6 +3236,7 @@ dependencies = [ "sha2 0.11.0", "skillbox-git", "skillbox-github", + "url", ] [[package]] diff --git a/README.md b/README.md index 28861a7..62cf1d8 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ A 30-second v0.9.0 overview of SkillBox: runtime-aware workspaces, review-before - **Evidence-aware Calls, references, and operation history.** Count locally confirmed executions plus defensible structured invocations as Calls, keep lower-signal history references separate, and explain coverage without storing full chat transcripts. - **Safe storage and deployment defaults.** Use ordered SQLite migrations, recovery backups, integrity checks, and ownership-checked symlinks instead of silently overwriting runtime content. - **Git-backed local and GitHub collections.** Import Review groups skills from the same local Git worktree or reviewed GitHub repository snapshot into one collection card while keeping each child independently importable, deployable, and usage-tracked. The current project UI makes one shared User/Remote choice in the collection header, then selects or clears all eligible children as a group; unresolved or mixed type state blocks selection and apply until you choose explicitly. GitHub preview/apply fetches one bounded ref, shows the resolved SHA and child status, and never deploys automatically. Collection-level update/rollback remains planned for a later v0.9.x release. -- **Installed-source provenance.** Copied skills with valid v3 installer lockfile entries can appear under one normalized GitHub source collection even without a local Git worktree. This is display-only provenance: it does not invent a branch, HEAD, or update authority, and each child keeps the normal reviewed per-skill import path. +- **Installed-source provenance.** Copied skills with valid v3 installer lockfile entries can appear under one verified GitHub or HTTPS well-known source collection even without a local Git worktree. This is display-only provenance: it does not invent a branch, HEAD, or update authority, and each child keeps the normal reviewed per-skill import path. - **Compatibility before deployment.** Rust-owned runtime profiles identify each workspace and report preserved frontmatter warnings or hard blockers before a confirmed symlink deployment. - **Signed macOS distribution.** Install a notarized DMG or Homebrew cask and apply signed app updates only after confirmation. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7ac88ae..173e9ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,7 +33,7 @@ SkillBox 是一个 local-first 的 macOS 桌面应用,带 Rust core/CLI,用 - **按证据分类的 Calls、引用与操作历史。** Calls 只统计本机 confirmed execution 与可辩护的 structured invocation,低信号 history references 单独展示,并且不保存完整聊天 transcript。 - **安全的存储与部署默认值。** 使用顺序 SQLite migrations、恢复备份、完整性检查和 ownership-checked symlink,不静默覆盖 runtime 内容。 - **Git-backed 本地与 GitHub Skill Collections。** Import Review 会把同一 Git worktree 或经 review 的 GitHub repository snapshot 中的 skills 聚合为一个 collection 卡片,同时保留每个 child 独立的导入、部署和 usage 边界。当前项目 UI 在 collection header 统一选择一次 User/Remote,并可一次选中或清除全部 eligible children;类型未决或混合时,必须先显式选择,collection selection/apply 才会开放。GitHub preview/apply 对一个有界 ref 只 fetch 一次,展示 resolved SHA 与 child 状态,绝不自动部署。Collection 级更新/回滚仍计划在后续 v0.9.x 实现。 -- **已安装来源 provenance。** 没有本地 Git worktree、但拥有有效 v3 installer lockfile 条目的复制 skill,也可以按规范化 GitHub source 聚合展示。这只是来源展示,不会伪造 branch、HEAD 或更新权限;每个 child 仍走原有的逐 skill review/import 流程。 +- **已安装来源 provenance。** 没有本地 Git worktree、但拥有有效 v3 installer lockfile 条目的复制 skill,也可以按已验证的 GitHub 或 HTTPS well-known source 聚合展示。这只是来源展示,不会伪造 branch、HEAD 或更新权限;每个 child 仍走原有的逐 skill review/import 流程。 - **部署前检查 compatibility。** Rust-owned runtime profiles 标识 workspace,并在确认 symlink 部署前报告会原样保留的 frontmatter warnings 或 hard blockers。 - **签名的 macOS 分发。** 可安装已公证 DMG 或 Homebrew cask,app 更新也只在用户确认后应用。 diff --git a/crates/skillbox-core/Cargo.toml b/crates/skillbox-core/Cargo.toml index 7f3b0dc..83a0f5b 100644 --- a/crates/skillbox-core/Cargo.toml +++ b/crates/skillbox-core/Cargo.toml @@ -15,3 +15,4 @@ serde_yaml_ng = "0.10" sha2 = "0.11" skillbox-git = { path = "../skillbox-git" } skillbox-github = { path = "../skillbox-github" } +url = "2" diff --git a/crates/skillbox-core/src/installed_sources.rs b/crates/skillbox-core/src/installed_sources.rs index 4c42bbb..3d5cd30 100644 --- a/crates/skillbox-core/src/installed_sources.rs +++ b/crates/skillbox-core/src/installed_sources.rs @@ -3,6 +3,7 @@ use serde_json::Value; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; +use url::Url; const MAX_LOCKFILE_BYTES: u64 = 5 * 1024 * 1024; const MAX_LOCKFILE_ENTRIES: usize = 10_000; @@ -23,10 +24,27 @@ pub(crate) struct InstalledSourceDiscoveryStats { struct LockfileEntry { root: PathBuf, skill_name: String, - source_url: String, + source_kind: LockfileSourceKind, + collection_url: String, + skill_source_url: String, skill_path: String, } +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum LockfileSourceKind { + Github, + WellKnown, +} + +impl LockfileSourceKind { + fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::WellKnown => "well-known", + } + } +} + pub(crate) fn discover_installed_source_collections( roots: &[PathBuf], candidates: &[ImportCandidate], @@ -71,8 +89,8 @@ pub(crate) fn discover_installed_source_collections( .map(|group| (group.name.to_ascii_lowercase(), group)) .collect::>(); let mut collection_children = - BTreeMap::>::new(); - let mut seen = HashSet::<(String, String, String)>::new(); + BTreeMap::<(String, String), BTreeMap>::new(); + let mut seen = HashSet::<(String, String, String, String)>::new(); for candidate in candidates { let name = candidate.name.to_ascii_lowercase(); @@ -99,7 +117,8 @@ pub(crate) fn discover_installed_source_collections( continue; }; let key = ( - entry.source_url.clone(), + entry.source_kind.as_str().to_string(), + entry.collection_url.clone(), group.id.clone(), variant.id.clone(), ); @@ -109,7 +128,10 @@ pub(crate) fn discover_installed_source_collections( stats.lockfile_matches += 1; let child = installed_source_child(entry, group, variant); collection_children - .entry(entry.source_url.clone()) + .entry(( + entry.source_kind.as_str().to_string(), + entry.collection_url.clone(), + )) .or_default() .insert(child.id.clone(), child); } @@ -117,7 +139,7 @@ pub(crate) fn discover_installed_source_collections( let collections = collection_children .into_iter() - .filter_map(|(source_url, children)| { + .filter_map(|((source_kind, source_url), children)| { let children = children.into_values().collect::>(); // A single installer-provenance match is not enough to establish // a useful collection. Keep it in the normal standalone group so @@ -140,11 +162,12 @@ pub(crate) fn discover_installed_source_collections( }) .collect::>() .join("\n"); - let identity = format!("installed-source-v1\n{source_url}\n{child_seed}"); - let display_name = source_url - .strip_prefix("https://github.com/") - .unwrap_or(&source_url) - .to_string(); + let identity = if source_kind == LockfileSourceKind::Github.as_str() { + format!("installed-source-v1\n{source_url}\n{child_seed}") + } else { + format!("installed-source-v2\n{source_kind}\n{source_url}\n{child_seed}") + }; + let display_name = installed_source_display_name(&source_url); stats.installed_source_collections += 1; Some(ImportCandidateCollection { id: format!("installed-source-{}", &sha256(&identity)[..16]), @@ -245,13 +268,38 @@ fn parse_lockfile_entry(name: &str, value: &Value, root: &Path) -> Option { + let source_url = bounded_string(object.get("sourceUrl")?)?; + let source_url = skillbox_github::normalize_github_repo_url(&source_url).ok()?; + let skill_path = bounded_string(object.get("skillPath")?)?; + validate_lockfile_skill_path(&skill_path, name)?; + ( + LockfileSourceKind::Github, + source_url.clone(), + source_url, + skill_path, + ) + } + "well-known" => { + let source_base_url = bounded_string(object.get("sourceBaseUrl")?)?; + let source_url = bounded_string(object.get("sourceUrl")?)?; + let (source_base_url, source_url) = + validate_well_known_source(&source_base_url, &source_url, name)?; + let digest = bounded_string(object.get("wellKnownDigest")?)?; + validate_sha256_digest(&digest)?; + if let Some(skill_folder_hash) = object.get("skillFolderHash") { + bounded_string(skill_folder_hash)?; + } + ( + LockfileSourceKind::WellKnown, + source_base_url, + source_url, + format!("skills/{name}/SKILL.md"), + ) + } + _ => return None, + }; if let Some(plugin_name) = object.get("pluginName") { // The installer may use this optional field for a package or collection // identifier (for example, several skills can belong to one plugin). @@ -262,7 +310,9 @@ fn parse_lockfile_entry(name: &str, value: &Value, root: &Path) -> Option Option<&str> { (!name.is_empty()).then_some(name) } +fn validate_well_known_source( + source_base_url: &str, + source_url: &str, + entry_name: &str, +) -> Option<(String, String)> { + validate_lockfile_skill_path(&format!("skills/{entry_name}/SKILL.md"), entry_name)?; + let base = parse_safe_https_url(source_base_url)?; + let source = parse_safe_https_url(source_url)?; + let normalized_base = base.as_str().trim_end_matches('/').to_string(); + let expected = Url::parse(&format!( + "{normalized_base}/.well-known/skills/{entry_name}/SKILL.md" + )) + .ok()?; + (source == expected).then(|| (normalized_base, expected.to_string())) +} + +fn parse_safe_https_url(value: &str) -> Option { + if value.is_empty() + || value.len() > MAX_LOCKFILE_STRING_BYTES + || value.trim() != value + || value.contains('\\') + || value.chars().any(char::is_control) + { + return None; + } + let url = Url::parse(value).ok()?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + || url.cannot_be_a_base() + { + return None; + } + let safe_path = url + .path_segments()? + .filter(|segment| !segment.is_empty()) + .all(|segment| { + segment != "." + && segment != ".." + && segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~')) + }); + safe_path.then_some(url) +} + +fn validate_sha256_digest(value: &str) -> Option<()> { + let digest = value.strip_prefix("sha256:")?; + (digest.len() == 64 && digest.chars().all(|ch| ch.is_ascii_hexdigit())).then_some(()) +} + +fn installed_source_display_name(source_url: &str) -> String { + if let Some(value) = source_url.strip_prefix("https://github.com/") { + return value.to_string(); + } + let Ok(url) = Url::parse(source_url) else { + return source_url.to_string(); + }; + let Some(host) = url.host_str() else { + return source_url.to_string(); + }; + let host = match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let path = url.path().trim_matches('/'); + if path.is_empty() { + host + } else { + format!("{host}/{path}") + } +} + fn candidate_matches_lock_entry(candidate: &ImportCandidate, entry: &LockfileEntry) -> bool { if !same_path(candidate.source_root.as_deref(), Some(&entry.root)) { return false; @@ -338,10 +464,21 @@ fn installed_source_child( .strip_suffix("/SKILL.md") .unwrap_or(&entry.skill_path) .to_string(); - let identity = format!( - "{}\n{}\n{}\n{}", - entry.source_url, group.id, variant.id, relative_path - ); + let identity = match entry.source_kind { + LockfileSourceKind::Github => format!( + "{}\n{}\n{}\n{}", + entry.collection_url, group.id, variant.id, relative_path + ), + LockfileSourceKind::WellKnown => format!( + "installed-source-child-v2\n{}\n{}\n{}\n{}\n{}\n{}", + entry.source_kind.as_str(), + entry.collection_url, + entry.skill_source_url, + group.id, + variant.id, + relative_path + ), + }; ImportCandidateCollectionChild { id: format!("child-{}", &sha256(&identity)[..16]), group_id: group.id.clone(), diff --git a/crates/skillbox-core/src/tests.rs b/crates/skillbox-core/src/tests.rs index 8398f5b..81f86ff 100644 --- a/crates/skillbox-core/src/tests.rs +++ b/crates/skillbox-core/src/tests.rs @@ -11100,6 +11100,261 @@ fn scan_import_candidates_groups_installer_plugin_children_without_using_plugin_ assert_eq!(scan.diagnostics.installed_source_collections, 1); } +#[test] +fn scan_import_candidates_groups_verified_well_known_children_by_source_base() { + let root = temp_dir("candidate-installed-source-well-known"); + let agents_root = root.join(".agents/skills"); + let claude_root = root.join(".claude/skills"); + let managed_root = root.join("SkillBox"); + let source_base_url = "https://open.feishu.cn"; + let names = [ + "lark-approval", + "lark-apps", + "lark-attendance", + "lark-base", + "lark-doc", + "lark-im", + ]; + + for name in names { + make_skill( + &agents_root.join(name), + name, + "Verified well-known source skill", + ); + fs::create_dir_all(&claude_root).unwrap(); + symlink_dir(&agents_root.join(name), &claude_root.join(name)).unwrap(); + } + let skills = names + .iter() + .map(|name| { + ( + (*name).to_string(), + serde_json::json!({ + "sourceType": "well-known", + "sourceBaseUrl": source_base_url, + "sourceUrl": format!("{source_base_url}/.well-known/skills/{name}/SKILL.md"), + "skillFolderHash": "", + "wellKnownDigest": format!("sha256:{}", "a".repeat(64)) + }), + ) + }) + .collect::>(); + fs::create_dir_all(root.join(".agents")).unwrap(); + fs::write( + root.join(".agents/.skill-lock.json"), + serde_json::to_vec(&serde_json::json!({ "version": 3, "skills": skills })).unwrap(), + ) + .unwrap(); + + let scan = scan_import_candidates(&[agents_root, claude_root], &managed_root).unwrap(); + + assert_eq!(scan.collections.len(), 1); + let collection = &scan.collections[0]; + assert_eq!( + collection.source_kind, + ImportCandidateCollectionSourceKind::InstalledSource + ); + assert_eq!(collection.display_name, "open.feishu.cn"); + assert_eq!(collection.origin_url.as_deref(), Some(source_base_url)); + assert_eq!(collection.children.len(), names.len()); + assert_eq!(scan.standalone_groups.len(), 0); + assert_eq!( + scan.diagnostics.installed_source_lockfile_entries, + names.len() + ); + assert_eq!( + scan.diagnostics.installed_source_lockfile_matches, + names.len() + ); + assert_eq!(scan.diagnostics.installed_source_invalid_entries, 0); + assert_eq!(scan.diagnostics.installed_source_collections, 1); + let child_names = collection + .children + .iter() + .map(|child| child.name.as_str()) + .collect::>(); + assert_eq!(child_names.len(), names.len()); + assert!(collection + .children + .iter() + .all(|child| child.locations.len() == 2 && !child.snapshot_hash.is_empty())); +} + +#[test] +fn scan_import_candidates_keeps_well_known_sources_separate_and_singletons_standalone() { + let root = temp_dir("candidate-installed-source-well-known-separation"); + let agents_root = root.join(".agents/skills"); + let managed_root = root.join("SkillBox"); + let sources = [ + ("lark-alpha", "https://open.feishu.cn"), + ("lark-beta", "https://open.feishu.cn"), + ("lark-gamma", "https://skills.example.com/team"), + ("lark-delta", "https://skills.example.com/team"), + ("lark-single", "https://single.example.com"), + ]; + + for (name, _) in sources { + make_skill(&agents_root.join(name), name, "Well-known source skill"); + } + let skills = sources + .iter() + .map(|(name, source_base_url)| { + ( + (*name).to_string(), + serde_json::json!({ + "sourceType": "well-known", + "sourceBaseUrl": source_base_url, + "sourceUrl": format!("{source_base_url}/.well-known/skills/{name}/SKILL.md"), + "wellKnownDigest": format!("sha256:{}", "b".repeat(64)) + }), + ) + }) + .collect::>(); + fs::create_dir_all(root.join(".agents")).unwrap(); + fs::write( + root.join(".agents/.skill-lock.json"), + serde_json::to_vec(&serde_json::json!({ "version": 3, "skills": skills })).unwrap(), + ) + .unwrap(); + + let scan = scan_import_candidates(std::slice::from_ref(&agents_root), &managed_root).unwrap(); + + assert_eq!(scan.collections.len(), 2); + assert_eq!(scan.collections[0].children.len(), 2); + assert_eq!(scan.collections[1].children.len(), 2); + assert_ne!( + scan.collections[0].origin_url, + scan.collections[1].origin_url + ); + assert_eq!(scan.standalone_groups.len(), 1); + assert_eq!(scan.standalone_groups[0].name, "lark-single"); + assert_eq!( + scan.diagnostics.installed_source_lockfile_matches, + sources.len() + ); + assert_eq!(scan.diagnostics.installed_source_collections, 2); +} + +#[test] +fn scan_import_candidates_does_not_merge_github_and_well_known_provenance() { + let root = temp_dir("candidate-installed-source-kind-separation"); + let agents_root = root.join(".agents/skills"); + let managed_root = root.join("SkillBox"); + let github_names = ["github-alpha", "github-beta"]; + let well_known_names = ["known-alpha", "known-beta"]; + let shared_url = "https://github.com/acme/skills"; + + for name in github_names.into_iter().chain(well_known_names) { + make_skill(&agents_root.join(name), name, "Installed source skill"); + } + fs::create_dir_all(root.join(".agents")).unwrap(); + fs::write( + root.join(".agents/.skill-lock.json"), + serde_json::to_vec(&serde_json::json!({ + "version": 3, + "skills": { + "github-alpha": { + "sourceType": "github", + "sourceUrl": format!("{shared_url}.git"), + "skillPath": "skills/github-alpha/SKILL.md" + }, + "github-beta": { + "sourceType": "github", + "sourceUrl": format!("{shared_url}.git"), + "skillPath": "skills/github-beta/SKILL.md" + }, + "known-alpha": { + "sourceType": "well-known", + "sourceBaseUrl": shared_url, + "sourceUrl": format!("{shared_url}/.well-known/skills/known-alpha/SKILL.md"), + "wellKnownDigest": format!("sha256:{}", "e".repeat(64)) + }, + "known-beta": { + "sourceType": "well-known", + "sourceBaseUrl": shared_url, + "sourceUrl": format!("{shared_url}/.well-known/skills/known-beta/SKILL.md"), + "wellKnownDigest": format!("sha256:{}", "f".repeat(64)) + } + } + })) + .unwrap(), + ) + .unwrap(); + + let scan = scan_import_candidates(std::slice::from_ref(&agents_root), &managed_root).unwrap(); + + assert_eq!(scan.collections.len(), 2); + assert!(scan + .collections + .iter() + .all(|collection| collection.children.len() == 2)); + assert_eq!(scan.diagnostics.installed_source_lockfile_matches, 4); + assert_eq!(scan.diagnostics.installed_source_collections, 2); +} + +#[test] +fn scan_import_candidates_rejects_unsafe_or_mismatched_well_known_provenance() { + let root = temp_dir("candidate-installed-source-well-known-safety"); + let agents_root = root.join(".agents/skills"); + let managed_root = root.join("SkillBox"); + let names = [ + "lark-query", + "lark-mismatch", + "lark-bad-digest", + "lark-wrong-type", + ]; + for name in names { + make_skill(&agents_root.join(name), name, "Unsafe provenance skill"); + } + fs::create_dir_all(root.join(".agents")).unwrap(); + fs::write( + root.join(".agents/.skill-lock.json"), + serde_json::to_vec(&serde_json::json!({ + "version": 3, + "skills": { + "lark-query": { + "sourceType": "well-known", + "sourceBaseUrl": "https://open.feishu.cn?token=secret", + "sourceUrl": "https://open.feishu.cn/.well-known/skills/lark-query/SKILL.md", + "wellKnownDigest": format!("sha256:{}", "c".repeat(64)) + }, + "lark-mismatch": { + "sourceType": "well-known", + "sourceBaseUrl": "https://open.feishu.cn", + "sourceUrl": "https://open.feishu.cn/.well-known/skills/other/SKILL.md", + "wellKnownDigest": format!("sha256:{}", "d".repeat(64)) + }, + "lark-bad-digest": { + "sourceType": "well-known", + "sourceBaseUrl": "https://open.feishu.cn", + "sourceUrl": "https://open.feishu.cn/.well-known/skills/lark-bad-digest/SKILL.md", + "wellKnownDigest": "sha256:not-a-digest" + }, + "lark-wrong-type": { + "sourceType": "custom", + "sourceBaseUrl": "https://open.feishu.cn", + "sourceUrl": "https://open.feishu.cn/.well-known/skills/lark-wrong-type/SKILL.md", + "wellKnownDigest": format!("sha256:{}", "e".repeat(64)) + } + } + })) + .unwrap(), + ) + .unwrap(); + + let scan = scan_import_candidates(std::slice::from_ref(&agents_root), &managed_root).unwrap(); + + assert!(scan.collections.is_empty()); + assert_eq!(scan.standalone_groups.len(), names.len()); + assert_eq!(scan.diagnostics.installed_source_lockfile_matches, 0); + assert_eq!( + scan.diagnostics.installed_source_invalid_entries, + names.len() + ); + assert_eq!(scan.diagnostics.installed_source_collections, 0); +} + #[test] fn scan_import_candidates_keeps_single_installed_source_match_standalone() { let root = temp_dir("candidate-installed-source-lockfile-singleton"); diff --git a/docs/architecture.md b/docs/architecture.md index bd41d5b..410cbb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -222,10 +222,11 @@ GitHub remote collections 使用稳定的 canonical source URL + explicit reques 成另一个长期 collection;Phase D 仍未提供更新/回滚语义。 对于没有 live Git metadata 的复制安装,Import Review 可以读取配置 runtime -root 旁边受支持的 v3 `.skill-lock.json`。Rust 只解析 bounded JSON,校验 -`sourceType=github`、无 credentials/query/fragment 的 canonical GitHub -repository URL、safe `skillPath` 和已扫描 candidate name,再把相同 source URL -映射为 `installed_source` display collection。它不读取 lockfile 指向的本地 +root 旁边受支持的 v3 `.skill-lock.json`。Rust 只解析 bounded JSON。GitHub entry +必须提供无 credentials/query/fragment 的 canonical repository URL、safe `skillPath` +和已扫描 candidate name;`well-known` entry 必须提供严格匹配 candidate name 的 +HTTPS `sourceBaseUrl` / `sourceUrl` 与合法 SHA-256 digest。source kind 与规范化 URL +共同构成 grouping identity,再映射为 `installed_source` display collection。它不读取 lockfile 指向的本地 路径,不执行网络,不伪造 branch/HEAD,也不允许 `apply_import_collection`; 选中的 child 仍走普通 per-skill import。live Git worktree identity 优先, lockfile hash 不会跳过完整目录 snapshot 校验。 @@ -247,8 +248,7 @@ target,然后才导入并保存 `skill_collections` / `skill_collection_member Phase C 的 GitHub multi-skill one-fetch install 只允许显式 child selection,并在 apply 前重新验证 canonical source URL、ref、resolved SHA、child snapshot 和 managed target; 裸 repository URL 不假设 `main`,必须通过结构化结果要求显式 ref;root-only skill 也 -拒绝与 nested `SKILL.md` roots 重叠。它已按 v0.9.0 实现但尚未完成 release -qualification,尚未发布。 +拒绝与 nested `SKILL.md` roots 重叠。它已随 v0.9.0 发布。 Phase D 的 collection-level update/rollback 尚未实现。当前实现也不自动部署、不执行 hooks、filters、submodules、repository scripts、custom helpers 或 arbitrary shell。 diff --git a/docs/data-model.md b/docs/data-model.md index eeaeafd..4c767fb 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -346,7 +346,10 @@ Import Review 返回的 `ImportCandidateCollection` 还有一个只读的 来源;`installed_source` 表示由受支持的 v3 installer lockfile 归并的来源 展示。后者不写新的 collection/member row,不保存假 branch/HEAD,也不改变 schema v8 的 apply 事务;child 的真实 source path、snapshot、status、type 和 -import identity 仍来自 filesystem scan,并通过普通 per-skill import 写入。 +import identity 仍来自 filesystem scan,并通过普通 per-skill import 写入。支持的 +lockfile provenance 为 canonical GitHub repository URL + safe `skillPath`,或严格匹配 +candidate name/path 的 HTTPS `well-known` base/source URL + SHA-256 digest;source kind +也是 display collection identity 的一部分,不能仅因 URL 或名称前缀相同而合并。 `skill_user_metadata` 保存用户显式设置的 favorite 和 tags。桌面首次读取该表时会把旧 `localStorage` 中仍存在的 metadata 通过 `INSERT OR IGNORE` 迁入,因此 SQLite 中已有值不会被旧浏览器状态覆盖;迁移成功后删除旧 key。 diff --git a/docs/decisions/0007-git-backed-skill-collections.md b/docs/decisions/0007-git-backed-skill-collections.md index 9cd8d20..6073255 100644 --- a/docs/decisions/0007-git-backed-skill-collections.md +++ b/docs/decisions/0007-git-backed-skill-collections.md @@ -38,10 +38,13 @@ and history record. Import Review also recognizes a bounded fallback provenance source: a supported v3 installer lockfile adjacent to a configured `.agents/skills`, `.claude/skills`, -`.codex/skills`, or `.cursor/skills` root. Valid GitHub `sourceUrl` entries are -grouped into an `installed_source` collection only after a real scanned -candidate matches the lock entry's name and safe repository-relative -`skillPath`. This is display/provenance grouping, not Git authority: it has no +`.codex/skills`, or `.cursor/skills` root. Valid GitHub entries require a canonical +repository `sourceUrl` and safe repository-relative `skillPath`; valid +`well-known` entries require exact HTTPS base/source URL matching for +`/.well-known/skills//SKILL.md` and a well-formed SHA-256 digest. Either +kind is grouped into an `installed_source` collection only after a real scanned +candidate matches the lock entry's name and runtime root. Source kind remains +part of the identity. This is display/provenance grouping, not Git authority: it has no worktree, branch, HEAD, fetch, update, or collection-apply capability, and each child continues through the normal per-skill preview/import contract. Live Git identity always wins. Lockfile hashes never replace full snapshot validation, diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 0dc7972..9a0d6cd 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -42,7 +42,7 @@ - Implemented v0.8.0 Skill Collections Phase A+B: Rust discovers the nearest safe Git worktree for local Import Review, groups repository children with canonical worktree/HEAD identity, keeps external copies unlinked, and persists reviewed collection/member provenance after a stale-checked child import. - Shipped GitHub Skill Collections Phase C in v0.9.0: one bounded fetch/check previews a complete repository/tree ref, exposes eligible and blocked children with one reviewed resolved SHA, and applies only explicitly selected children with stale and recovery protections. Collection-level update/rollback remains planned Phase D work for a later v0.9.x release. - Current Import Review uses one collection-header User/Remote decision to resolve all actionable pending children, and one collection checkbox to select or clear the complete eligible set. Unresolved or mixed type state remains ineligible until explicitly resolved. -- Added a bounded installer-lockfile fallback for copied skills: valid v3 GitHub provenance can form a display-only `installed_source` collection after filesystem scanning, while live Git identity wins and selected children retain the ordinary per-skill import/apply contract. +- Added a bounded installer-lockfile fallback for copied skills: valid v3 GitHub provenance or strictly matched HTTPS `well-known` provenance can form a display-only `installed_source` collection after filesystem scanning, while live Git identity wins and selected children retain the ordinary per-skill import/apply contract. - Added signed macOS app update checks and user-confirmed install/restart through the Tauri updater plugin, plus release workflow assets for updater archives, signatures, and `latest.json`. - Added daily macOS updater metadata checks with a SQLite-backed successful-result cache, a sidebar Update reminder, one-click metadata recheck plus signed install/restart, and retry-safe pending updates without automatic downloads. - Added ordered, transactional Rust SQLite migrations, consistent pre-migration backups for existing databases, schema version tracking, and integrity validation. diff --git a/docs/roadmap.md b/docs/roadmap.md index d56ccac..5f8694c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -153,7 +153,8 @@ automatically. When a copied runtime installation has no live Git metadata, a supported v3 installer lockfile may provide a display-only `installed_source` grouping by -validated canonical GitHub source URL. Live Git identity remains authoritative; +validated canonical GitHub source URL or strictly matched HTTPS `well-known` +source base. Live Git identity remains authoritative; lockfile groups never fabricate branch/HEAD, fetch/update, or collection apply permissions, and child imports continue through the ordinary per-skill safety contract. diff --git a/docs/workflows.md b/docs/workflows.md index 7f57a43..bfc96dd 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -142,19 +142,20 @@ Review/apply: - 如果 candidate 不在 live Git worktree,Rust 会按固定 runtime root 旁边的 v3 `.skill-lock.json` 做一次 bounded provenance lookup。只有真实扫描到的 candidate - 与 safe `skillPath`/name 匹配,并且 `sourceType=github`、source URL 可被 - canonicalize 为无凭据 GitHub repository identity 时,才会显示为 - `Installed source collection`。 + 与 entry key/name/root 匹配时,才可能显示为 `Installed source collection`。 + `sourceType=github` 还要求 safe `skillPath` 和无凭据的 canonical GitHub repository + URL;`sourceType=well-known` 则要求规范 HTTPS base/source URL、精确的 + `/.well-known/skills//SKILL.md` 路径和合法 SHA-256 digest。 - `pluginName` 是 installer 的 optional、有界 package metadata,不参与 child identity: 多个 skill 可共享同一个 plugin name。每个 child 仍必须通过 lockfile entry key、safe - `skillPath`、candidate name/root/path 和 normalized GitHub source URL 的匹配。 + source path contract、candidate name/root/path 和对应 source kind 的 URL 校验。 - 这类 collection 只用于展示来源和聚合 child,不提供 branch、HEAD、fetch、更新或 `collection-apply`。它不使用 lockfile hash 替代完整 snapshot 校验;用户选中的 child 仍逐项走现有 per-skill Import Review/apply,`.agents` 与 `.claude` 的等价 symlink location 仍只保留一份。 -- lockfile 版本不支持、文件过大、条目 malformed、路径 traversal、非 GitHub/custom - source 或 stale/mismatched entry 都不会隐藏 candidate,也不会创建网络或文件写入。 -- installed-source fallback 只有在同一 normalized source URL 匹配至少两个已验证 child +- lockfile 版本不支持、文件过大、条目 malformed、路径 traversal、不受支持的 source + type 或 stale/mismatched entry 都不会隐藏 candidate,也不会创建网络或文件写入。 +- installed-source fallback 只有在同一 source kind 和 normalized source URL 匹配至少两个已验证 child 时才形成 collection card;单个匹配仍作为普通 standalone candidate 显示。live Git worktree collection 保留其 repository/HEAD 语义,不受这个展示降噪门槛影响。