Merge branch 'feat/v070-skill-install' (#140 skill install command)
This commit is contained in:
Generated
+46
-1
@@ -1171,6 +1171,7 @@ dependencies = [
|
||||
"deepseek-secrets",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ignore",
|
||||
"image",
|
||||
@@ -1186,9 +1187,11 @@ dependencies = [
|
||||
"rustyline 15.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shellexpand",
|
||||
"shlex",
|
||||
"starlark",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tiny_http",
|
||||
@@ -1597,6 +1600,17 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.7"
|
||||
@@ -2484,6 +2498,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3063,7 +3078,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
@@ -3458,6 +3473,15 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
@@ -4335,6 +4359,17 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.24.0"
|
||||
@@ -5487,6 +5522,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
|
||||
@@ -198,6 +198,23 @@ Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
|
||||
---
|
||||
|
||||
## Publishing your own skill
|
||||
|
||||
DeepSeek-TUI can install community skills directly from a GitHub repo, with no
|
||||
backend service in the loop:
|
||||
|
||||
1. Create a public GitHub repo with a `SKILL.md` at the root containing the
|
||||
usual `---` frontmatter (`name`, `description`).
|
||||
2. Multi-skill bundles use `skills/<name>/SKILL.md` instead — the installer
|
||||
picks the first match and names the install after the frontmatter `name`.
|
||||
3. Push to `main` (or `master`); the installer fetches
|
||||
`archive/refs/heads/main.tar.gz` and falls back to `master.tar.gz`.
|
||||
4. Users install via `/skill install github:<owner>/<repo>` — installs are
|
||||
gated by the `[network]` policy, validated for path traversal and size, and
|
||||
placed under `~/.deepseek/skills/<name>/`.
|
||||
5. Submit a PR to the curated `index.json` (default registry) to make the skill
|
||||
installable by name (`/skill install <name>`) instead of the GitHub spec.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Topic |
|
||||
|
||||
@@ -124,6 +124,25 @@ max_subagents = 5 # optional (1-20)
|
||||
# deny = []
|
||||
# audit = true # one line per call to ~/.deepseek/audit.log
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Skills (#140)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Settings for the `/skill install <spec>` community-skill installer.
|
||||
# * registry_url — curated index.json that resolves bare names to
|
||||
# `github:owner/repo` specs. Override to point at
|
||||
# a private fork or internal mirror.
|
||||
# * max_install_size_bytes — per-skill uncompressed size cap. Tarballs that
|
||||
# exceed this limit are rejected during validation.
|
||||
# Default: 5 MiB.
|
||||
#
|
||||
# `/skill install` is gated by `[network]`. Make sure `github.com` and
|
||||
# `raw.githubusercontent.com` are reachable (default `prompt` is fine — you'll
|
||||
# be asked once and can persist) before running it.
|
||||
#
|
||||
# [skills]
|
||||
# registry_url = "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json"
|
||||
# max_install_size_bytes = 5_242_880
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -128,10 +128,29 @@ pub struct ConfigToml {
|
||||
/// to a permissive default that mirrors pre-v0.7.0 behavior.
|
||||
#[serde(default)]
|
||||
pub network: Option<NetworkPolicyToml>,
|
||||
/// Community skill installer settings (#140). Mirrors
|
||||
/// [`SkillsToml`] from the TUI side; the dispatcher consults
|
||||
/// `registry_url` when running `deepseek skill install`.
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsToml>,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
|
||||
/// for documentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SkillsToml {
|
||||
/// Curated registry index URL. When unset, the TUI falls back to the
|
||||
/// bundled default (community-curated GitHub raw).
|
||||
#[serde(default)]
|
||||
pub registry_url: Option<String>,
|
||||
/// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
|
||||
/// uses 5 MiB.
|
||||
#[serde(default)]
|
||||
pub max_install_size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
|
||||
/// for documentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -57,6 +57,9 @@ zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
pdf-extract = "0.7"
|
||||
tar = "0.4"
|
||||
flate2 = "1.1"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -322,14 +322,14 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
description: "List available skills",
|
||||
usage: "/skills",
|
||||
description: "List local skills (or --remote to browse the curated registry)",
|
||||
usage: "/skills [--remote]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "skill",
|
||||
aliases: &[],
|
||||
description: "Activate a skill for next message",
|
||||
usage: "/skill <name>",
|
||||
description: "Activate a skill, or install/update/uninstall/trust a community skill",
|
||||
usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "review",
|
||||
@@ -409,7 +409,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"init" => init::init(app),
|
||||
|
||||
// Skills commands
|
||||
"skills" => skills::list_skills(app),
|
||||
"skills" => skills::list_skills(app, arg),
|
||||
"skill" => skills::run_skill(app, arg),
|
||||
"review" => review::review(app, arg),
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::network_policy::NetworkPolicy;
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::skills::install::{
|
||||
self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource,
|
||||
RegistryFetchResult, UpdateResult,
|
||||
};
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
@@ -21,8 +26,18 @@ fn render_skill_warnings(registry: &SkillRegistry) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// List all available skills
|
||||
pub fn list_skills(app: &mut App) -> CommandResult {
|
||||
/// List all available skills. Pass `--remote` (or `remote`) to fetch the
|
||||
/// curated registry instead of scanning the local skills directory.
|
||||
pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
if let Some(arg) = arg {
|
||||
let trimmed = arg.trim();
|
||||
if trimmed == "--remote" || trimmed == "remote" {
|
||||
return list_remote_skills(app);
|
||||
}
|
||||
if !trimmed.is_empty() {
|
||||
return CommandResult::error("Usage: /skills [--remote]");
|
||||
}
|
||||
}
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let warnings = render_skill_warnings(®istry);
|
||||
@@ -61,15 +76,35 @@ pub fn list_skills(app: &mut App) -> CommandResult {
|
||||
CommandResult::message(output)
|
||||
}
|
||||
|
||||
/// Run a specific skill - activates skill for next user message
|
||||
/// Run a specific skill — activates skill for next user message, or
|
||||
/// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`).
|
||||
pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
|
||||
let name = match name {
|
||||
let raw = match name {
|
||||
Some(n) => n.trim(),
|
||||
None => {
|
||||
return CommandResult::error("Usage: /skill <name>");
|
||||
return CommandResult::error(
|
||||
"Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://…|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Sub-command dispatch happens before the activation path so users can't
|
||||
// accidentally activate a skill literally named "install".
|
||||
let mut iter = raw.splitn(2, char::is_whitespace);
|
||||
let head = iter.next().unwrap_or("").trim();
|
||||
let rest = iter.next().unwrap_or("").trim();
|
||||
match head {
|
||||
"install" => return install_skill(app, rest),
|
||||
"update" => return update_skill(app, rest),
|
||||
"uninstall" => return uninstall_skill(app, rest),
|
||||
"trust" => return trust_skill(app, rest),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
activate_skill(app, raw)
|
||||
}
|
||||
|
||||
fn activate_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
// `/skill new` is a friendly alias for `/skill skill-creator`.
|
||||
let name = if name == "new" { "skill-creator" } else { name };
|
||||
|
||||
@@ -111,6 +146,214 @@ pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill install ────────────────────────────────────────────────────────
|
||||
|
||||
fn install_skill(app: &mut App, spec: &str) -> CommandResult {
|
||||
if spec.is_empty() {
|
||||
return CommandResult::error(
|
||||
"Usage: /skill install <github:owner/repo|https://…|<registry-name>>",
|
||||
);
|
||||
}
|
||||
let source = match InstallSource::parse(spec) {
|
||||
Ok(s) => s,
|
||||
Err(err) => return CommandResult::error(format!("Invalid install source: {err}")),
|
||||
};
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let (network, max_size, registry_url) = installer_settings(app);
|
||||
|
||||
let outcome = run_async(async move {
|
||||
install::install_with_registry(
|
||||
source,
|
||||
&skills_dir,
|
||||
max_size,
|
||||
&network,
|
||||
false,
|
||||
®istry_url,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(InstallOutcome::Installed(installed)) => {
|
||||
let path_str = path_or_default(&installed.path);
|
||||
CommandResult::message(format!(
|
||||
"Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.",
|
||||
installed.name, spec, path_str
|
||||
))
|
||||
}
|
||||
Ok(InstallOutcome::NeedsApproval(host)) => {
|
||||
CommandResult::error(needs_approval_message(&host))
|
||||
}
|
||||
Ok(InstallOutcome::NetworkDenied(host)) => {
|
||||
CommandResult::error(network_denied_message(&host))
|
||||
}
|
||||
Err(err) => CommandResult::error(format!("Install failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill update ─────────────────────────────────────────────────────────
|
||||
|
||||
fn update_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
if name.is_empty() {
|
||||
return CommandResult::error("Usage: /skill update <name>");
|
||||
}
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let (network, max_size, registry_url) = installer_settings(app);
|
||||
let owned_name = name.to_string();
|
||||
let outcome = run_async(async move {
|
||||
install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url)
|
||||
.await
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Ok(UpdateResult::NoChange) => {
|
||||
CommandResult::message(format!("Skill '{name}': no upstream change."))
|
||||
}
|
||||
Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!(
|
||||
"Skill '{}' updated. Location: {}",
|
||||
installed.name,
|
||||
path_or_default(&installed.path)
|
||||
)),
|
||||
Ok(UpdateResult::NeedsApproval(host)) => {
|
||||
CommandResult::error(needs_approval_message(&host))
|
||||
}
|
||||
Ok(UpdateResult::NetworkDenied(host)) => {
|
||||
CommandResult::error(network_denied_message(&host))
|
||||
}
|
||||
Err(err) => CommandResult::error(format!("Update failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill uninstall ──────────────────────────────────────────────────────
|
||||
|
||||
fn uninstall_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
if name.is_empty() {
|
||||
return CommandResult::error("Usage: /skill uninstall <name>");
|
||||
}
|
||||
match install::uninstall(name, &app.skills_dir) {
|
||||
Ok(()) => CommandResult::message(format!("Removed skill '{name}'.")),
|
||||
Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skill trust ──────────────────────────────────────────────────────────
|
||||
|
||||
fn trust_skill(app: &mut App, name: &str) -> CommandResult {
|
||||
if name.is_empty() {
|
||||
return CommandResult::error("Usage: /skill trust <name>");
|
||||
}
|
||||
match install::trust(name, &app.skills_dir) {
|
||||
Ok(()) => CommandResult::message(format!(
|
||||
"Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/."
|
||||
)),
|
||||
Err(err) => CommandResult::error(format!("Trust failed: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /skills --remote ──────────────────────────────────────────────────────
|
||||
|
||||
/// List skills available in the configured curated registry.
|
||||
pub fn list_remote_skills(app: &mut App) -> CommandResult {
|
||||
let (network, _max_size, registry_url) = installer_settings(app);
|
||||
let registry = run_async(async move { install::fetch_registry(&network, ®istry_url).await });
|
||||
match registry {
|
||||
Ok(RegistryFetchResult::Loaded(doc)) => {
|
||||
if doc.skills.is_empty() {
|
||||
return CommandResult::message("Registry is empty.");
|
||||
}
|
||||
let mut out = format!("Available remote skills ({}):\n", doc.skills.len());
|
||||
out.push_str("─────────────────────────────\n");
|
||||
for (name, entry) in &doc.skills {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {name} — {} (source: {})",
|
||||
entry.description.clone().unwrap_or_default(),
|
||||
entry.source
|
||||
);
|
||||
}
|
||||
let _ = write!(out, "\nInstall with: /skill install <name>");
|
||||
CommandResult::message(out)
|
||||
}
|
||||
Ok(RegistryFetchResult::NeedsApproval(host)) => {
|
||||
CommandResult::error(needs_approval_message(&host))
|
||||
}
|
||||
Ok(RegistryFetchResult::Denied(host)) => {
|
||||
CommandResult::error(network_denied_message(&host))
|
||||
}
|
||||
Err(err) => CommandResult::error(format!("Failed to fetch registry: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Read the active config knobs for the installer.
|
||||
///
|
||||
/// We load `Config::load` on demand because [`App`] does not carry a `Config`
|
||||
/// field — and loading is cheap (small TOML file) compared to the network
|
||||
/// round-trip the install/update operation will incur next. If the config
|
||||
/// fails to parse, we fall back to defaults so the user still gets a
|
||||
/// network-gated install rather than a silent crash.
|
||||
fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) {
|
||||
let cfg = crate::config::Config::load(None, None).unwrap_or_default();
|
||||
let network = cfg
|
||||
.network
|
||||
.clone()
|
||||
.map(|policy| policy.into_runtime())
|
||||
.unwrap_or_default();
|
||||
let skills_cfg = cfg.skills.as_ref();
|
||||
let max_size = skills_cfg
|
||||
.and_then(|s| s.max_install_size_bytes)
|
||||
.unwrap_or(DEFAULT_MAX_SIZE_BYTES);
|
||||
let registry_url = skills_cfg
|
||||
.and_then(|s| s.registry_url.clone())
|
||||
.unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
|
||||
(network, max_size, registry_url)
|
||||
}
|
||||
|
||||
fn run_async<F, T>(future: F) -> T
|
||||
where
|
||||
F: std::future::Future<Output = T>,
|
||||
{
|
||||
// We're on the TUI's thread, which is part of the multi-threaded runtime.
|
||||
// `block_in_place` + `Handle::current().block_on` is the pattern used by
|
||||
// `commands/cycle.rs` to bridge sync slash-command handlers back into the
|
||||
// async ecosystem.
|
||||
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
|
||||
}
|
||||
|
||||
fn path_or_default(path: &std::path::Path) -> String {
|
||||
path.file_name()
|
||||
.map(|n| {
|
||||
// Display with parent so the user sees the full skill location.
|
||||
// We intentionally use `display()` here because it's just for
|
||||
// user-facing output, not for path comparisons.
|
||||
let parent = path
|
||||
.parent()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
if parent.is_empty() {
|
||||
n.to_string_lossy().to_string()
|
||||
} else {
|
||||
format!("{parent}/{}", n.to_string_lossy())
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
fn needs_approval_message(host: &str) -> String {
|
||||
format!(
|
||||
"Network policy requires approval for {host}.\n\
|
||||
Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.deepseek/config.toml), then retry."
|
||||
)
|
||||
}
|
||||
|
||||
fn network_denied_message(host: &str) -> String {
|
||||
format!(
|
||||
"Network policy denied access to {host}.\n\
|
||||
Remove the deny entry from ~/.deepseek/config.toml under [network] or contact your administrator."
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -150,7 +393,7 @@ mod tests {
|
||||
fn test_list_skills_empty_directory() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app);
|
||||
let result = list_skills(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No skills found"));
|
||||
@@ -166,13 +409,32 @@ mod tests {
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nDo something",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app);
|
||||
let result = list_skills(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Available skills"));
|
||||
assert!(msg.contains("/test-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_subcommand_dispatch_install_usage() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Empty install spec → usage hint, not invalid-source error.
|
||||
let result = run_skill(&mut app, Some("install"));
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("/skill install"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_subcommand_dispatch_uninstall_missing() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, Some("uninstall absent-skill"));
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("not installed"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_without_name() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
@@ -429,6 +429,43 @@ pub struct Config {
|
||||
/// to a permissive default that mirrors pre-v0.7.0 behavior.
|
||||
#[serde(default)]
|
||||
pub network: Option<NetworkPolicyToml>,
|
||||
|
||||
/// Community skill installer settings (#140). When absent, installer
|
||||
/// commands fall back to the bundled defaults
|
||||
/// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +
|
||||
/// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
|
||||
#[serde(default)]
|
||||
pub skills: Option<SkillsConfig>,
|
||||
}
|
||||
|
||||
/// `[skills]` table — knobs for the community-skill installer.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct SkillsConfig {
|
||||
/// Curated registry index. `/skill install <name>` looks up the spec here.
|
||||
/// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].
|
||||
#[serde(default)]
|
||||
pub registry_url: Option<String>,
|
||||
/// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed
|
||||
/// this limit are rejected during validation. Defaults to 5 MiB.
|
||||
#[serde(default)]
|
||||
pub max_install_size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl SkillsConfig {
|
||||
/// Resolve the registry URL with the bundled default.
|
||||
#[must_use]
|
||||
pub fn registry_url(&self) -> String {
|
||||
self.registry_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())
|
||||
}
|
||||
|
||||
/// Resolve the max install size with the bundled default.
|
||||
#[must_use]
|
||||
pub fn max_install_size_bytes(&self) -> u64 {
|
||||
self.max_install_size_bytes
|
||||
.unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live
|
||||
@@ -1384,6 +1421,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
features: merge_features(base.features, override_cfg.features),
|
||||
notifications: override_cfg.notifications.or(base.notifications),
|
||||
network: override_cfg.network.or(base.network),
|
||||
skills: override_cfg.skills.or(base.skills),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,17 @@
|
||||
//! Skill discovery and registry for local SKILL.md files.
|
||||
|
||||
pub mod install;
|
||||
mod system;
|
||||
// Re-exports kept for documentation parity and downstream consumers; the
|
||||
// binary itself imports directly from `skills::install`. `#[allow(...)]`
|
||||
// silences the dead-code warning that fires because no `bin` source path
|
||||
// references these names through `skills::*`.
|
||||
#[allow(unused_imports)]
|
||||
pub use install::{
|
||||
DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, INSTALLED_FROM_MARKER, InstallOutcome,
|
||||
InstallSource, InstalledSkill, RegistryDocument, RegistryEntry, RegistryFetchResult,
|
||||
UpdateResult,
|
||||
};
|
||||
pub use system::install_system_skills;
|
||||
|
||||
use std::fs;
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Integration tests for the community-skill installer (#140).
|
||||
//!
|
||||
//! These tests exercise the full validation pipeline against a tiny in-process
|
||||
//! HTTP server, so the network gate, download cap, tarball validation, atomic
|
||||
//! rename, and `.installed-from` marker all run end-to-end. The module is
|
||||
//! pulled in via `#[path]` includes (matching `integration_mock_llm.rs`) so we
|
||||
//! get access to private helpers without a separate library crate.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use tempfile::TempDir;
|
||||
use tiny_http::{Method, Response, Server};
|
||||
|
||||
// Pull the production source files into this test binary so the test can
|
||||
// reach `install`'s public surface without a dedicated library crate.
|
||||
//
|
||||
// `install.rs` only references `crate::network_policy` so we just need that
|
||||
// one helper module alongside `install` itself.
|
||||
#[path = "../src/network_policy.rs"]
|
||||
mod network_policy;
|
||||
|
||||
#[path = "../src/skills/install.rs"]
|
||||
#[allow(dead_code)]
|
||||
mod install;
|
||||
|
||||
use crate::install::{InstallOutcome, InstallSource, UpdateResult};
|
||||
use crate::network_policy::{DecisionToml, NetworkPolicy};
|
||||
|
||||
/// Construct a gzipped tarball from `(path, body)` pairs. Permissions are set
|
||||
/// to 0o644 so umask differences across platforms don't perturb the bytes.
|
||||
fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut gz);
|
||||
for (path, body) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(body.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, path, *body)
|
||||
.expect("append_data");
|
||||
}
|
||||
builder.finish().expect("finish tar");
|
||||
}
|
||||
gz.finish().expect("finish gz")
|
||||
}
|
||||
|
||||
fn skill_md(name: &str, description: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nThis is a test skill.\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn allow_all_policy() -> NetworkPolicy {
|
||||
NetworkPolicy {
|
||||
default: DecisionToml::Allow,
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
audit: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn deny_all_policy() -> NetworkPolicy {
|
||||
NetworkPolicy {
|
||||
default: DecisionToml::Deny,
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
audit: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_all_policy() -> NetworkPolicy {
|
||||
NetworkPolicy {
|
||||
default: DecisionToml::Prompt,
|
||||
allow: Vec::new(),
|
||||
deny: Vec::new(),
|
||||
audit: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a tiny HTTP server that serves `bytes` at any path with 200 OK and
|
||||
/// returns the bound URL. The server replies to *every* request (we re-use it
|
||||
/// across multiple installs in the same test).
|
||||
fn spawn_tarball_server(
|
||||
bytes: Vec<u8>,
|
||||
) -> (
|
||||
String,
|
||||
std::sync::mpsc::Sender<()>,
|
||||
std::thread::JoinHandle<()>,
|
||||
) {
|
||||
let server = Server::http("127.0.0.1:0").expect("bind ephemeral port");
|
||||
let url = format!(
|
||||
"http://{}/skill.tar.gz",
|
||||
server.server_addr().to_ip().expect("ip addr")
|
||||
);
|
||||
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
|
||||
let handle = std::thread::spawn(move || {
|
||||
loop {
|
||||
// Poll-style with a small recv timeout so we can break out cleanly.
|
||||
match server.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(Some(req)) => {
|
||||
if req.method() != &Method::Get {
|
||||
continue;
|
||||
}
|
||||
let response = Response::from_data(bytes.clone());
|
||||
let _ = req.respond(response);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
if shutdown_rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
(url, shutdown_tx, handle)
|
||||
}
|
||||
|
||||
fn shutdown(tx: std::sync::mpsc::Sender<()>, handle: std::thread::JoinHandle<()>) {
|
||||
let _ = tx.send(());
|
||||
let _ = handle.join();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_happy_path_writes_skill_and_marker() {
|
||||
let tarball = make_tarball(&[
|
||||
(
|
||||
"test-skill-main/SKILL.md",
|
||||
&skill_md("test-skill", "Test skill"),
|
||||
),
|
||||
("test-skill-main/notes.txt", b"hello world"),
|
||||
]);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
|
||||
let outcome = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("install ok");
|
||||
|
||||
let installed = match outcome {
|
||||
InstallOutcome::Installed(s) => s,
|
||||
other => panic!("expected Installed, got {other:?}"),
|
||||
};
|
||||
assert_eq!(installed.name, "test-skill");
|
||||
|
||||
let installed_dir = tmp.path().join("test-skill");
|
||||
assert!(installed_dir.is_dir(), "skill dir created");
|
||||
assert!(installed_dir.join("SKILL.md").is_file(), "SKILL.md present");
|
||||
assert!(
|
||||
installed_dir.join("notes.txt").is_file(),
|
||||
"extra file present"
|
||||
);
|
||||
assert!(
|
||||
installed_dir.join(install::INSTALLED_FROM_MARKER).is_file(),
|
||||
".installed-from marker present"
|
||||
);
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_rejects_path_traversal() {
|
||||
// `tar::Builder::append_data` rejects `..` itself, so we craft the bad
|
||||
// entry by writing the raw header bytes via `append`.
|
||||
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut gz);
|
||||
let body = skill_md("test-skill", "T");
|
||||
let mut hdr = tar::Header::new_gnu();
|
||||
hdr.set_size(body.len() as u64);
|
||||
hdr.set_mode(0o644);
|
||||
hdr.set_cksum();
|
||||
builder
|
||||
.append_data(&mut hdr, "test-skill-main/SKILL.md", body.as_slice())
|
||||
.unwrap();
|
||||
|
||||
// Path-traversal entry. The `tar` crate's `set_path` rejects `..`
|
||||
// itself, so we patch the raw 100-byte name field in the header.
|
||||
let evil_body: &[u8] = b"not gonna happen";
|
||||
let mut evil_hdr = tar::Header::new_gnu();
|
||||
evil_hdr.set_size(evil_body.len() as u64);
|
||||
evil_hdr.set_mode(0o644);
|
||||
// Write a name with a `..` directly into the legacy "name" field.
|
||||
let bytes = evil_hdr.as_old_mut();
|
||||
let evil_name = b"../etc/passwd";
|
||||
bytes.name[..evil_name.len()].copy_from_slice(evil_name);
|
||||
evil_hdr.set_cksum();
|
||||
builder.append(&evil_hdr, evil_body).unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
let tarball = gz.finish().unwrap();
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("path traversal must be rejected");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("escapes destination"),
|
||||
"expected path-traversal error, got: {msg}"
|
||||
);
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_rejects_oversized_tarball() {
|
||||
let big = vec![b'a'; 256 * 1024]; // 256 KiB per file
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
entries.push((
|
||||
"test-skill-main/SKILL.md".to_string(),
|
||||
skill_md("test-skill", "T"),
|
||||
));
|
||||
for i in 0..50 {
|
||||
entries.push((format!("test-skill-main/big-{i}.bin"), big.clone()));
|
||||
}
|
||||
let entry_refs: Vec<(&str, &[u8])> = entries
|
||||
.iter()
|
||||
.map(|(p, b)| (p.as_str(), b.as_slice()))
|
||||
.collect();
|
||||
let tarball = make_tarball(&entry_refs);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
let small_cap = 1024 * 1024;
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
small_cap,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("oversized must be rejected");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("too large") || msg.contains("exceed"),
|
||||
"expected size cap error, got: {msg}"
|
||||
);
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_rejects_missing_skill_md() {
|
||||
let tarball = make_tarball(&[("repo-main/README.md", b"not a skill")]);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing SKILL.md must be rejected");
|
||||
assert!(format!("{err:#}").contains("missing SKILL.md"), "{err:#}");
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_rejects_missing_required_frontmatter() {
|
||||
let tarball = make_tarball(&[("repo-main/SKILL.md", b"---\nname: test\n---\nbody\n")]);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing description must be rejected");
|
||||
assert!(format!("{err:#}").contains("description"), "{err:#}");
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_idempotent_then_uninstall_then_reinstall() {
|
||||
let tarball_bytes =
|
||||
make_tarball(&[("repo-main/SKILL.md", &skill_md("idem-skill", "Idempotent"))]);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
|
||||
install::install(
|
||||
InstallSource::DirectUrl(url.clone()),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("first install ok");
|
||||
|
||||
// Second install with `update = false` must reject.
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url.clone()),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("second install must reject");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("already installed"),
|
||||
"expected already-installed error, got: {msg}"
|
||||
);
|
||||
|
||||
// Uninstall then reinstall.
|
||||
install::uninstall("idem-skill", tmp.path()).expect("uninstall ok");
|
||||
assert!(!tmp.path().join("idem-skill").exists());
|
||||
|
||||
install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("reinstall ok");
|
||||
|
||||
assert!(tmp.path().join("idem-skill").join("SKILL.md").is_file());
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_no_change_returns_nochange_without_overwriting() {
|
||||
let tarball_bytes =
|
||||
make_tarball(&[("repo-main/SKILL.md", &skill_md("upd-skill", "Update test"))]);
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
|
||||
install::install(
|
||||
InstallSource::DirectUrl(url.clone()),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Patch the marker so update() re-fetches the same URL.
|
||||
let marker_path = tmp
|
||||
.path()
|
||||
.join("upd-skill")
|
||||
.join(install::INSTALLED_FROM_MARKER);
|
||||
let marker_body = std::fs::read_to_string(&marker_path).unwrap();
|
||||
let mut marker_json: serde_json::Value = serde_json::from_str(&marker_body).unwrap();
|
||||
marker_json["spec"] = serde_json::Value::String(url);
|
||||
std::fs::write(&marker_path, marker_json.to_string()).unwrap();
|
||||
|
||||
// Capture mtime so we can confirm SKILL.md wasn't rewritten.
|
||||
let skill_md_path = tmp.path().join("upd-skill").join("SKILL.md");
|
||||
let mtime_before = std::fs::metadata(&skill_md_path)
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
|
||||
let result = install::update(
|
||||
"upd-skill",
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
)
|
||||
.await
|
||||
.expect("update ok");
|
||||
assert!(matches!(result, UpdateResult::NoChange));
|
||||
|
||||
let mtime_after = std::fs::metadata(&skill_md_path)
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap();
|
||||
assert_eq!(mtime_before, mtime_after, "SKILL.md must not be rewritten");
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_with_deny_policy_returns_network_denied() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = deny_all_policy();
|
||||
let outcome = install::install(
|
||||
InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("policy outcome should be Ok");
|
||||
match outcome {
|
||||
InstallOutcome::NetworkDenied(host) => {
|
||||
assert!(host.contains("example.invalid"), "got host {host}");
|
||||
}
|
||||
other => panic!("expected NetworkDenied, got {other:?}"),
|
||||
}
|
||||
|
||||
// Verify the temp dir is untouched.
|
||||
assert!(
|
||||
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
||||
"temp dir must be untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_with_prompt_policy_returns_needs_approval() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = prompt_all_policy();
|
||||
let outcome = install::install(
|
||||
InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect("policy outcome should be Ok");
|
||||
match outcome {
|
||||
InstallOutcome::NeedsApproval(host) => {
|
||||
assert!(host.contains("example.invalid"), "got host {host}");
|
||||
}
|
||||
other => panic!("expected NeedsApproval, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
||||
"temp dir must be untouched on prompt"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_rejects_symlink_entry() {
|
||||
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut gz);
|
||||
|
||||
let body = skill_md("link-skill", "x");
|
||||
let mut hdr = tar::Header::new_gnu();
|
||||
hdr.set_size(body.len() as u64);
|
||||
hdr.set_mode(0o644);
|
||||
hdr.set_cksum();
|
||||
builder
|
||||
.append_data(&mut hdr, "repo-main/SKILL.md", body.as_slice())
|
||||
.unwrap();
|
||||
|
||||
let mut link_hdr = tar::Header::new_gnu();
|
||||
link_hdr.set_entry_type(tar::EntryType::Symlink);
|
||||
link_hdr.set_size(0);
|
||||
link_hdr.set_mode(0o777);
|
||||
builder
|
||||
.append_link(&mut link_hdr, "repo-main/escape", Path::new("/etc/passwd"))
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
let tarball = gz.finish().unwrap();
|
||||
let (url, tx, handle) = spawn_tarball_server(tarball);
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let policy = allow_all_policy();
|
||||
let err = install::install(
|
||||
InstallSource::DirectUrl(url),
|
||||
tmp.path(),
|
||||
install::DEFAULT_MAX_SIZE_BYTES,
|
||||
&policy,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.expect_err("symlinks must be rejected");
|
||||
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
|
||||
|
||||
shutdown(tx, handle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_refuses_system_skill() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path().join("system-skill");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut f = std::fs::File::create(dir.join("SKILL.md")).unwrap();
|
||||
f.write_all(b"---\nname: system-skill\ndescription: x\n---\n")
|
||||
.unwrap();
|
||||
// No `.installed-from` marker — looks like a system skill.
|
||||
|
||||
let err = install::uninstall("system-skill", tmp.path()).expect_err("must refuse");
|
||||
assert!(format!("{err:#}").contains("not installed via"));
|
||||
assert!(dir.exists(), "directory must be left alone");
|
||||
}
|
||||
Reference in New Issue
Block a user