Some checks failed
Weekly OSV dependency audit / dependency-audit (push) Failing after 5s
1902 lines
66 KiB
Rust
1902 lines
66 KiB
Rust
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::process::CommandExt;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
|
|
const REGISTRY_SCHEMA: u32 = 1;
|
|
const MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
|
const MAX_HOOK_INPUT: usize = 64 * 1024;
|
|
const MAX_HOOK_OUTPUT: usize = 64 * 1024;
|
|
const MAX_HOOK_TIMEOUT: u64 = 10;
|
|
const CONTEXT_PREFIX: &str = "[DS4Server extension ";
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub(crate) struct HookBinding {
|
|
pub(crate) event: String,
|
|
pub(crate) matcher: Option<String>,
|
|
pub(crate) argv: Vec<String>,
|
|
pub(crate) timeout_seconds: u64,
|
|
pub(crate) status_message: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub(crate) struct InstalledExtension {
|
|
pub(crate) id: String,
|
|
pub(crate) name: String,
|
|
pub(crate) version: String,
|
|
pub(crate) description: String,
|
|
pub(crate) author: String,
|
|
pub(crate) enabled: bool,
|
|
pub(crate) trusted: bool,
|
|
pub(crate) source_url: String,
|
|
pub(crate) requested_ref: Option<String>,
|
|
pub(crate) resolved_commit: String,
|
|
pub(crate) skills_path: Option<String>,
|
|
pub(crate) skill_count: usize,
|
|
pub(crate) hooks_path: Option<String>,
|
|
pub(crate) hooks: Vec<HookBinding>,
|
|
pub(crate) last_error: Option<String>,
|
|
}
|
|
|
|
impl InstalledExtension {
|
|
pub(crate) fn hook_names(&self) -> String {
|
|
let mut names = self
|
|
.hooks
|
|
.iter()
|
|
.map(|hook| hook.event.as_str())
|
|
.collect::<BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect::<Vec<_>>();
|
|
names.sort_unstable();
|
|
if names.is_empty() {
|
|
"None".into()
|
|
} else {
|
|
names.join(", ")
|
|
}
|
|
}
|
|
|
|
pub(crate) fn has_commands(&self) -> bool {
|
|
!self.hooks.is_empty()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub(crate) struct ExtensionRegistry {
|
|
schema_version: u32,
|
|
pub(crate) extensions: Vec<InstalledExtension>,
|
|
#[serde(skip)]
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl ExtensionRegistry {
|
|
pub(crate) fn load(root: &Path) -> Result<Self, String> {
|
|
let path = root.join("registry.json");
|
|
let mut registry = match fs::read(&path) {
|
|
Ok(bytes) => serde_json::from_slice::<Self>(&bytes)
|
|
.map_err(|error| format!("Could not read {}: {error}", path.display()))?,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self {
|
|
schema_version: REGISTRY_SCHEMA,
|
|
extensions: Vec::new(),
|
|
root: root.to_owned(),
|
|
},
|
|
Err(error) => return Err(format!("Could not read {}: {error}", path.display())),
|
|
};
|
|
if registry.schema_version != REGISTRY_SCHEMA {
|
|
return Err(format!(
|
|
"Extension registry schema {} is unsupported; expected {REGISTRY_SCHEMA}",
|
|
registry.schema_version
|
|
));
|
|
}
|
|
registry.root = root.to_owned();
|
|
let mut ids = BTreeSet::new();
|
|
for extension in ®istry.extensions {
|
|
if !valid_id(&extension.id) || !ids.insert(extension.id.clone()) {
|
|
return Err(format!(
|
|
"Extension registry contains an invalid or duplicate ID: {}",
|
|
extension.id
|
|
));
|
|
}
|
|
}
|
|
Ok(registry)
|
|
}
|
|
|
|
pub(crate) fn empty(root: &Path) -> Self {
|
|
Self {
|
|
schema_version: REGISTRY_SCHEMA,
|
|
extensions: Vec::new(),
|
|
root: root.to_owned(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn enabled_ids(&self) -> BTreeSet<&str> {
|
|
self.extensions
|
|
.iter()
|
|
.filter(|extension| extension.enabled)
|
|
.map(|extension| extension.id.as_str())
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn enabled_skill_roots(&self) -> Result<Vec<(String, PathBuf)>, String> {
|
|
self.extensions
|
|
.iter()
|
|
.filter(|extension| extension.enabled)
|
|
.filter_map(|extension| {
|
|
extension.skills_path.as_ref().map(|skills| {
|
|
let root = self.package_root(&extension.id).join(skills);
|
|
resolve_inside(&self.package_root(&extension.id), &root)
|
|
.map(|root| (extension.id.clone(), root))
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn install(
|
|
root: &Path,
|
|
source_url: &str,
|
|
requested_ref: Option<&str>,
|
|
) -> Result<Self, String> {
|
|
validate_source_url(source_url)?;
|
|
let requested_ref = requested_ref
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(str::to_owned);
|
|
let mut registry = Self::load(root)?;
|
|
fs::create_dir_all(root.join("packages")).map_err(|error| error.to_string())?;
|
|
let staging = temporary_path(&root.join("packages"), "staging");
|
|
let result = (|| {
|
|
let repository = git2::build::RepoBuilder::new()
|
|
.clone(source_url, &staging)
|
|
.map_err(|error| format!("Could not clone extension: {error}"))?;
|
|
let commit = checkout_requested(&repository, requested_ref.as_deref())?;
|
|
let mut extension =
|
|
validate_package(&staging, source_url, requested_ref.clone(), commit)?;
|
|
if registry
|
|
.extensions
|
|
.iter()
|
|
.any(|installed| installed.id == extension.id)
|
|
{
|
|
return Err(format!("Extension {} is already installed", extension.id));
|
|
}
|
|
extension.enabled = false;
|
|
extension.trusted = false;
|
|
promote_package(root, &extension.id, &staging, None)?;
|
|
let package = root.join("packages").join(&extension.id);
|
|
registry.extensions.push(extension);
|
|
if let Err(error) = registry.save() {
|
|
let _ = fs::remove_dir_all(package);
|
|
return Err(error);
|
|
}
|
|
Ok(registry)
|
|
})();
|
|
if staging.exists() {
|
|
let _ = fs::remove_dir_all(&staging);
|
|
}
|
|
result
|
|
}
|
|
|
|
pub(crate) fn update(root: &Path, id: &str) -> Result<Self, String> {
|
|
let mut registry = Self::load(root)?;
|
|
let index = registry
|
|
.extensions
|
|
.iter()
|
|
.position(|extension| extension.id == id)
|
|
.ok_or_else(|| format!("Extension {id} is not installed"))?;
|
|
let previous = registry.extensions[index].clone();
|
|
validate_source_url(&previous.source_url)?;
|
|
let staging = temporary_path(&root.join("packages"), "staging");
|
|
let result = (|| {
|
|
let repository = git2::build::RepoBuilder::new()
|
|
.clone(&previous.source_url, &staging)
|
|
.map_err(|error| format!("Could not clone extension update: {error}"))?;
|
|
let commit = checkout_requested(&repository, previous.requested_ref.as_deref())?;
|
|
let mut updated = validate_package(
|
|
&staging,
|
|
&previous.source_url,
|
|
previous.requested_ref.clone(),
|
|
commit,
|
|
)?;
|
|
if updated.id != previous.id {
|
|
return Err(format!(
|
|
"Extension update changed its ID from {} to {}",
|
|
previous.id, updated.id
|
|
));
|
|
}
|
|
updated.enabled = previous.enabled;
|
|
updated.trusted = previous.trusted;
|
|
let backup = promote_package(root, id, &staging, Some("previous"))?;
|
|
registry.extensions[index] = updated;
|
|
if let Err(error) = registry.save() {
|
|
rollback_package(root, id, backup.as_deref());
|
|
return Err(error);
|
|
}
|
|
if let Some(backup) = backup {
|
|
let _ = fs::remove_dir_all(backup);
|
|
}
|
|
Ok(registry)
|
|
})();
|
|
if staging.exists() {
|
|
let _ = fs::remove_dir_all(&staging);
|
|
}
|
|
result
|
|
}
|
|
|
|
pub(crate) fn set_enabled(root: &Path, id: &str, enabled: bool) -> Result<Self, String> {
|
|
let mut registry = Self::load(root)?;
|
|
let extension = registry
|
|
.extensions
|
|
.iter_mut()
|
|
.find(|extension| extension.id == id)
|
|
.ok_or_else(|| format!("Extension {id} is not installed"))?;
|
|
if enabled && extension.has_commands() && !extension.trusted {
|
|
return Err(format!(
|
|
"Extension {id} command hooks have not been trusted"
|
|
));
|
|
}
|
|
extension.enabled = enabled;
|
|
extension.last_error = None;
|
|
registry.validate_skill_conflicts()?;
|
|
registry.save()?;
|
|
Ok(registry)
|
|
}
|
|
|
|
pub(crate) fn trust_and_enable(root: &Path, id: &str) -> Result<Self, String> {
|
|
let mut registry = Self::load(root)?;
|
|
let extension = registry
|
|
.extensions
|
|
.iter_mut()
|
|
.find(|extension| extension.id == id)
|
|
.ok_or_else(|| format!("Extension {id} is not installed"))?;
|
|
extension.trusted = true;
|
|
extension.enabled = true;
|
|
extension.last_error = None;
|
|
registry.validate_skill_conflicts()?;
|
|
registry.save()?;
|
|
Ok(registry)
|
|
}
|
|
|
|
pub(crate) fn uninstall(root: &Path, id: &str) -> Result<Self, String> {
|
|
let mut registry = Self::load(root)?;
|
|
let index = registry
|
|
.extensions
|
|
.iter()
|
|
.position(|extension| extension.id == id)
|
|
.ok_or_else(|| format!("Extension {id} is not installed"))?;
|
|
let package = root.join("packages").join(id);
|
|
let data = root.join("data").join(id);
|
|
let package_tomb = temporary_path(root, "uninstall-package");
|
|
let data_tomb = temporary_path(root, "uninstall-data");
|
|
if package.exists() {
|
|
fs::rename(&package, &package_tomb).map_err(|error| {
|
|
format!("Could not stage {} for removal: {error}", package.display())
|
|
})?;
|
|
}
|
|
if data.exists()
|
|
&& let Err(error) = fs::rename(&data, &data_tomb)
|
|
{
|
|
if package_tomb.exists() {
|
|
let _ = fs::rename(&package_tomb, &package);
|
|
}
|
|
return Err(format!(
|
|
"Could not stage {} for removal: {error}",
|
|
data.display()
|
|
));
|
|
}
|
|
registry.extensions.remove(index);
|
|
if let Err(error) = registry.save() {
|
|
if package_tomb.exists() {
|
|
let _ = fs::rename(&package_tomb, &package);
|
|
}
|
|
if data_tomb.exists() {
|
|
let _ = fs::rename(&data_tomb, &data);
|
|
}
|
|
return Err(error);
|
|
}
|
|
let _ = fs::remove_dir_all(package_tomb);
|
|
let _ = fs::remove_dir_all(data_tomb);
|
|
Ok(registry)
|
|
}
|
|
|
|
pub(crate) fn record_hook_results(&mut self, results: &HookBatchResult) -> Result<(), String> {
|
|
let mut current = Self::load(&self.root)?;
|
|
current.apply_hook_results(results);
|
|
current.save()?;
|
|
*self = current;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn persist_hook_results(&self, results: &HookBatchResult) -> Result<(), String> {
|
|
let mut current = Self::load(&self.root)?;
|
|
current.apply_hook_results(results);
|
|
current.save()
|
|
}
|
|
|
|
fn apply_hook_results(&mut self, results: &HookBatchResult) {
|
|
for extension in &mut self.extensions {
|
|
if let Some(error) = results.errors.get(&extension.id) {
|
|
extension.last_error = Some(error.clone());
|
|
} else if results.invoked.contains(&extension.id) {
|
|
extension.last_error = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn dispatch(
|
|
&self,
|
|
events: &[HookEvent],
|
|
project_root: &Path,
|
|
model: &str,
|
|
cancel: &AtomicBool,
|
|
) -> HookBatchResult {
|
|
let mut result = HookBatchResult::default();
|
|
for extension in self.extensions.iter().filter(|extension| extension.enabled) {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
let package_root = self.package_root(&extension.id);
|
|
for event in events {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
let bindings = extension
|
|
.hooks
|
|
.iter()
|
|
.filter(|binding| binding.event == event.name())
|
|
.filter(|binding| matcher_applies(binding.matcher.as_deref(), event));
|
|
for binding in bindings {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
result.invoked.insert(extension.id.clone());
|
|
match run_hook(
|
|
extension,
|
|
binding,
|
|
event,
|
|
(
|
|
&package_root,
|
|
&self.root.join("data").join(&extension.id),
|
|
project_root,
|
|
),
|
|
model,
|
|
cancel,
|
|
) {
|
|
Ok(output) => result.outputs.push(output),
|
|
Err(error) => {
|
|
result.errors.entry(extension.id.clone()).or_insert(error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if !cancel.load(Ordering::Relaxed)
|
|
&& extension.id == "ponytail"
|
|
&& ponytail_mode_command(event)
|
|
{
|
|
let turned_off = result.outputs.iter().rev().any(|output| {
|
|
output.extension_id == extension.id
|
|
&& output.event == "UserPromptSubmit"
|
|
&& output.system_message.as_deref() == Some("PONYTAIL:OFF")
|
|
});
|
|
if turned_off {
|
|
result.outputs.push(HookOutput {
|
|
extension_id: extension.id.clone(),
|
|
event: "SessionStart".into(),
|
|
additional_context: Some(String::new()),
|
|
system_message: None,
|
|
});
|
|
continue;
|
|
}
|
|
let refresh = HookEvent::SessionStart {
|
|
session_id: event.session_id(),
|
|
reason: "resume",
|
|
};
|
|
for binding in extension
|
|
.hooks
|
|
.iter()
|
|
.filter(|binding| binding.event == refresh.name())
|
|
.filter(|binding| matcher_applies(binding.matcher.as_deref(), &refresh))
|
|
{
|
|
result.invoked.insert(extension.id.clone());
|
|
match run_hook(
|
|
extension,
|
|
binding,
|
|
&refresh,
|
|
(
|
|
&package_root,
|
|
&self.root.join("data").join(&extension.id),
|
|
project_root,
|
|
),
|
|
model,
|
|
cancel,
|
|
) {
|
|
Ok(mut output) => {
|
|
output.additional_context.get_or_insert_with(String::new);
|
|
result.outputs.push(output);
|
|
}
|
|
Err(error) => {
|
|
result.errors.entry(extension.id.clone()).or_insert(error);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
pub(crate) fn status_for(&self, events: &[HookEvent]) -> Option<String> {
|
|
let statuses = self
|
|
.extensions
|
|
.iter()
|
|
.filter(|extension| extension.enabled)
|
|
.flat_map(|extension| {
|
|
extension.hooks.iter().filter_map(|binding| {
|
|
events
|
|
.iter()
|
|
.any(|event| {
|
|
binding.event == event.name()
|
|
&& matcher_applies(binding.matcher.as_deref(), event)
|
|
})
|
|
.then(|| binding.status_message.clone())
|
|
.flatten()
|
|
})
|
|
})
|
|
.collect::<BTreeSet<_>>();
|
|
(!statuses.is_empty()).then(|| statuses.into_iter().collect::<Vec<_>>().join(" · "))
|
|
}
|
|
|
|
pub(crate) fn has_hooks_for(&self, events: &[HookEvent]) -> bool {
|
|
self.extensions
|
|
.iter()
|
|
.filter(|extension| extension.enabled)
|
|
.any(|extension| {
|
|
extension.hooks.iter().any(|binding| {
|
|
events.iter().any(|event| {
|
|
binding.event == event.name()
|
|
&& matcher_applies(binding.matcher.as_deref(), event)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
fn validate_skill_conflicts(&self) -> Result<(), String> {
|
|
let mut names = BTreeMap::<String, String>::new();
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
for skill in
|
|
crate::agent::discover_agent_skills(&PathBuf::from(home).join(".agents/skills"))
|
|
{
|
|
names.insert(skill.name, "standard agent skills".into());
|
|
}
|
|
}
|
|
for (id, root) in self.enabled_skill_roots()? {
|
|
for skill in crate::agent::discover_agent_skills(&root) {
|
|
if let Some(owner) = names.insert(skill.name.clone(), id.clone()) {
|
|
return Err(format!(
|
|
"Skill {} is declared by both {owner} and {id}",
|
|
skill.name
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn package_root(&self, id: &str) -> PathBuf {
|
|
self.root.join("packages").join(id).join("current")
|
|
}
|
|
|
|
fn save(&self) -> Result<(), String> {
|
|
fs::create_dir_all(&self.root).map_err(|error| error.to_string())?;
|
|
let path = self.root.join("registry.json");
|
|
let temporary = self.root.join("registry.json.tmp");
|
|
let bytes = serde_json::to_vec_pretty(self).map_err(|error| error.to_string())?;
|
|
fs::write(&temporary, bytes)
|
|
.map_err(|error| format!("Could not write {}: {error}", temporary.display()))?;
|
|
fs::rename(&temporary, &path)
|
|
.map_err(|error| format!("Could not publish {}: {error}", path.display()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) enum HookEvent {
|
|
SessionStart {
|
|
session_id: i32,
|
|
reason: &'static str,
|
|
},
|
|
UserPromptSubmit {
|
|
session_id: i32,
|
|
prompt: String,
|
|
},
|
|
SubagentStart {
|
|
session_id: i32,
|
|
round: usize,
|
|
},
|
|
}
|
|
|
|
impl HookEvent {
|
|
pub(crate) fn name(&self) -> &'static str {
|
|
match self {
|
|
Self::SessionStart { .. } => "SessionStart",
|
|
Self::UserPromptSubmit { .. } => "UserPromptSubmit",
|
|
Self::SubagentStart { .. } => "SubagentStart",
|
|
}
|
|
}
|
|
|
|
fn session_id(&self) -> i32 {
|
|
match self {
|
|
Self::SessionStart { session_id, .. }
|
|
| Self::UserPromptSubmit { session_id, .. }
|
|
| Self::SubagentStart { session_id, .. } => *session_id,
|
|
}
|
|
}
|
|
|
|
fn matcher_value(&self) -> &str {
|
|
match self {
|
|
Self::SessionStart { reason, .. } => reason,
|
|
Self::SubagentStart { .. } => "ralph",
|
|
Self::UserPromptSubmit { .. } => "",
|
|
}
|
|
}
|
|
|
|
fn payload(&self, project_root: &Path, model: &str) -> Value {
|
|
let mut payload = serde_json::json!({
|
|
"hook_event_name": self.name(),
|
|
"event_name": self.name(),
|
|
"session_id": self.session_id().to_string(),
|
|
"cwd": project_root,
|
|
"project_dir": project_root,
|
|
"model": model,
|
|
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),
|
|
});
|
|
match self {
|
|
Self::SessionStart { reason, .. } => {
|
|
payload["source"] = Value::String((*reason).into())
|
|
}
|
|
Self::UserPromptSubmit { prompt, .. } => {
|
|
payload["prompt"] = Value::String(prompt.clone())
|
|
}
|
|
Self::SubagentStart { round, .. } => {
|
|
payload["agent_type"] = Value::String("ralph".into());
|
|
payload["round"] = Value::from(*round as u64);
|
|
}
|
|
}
|
|
payload
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct HookOutput {
|
|
pub(crate) extension_id: String,
|
|
pub(crate) event: String,
|
|
pub(crate) additional_context: Option<String>,
|
|
pub(crate) system_message: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct HookBatchResult {
|
|
pub(crate) outputs: Vec<HookOutput>,
|
|
pub(crate) errors: BTreeMap<String, String>,
|
|
invoked: BTreeSet<String>,
|
|
}
|
|
|
|
impl HookBatchResult {
|
|
pub(crate) fn status(&self) -> Option<String> {
|
|
let statuses = self
|
|
.outputs
|
|
.iter()
|
|
.filter_map(|output| output.system_message.as_deref())
|
|
.collect::<Vec<_>>();
|
|
(!statuses.is_empty()).then(|| statuses.join(" · "))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn wrap_context(id: &str, event: &str, context: &str) -> String {
|
|
format!("{CONTEXT_PREFIX}{id} {event}]\n{context}")
|
|
}
|
|
|
|
pub(crate) fn context_identity(content: &str) -> Option<(&str, &str)> {
|
|
let header = content.strip_prefix(CONTEXT_PREFIX)?.split_once("]\n")?.0;
|
|
let (id, event) = header.split_once(' ')?;
|
|
Some((id, event))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawManifest {
|
|
name: String,
|
|
version: String,
|
|
#[serde(default)]
|
|
description: String,
|
|
#[serde(default)]
|
|
author: Option<RawAuthor>,
|
|
#[serde(default)]
|
|
skills: Option<String>,
|
|
#[serde(default)]
|
|
hooks: Option<String>,
|
|
#[serde(default, alias = "schemaVersion")]
|
|
schema_version: Option<u32>,
|
|
#[serde(flatten)]
|
|
extra: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum RawAuthor {
|
|
Name(String),
|
|
Object { name: String },
|
|
}
|
|
|
|
impl RawAuthor {
|
|
fn name(self) -> String {
|
|
match self {
|
|
Self::Name(name) | Self::Object { name } => name,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawHookManifest {
|
|
hooks: BTreeMap<String, Vec<RawHookGroup>>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawHookGroup {
|
|
#[serde(default)]
|
|
matcher: Option<String>,
|
|
hooks: Vec<RawHook>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawHook {
|
|
#[serde(rename = "type")]
|
|
kind: String,
|
|
command: String,
|
|
#[serde(default = "default_timeout")]
|
|
timeout: u64,
|
|
#[serde(default, rename = "statusMessage")]
|
|
status_message: Option<String>,
|
|
}
|
|
|
|
fn default_timeout() -> u64 {
|
|
5
|
|
}
|
|
|
|
fn validate_package(
|
|
root: &Path,
|
|
source_url: &str,
|
|
requested_ref: Option<String>,
|
|
commit: git2::Oid,
|
|
) -> Result<InstalledExtension, String> {
|
|
let root = root.canonicalize().map_err(|error| error.to_string())?;
|
|
let manifest_path = resolve_inside(&root, &root.join(MANIFEST_PATH))?;
|
|
let raw = fs::read(&manifest_path)
|
|
.map_err(|error| format!("Could not read {}: {error}", manifest_path.display()))?;
|
|
let manifest: RawManifest = serde_json::from_slice(&raw)
|
|
.map_err(|error| format!("Invalid extension manifest: {error}"))?;
|
|
if manifest.schema_version.is_some_and(|version| version != 1) {
|
|
return Err(format!(
|
|
"Extension manifest schema {} is unsupported; expected 1",
|
|
manifest.schema_version.unwrap()
|
|
));
|
|
}
|
|
if !valid_id(&manifest.name) {
|
|
return Err(
|
|
"Extension name must be a lowercase ID using letters, digits, and hyphens".into(),
|
|
);
|
|
}
|
|
if manifest.version.trim().is_empty() || manifest.version.len() > 64 {
|
|
return Err("Extension version must be 1 to 64 characters".into());
|
|
}
|
|
for capability in [
|
|
"commands",
|
|
"agents",
|
|
"mcpServers",
|
|
"mcp_servers",
|
|
"apps",
|
|
"scripts",
|
|
] {
|
|
if manifest.extra.contains_key(capability) {
|
|
return Err(format!(
|
|
"Extension capability {capability} is executable and unsupported by DS4Server"
|
|
));
|
|
}
|
|
}
|
|
let skills_path = manifest
|
|
.skills
|
|
.as_deref()
|
|
.map(|path| validate_relative(path, "skills"))
|
|
.transpose()?;
|
|
let mut skill_count = 0;
|
|
if let Some(path) = &skills_path {
|
|
let skills = resolve_inside(&root, &root.join(path))?;
|
|
reject_symlink_escapes(&root, &skills)?;
|
|
let discovered = crate::agent::discover_agent_skills(&skills);
|
|
skill_count = discovered.len();
|
|
if skill_count == 0 {
|
|
return Err(format!(
|
|
"Extension skill directory {} has no valid skills",
|
|
skills.display()
|
|
));
|
|
}
|
|
}
|
|
let hooks_path = manifest
|
|
.hooks
|
|
.as_deref()
|
|
.map(|path| validate_relative(path, "hooks"))
|
|
.transpose()?;
|
|
if skills_path.is_none() && hooks_path.is_none() {
|
|
return Err("Extension declares neither skills nor lifecycle hooks".into());
|
|
}
|
|
let hooks = hooks_path
|
|
.as_ref()
|
|
.map(|path| parse_hooks(&root, path))
|
|
.transpose()?
|
|
.unwrap_or_default();
|
|
Ok(InstalledExtension {
|
|
id: manifest.name.clone(),
|
|
name: manifest.name,
|
|
version: manifest.version,
|
|
description: manifest.description,
|
|
author: manifest.author.map(RawAuthor::name).unwrap_or_default(),
|
|
enabled: false,
|
|
trusted: false,
|
|
source_url: source_url.into(),
|
|
requested_ref,
|
|
resolved_commit: commit.to_string(),
|
|
skills_path,
|
|
skill_count,
|
|
hooks_path,
|
|
hooks,
|
|
last_error: None,
|
|
})
|
|
}
|
|
|
|
fn parse_hooks(root: &Path, relative: &str) -> Result<Vec<HookBinding>, String> {
|
|
let path = resolve_inside(root, &root.join(relative))?;
|
|
let raw =
|
|
fs::read(&path).map_err(|error| format!("Could not read {}: {error}", path.display()))?;
|
|
let manifest: RawHookManifest =
|
|
serde_json::from_slice(&raw).map_err(|error| format!("Invalid hook manifest: {error}"))?;
|
|
let mut bindings = Vec::new();
|
|
for (event, groups) in manifest.hooks {
|
|
if !matches!(
|
|
event.as_str(),
|
|
"SessionStart" | "UserPromptSubmit" | "SubagentStart"
|
|
) {
|
|
return Err(format!("Hook event {event} is unsupported by DS4Server"));
|
|
}
|
|
for group in groups {
|
|
if let Some(matcher) = group.matcher.as_deref() {
|
|
Regex::new(matcher).map_err(|error| format!("Invalid {event} matcher: {error}"))?;
|
|
}
|
|
for hook in group.hooks {
|
|
if hook.kind != "command" {
|
|
return Err(format!("Hook type {} is unsupported", hook.kind));
|
|
}
|
|
if hook.timeout == 0 || hook.timeout > MAX_HOOK_TIMEOUT {
|
|
return Err(format!(
|
|
"Hook timeout must be between 1 and {MAX_HOOK_TIMEOUT} seconds"
|
|
));
|
|
}
|
|
let argv = shlex::split(&hook.command)
|
|
.ok_or_else(|| format!("Hook command has invalid quoting: {}", hook.command))?;
|
|
if argv.is_empty() {
|
|
return Err("Hook command is empty".into());
|
|
}
|
|
validate_command_paths(root, &argv)?;
|
|
bindings.push(HookBinding {
|
|
event: event.clone(),
|
|
matcher: group.matcher.clone(),
|
|
argv,
|
|
timeout_seconds: hook.timeout,
|
|
status_message: hook.status_message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(bindings)
|
|
}
|
|
|
|
fn run_hook(
|
|
extension: &InstalledExtension,
|
|
binding: &HookBinding,
|
|
event: &HookEvent,
|
|
roots: (&Path, &Path, &Path),
|
|
model: &str,
|
|
cancel: &AtomicBool,
|
|
) -> Result<HookOutput, String> {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
return Err(format!("{} hook was cancelled", event.name()));
|
|
}
|
|
let (package_root, data_root, project_root) = roots;
|
|
let package_root = package_root
|
|
.canonicalize()
|
|
.map_err(|error| error.to_string())?;
|
|
let project_root = project_root
|
|
.canonicalize()
|
|
.map_err(|error| format!("Could not resolve project root: {error}"))?;
|
|
let session_data = data_root
|
|
.join("sessions")
|
|
.join(event.session_id().to_string());
|
|
let config = data_root.join("config");
|
|
let home = data_root.join("home");
|
|
let temporary = data_root.join("tmp");
|
|
for directory in [&session_data, &config, &home, &temporary] {
|
|
fs::create_dir_all(directory).map_err(|error| error.to_string())?;
|
|
}
|
|
let argv = expand_argv(&binding.argv, &package_root, &session_data, data_root)?;
|
|
let mut event_payload = event.payload(&project_root, model);
|
|
if extension.id == "ponytail"
|
|
&& let HookEvent::UserPromptSubmit { prompt, .. } = event
|
|
&& prompt.trim() == "/ponytail status"
|
|
{
|
|
// Ponytail 4.9 implements status as the bare command. DS4Server keeps
|
|
// the user's original prompt intact and adapts only the hook payload.
|
|
event_payload["prompt"] = Value::String("/ponytail".into());
|
|
}
|
|
let payload = serde_json::to_vec(&event_payload).map_err(|error| error.to_string())?;
|
|
if payload.len() > MAX_HOOK_INPUT {
|
|
return Err("Hook event exceeds the input limit".into());
|
|
}
|
|
let mut command = Command::new(&argv[0]);
|
|
command
|
|
.args(&argv[1..])
|
|
.current_dir(&project_root)
|
|
.env_clear()
|
|
.env("PATH", crate::agent::shell_path())
|
|
.env("HOME", &home)
|
|
.env("TMPDIR", &temporary)
|
|
.env("XDG_CONFIG_HOME", &config)
|
|
.env("CLAUDE_CONFIG_DIR", data_root.join("claude"))
|
|
.env("CLAUDE_PLUGIN_ROOT", &package_root)
|
|
.env("PLUGIN_DATA", &session_data)
|
|
.env("DS4SERVER_PLUGIN_ROOT", &package_root)
|
|
.env("DS4SERVER_PLUGIN_DATA", data_root)
|
|
.env("DS4SERVER_PROJECT_ROOT", &project_root)
|
|
.env("DS4SERVER_SESSION_ID", event.session_id().to_string())
|
|
.env("DS4SERVER_EVENT_NAME", event.name())
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.process_group(0);
|
|
if extension.id == "ponytail"
|
|
&& matches!(event, HookEvent::SessionStart { .. })
|
|
&& let Ok(mode) = fs::read_to_string(session_data.join(".ponytail-active"))
|
|
&& matches!(mode.trim(), "off" | "lite" | "full" | "ultra")
|
|
{
|
|
command.env("PONYTAIL_DEFAULT_MODE", mode.trim());
|
|
}
|
|
let mut child = command.spawn().map_err(|error| {
|
|
format!(
|
|
"Could not start {} hook executable {}: {error}. Install it or disable the extension.",
|
|
event.name(),
|
|
argv[0]
|
|
)
|
|
})?;
|
|
child
|
|
.stdin
|
|
.take()
|
|
.ok_or_else(|| "Hook stdin is unavailable".to_owned())?
|
|
.write_all(&payload)
|
|
.map_err(|error| format!("Could not write hook input: {error}"))?;
|
|
let stdout = child
|
|
.stdout
|
|
.take()
|
|
.ok_or_else(|| "Hook stdout is unavailable".to_owned())?;
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or_else(|| "Hook stderr is unavailable".to_owned())?;
|
|
let stdout = thread::spawn(move || read_bounded(stdout));
|
|
let stderr = thread::spawn(move || read_bounded(stderr));
|
|
let deadline = Instant::now() + Duration::from_secs(binding.timeout_seconds);
|
|
let status = loop {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
terminate_process_group(&mut child);
|
|
return Err(format!("{} hook was cancelled", event.name()));
|
|
}
|
|
if Instant::now() >= deadline {
|
|
terminate_process_group(&mut child);
|
|
return Err(format!(
|
|
"{} hook exceeded its {} second timeout",
|
|
event.name(),
|
|
binding.timeout_seconds
|
|
));
|
|
}
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => break status,
|
|
Ok(None) => thread::sleep(Duration::from_millis(20)),
|
|
Err(error) => {
|
|
terminate_process_group(&mut child);
|
|
return Err(format!("Could not wait for {} hook: {error}", event.name()));
|
|
}
|
|
}
|
|
};
|
|
let stdout = stdout
|
|
.join()
|
|
.map_err(|_| "Hook stdout reader panicked".to_owned())??;
|
|
let stderr = stderr
|
|
.join()
|
|
.map_err(|_| "Hook stderr reader panicked".to_owned())??;
|
|
if !status.success() {
|
|
return Err(format!(
|
|
"{} hook exited with {}{}",
|
|
event.name(),
|
|
status,
|
|
if stderr.trim().is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(": {}", stderr.trim())
|
|
}
|
|
));
|
|
}
|
|
parse_hook_output(&extension.id, event.name(), stdout)
|
|
}
|
|
|
|
fn parse_hook_output(id: &str, event: &str, stdout: String) -> Result<HookOutput, String> {
|
|
let stdout = stdout.trim();
|
|
if stdout.is_empty() {
|
|
return Ok(HookOutput {
|
|
extension_id: id.into(),
|
|
event: event.into(),
|
|
additional_context: None,
|
|
system_message: None,
|
|
});
|
|
}
|
|
let Ok(value) = serde_json::from_str::<Value>(stdout) else {
|
|
if stdout.starts_with('{') || stdout.starts_with('[') {
|
|
return Err(format!("{event} hook returned malformed JSON"));
|
|
}
|
|
return Ok(HookOutput {
|
|
extension_id: id.into(),
|
|
event: event.into(),
|
|
additional_context: Some(stdout.into()),
|
|
system_message: None,
|
|
});
|
|
};
|
|
let object = value
|
|
.as_object()
|
|
.ok_or_else(|| format!("{event} hook JSON output must be an object"))?;
|
|
let specific = object.get("hookSpecificOutput").and_then(Value::as_object);
|
|
if let Some(name) = specific
|
|
.and_then(|specific| specific.get("hookEventName"))
|
|
.and_then(Value::as_str)
|
|
&& name != event
|
|
{
|
|
return Err(format!("{event} hook returned output for {name}"));
|
|
}
|
|
let additional_context = specific
|
|
.and_then(|specific| specific.get("additionalContext"))
|
|
.or_else(|| object.get("additionalContext"))
|
|
.map(|value| {
|
|
value
|
|
.as_str()
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| format!("{event} additionalContext must be a string"))
|
|
})
|
|
.transpose()?;
|
|
let system_message = object
|
|
.get("systemMessage")
|
|
.map(|value| {
|
|
value
|
|
.as_str()
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| format!("{event} systemMessage must be a string"))
|
|
})
|
|
.transpose()?;
|
|
Ok(HookOutput {
|
|
extension_id: id.into(),
|
|
event: event.into(),
|
|
additional_context,
|
|
system_message,
|
|
})
|
|
}
|
|
|
|
fn read_bounded(mut reader: impl Read) -> Result<String, String> {
|
|
let mut bytes = Vec::new();
|
|
reader
|
|
.by_ref()
|
|
.take((MAX_HOOK_OUTPUT + 1) as u64)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|error| error.to_string())?;
|
|
if bytes.len() > MAX_HOOK_OUTPUT {
|
|
return Err(format!("Hook output exceeds {MAX_HOOK_OUTPUT} bytes"));
|
|
}
|
|
String::from_utf8(bytes).map_err(|_| "Hook output is not valid UTF-8".into())
|
|
}
|
|
|
|
fn terminate_process_group(child: &mut std::process::Child) {
|
|
let Ok(group) = i32::try_from(child.id()).map(|pid| -pid) else {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return;
|
|
};
|
|
// SAFETY: `group` is the freshly spawned child's process group and the
|
|
// signal constants are valid on the supported Unix platform.
|
|
unsafe {
|
|
libc::kill(group, libc::SIGTERM);
|
|
}
|
|
let deadline = Instant::now() + Duration::from_millis(250);
|
|
while Instant::now() < deadline {
|
|
if child.try_wait().ok().flatten().is_some() {
|
|
return;
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
// SAFETY: same process group and platform invariants as above.
|
|
unsafe {
|
|
libc::kill(group, libc::SIGKILL);
|
|
}
|
|
let _ = child.wait();
|
|
}
|
|
|
|
fn matcher_applies(matcher: Option<&str>, event: &HookEvent) -> bool {
|
|
matcher.is_none_or(|matcher| {
|
|
Regex::new(matcher).is_ok_and(|matcher| matcher.is_match(event.matcher_value()))
|
|
})
|
|
}
|
|
|
|
fn expand_argv(
|
|
argv: &[String],
|
|
package_root: &Path,
|
|
session_data: &Path,
|
|
data_root: &Path,
|
|
) -> Result<Vec<String>, String> {
|
|
argv.iter()
|
|
.map(|argument| {
|
|
let expanded = argument
|
|
.replace("${CLAUDE_PLUGIN_ROOT}", &package_root.to_string_lossy())
|
|
.replace("${DS4SERVER_PLUGIN_ROOT}", &package_root.to_string_lossy())
|
|
.replace("${PLUGIN_DATA}", &session_data.to_string_lossy())
|
|
.replace("${DS4SERVER_PLUGIN_DATA}", &data_root.to_string_lossy());
|
|
if expanded.contains('$') {
|
|
return Err(format!(
|
|
"Hook argument contains an unsupported variable: {argument}"
|
|
));
|
|
}
|
|
Ok(expanded)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn validate_command_paths(root: &Path, argv: &[String]) -> Result<(), String> {
|
|
for argument in argv {
|
|
let without_variables = [
|
|
"${CLAUDE_PLUGIN_ROOT}",
|
|
"${DS4SERVER_PLUGIN_ROOT}",
|
|
"${PLUGIN_DATA}",
|
|
"${DS4SERVER_PLUGIN_DATA}",
|
|
]
|
|
.iter()
|
|
.fold(argument.clone(), |value, variable| {
|
|
value.replace(variable, "")
|
|
});
|
|
if without_variables.contains('$') {
|
|
return Err(format!(
|
|
"Hook command uses an unsupported variable: {argument}"
|
|
));
|
|
}
|
|
if argument.contains("${CLAUDE_PLUGIN_ROOT}")
|
|
|| argument.contains("${DS4SERVER_PLUGIN_ROOT}")
|
|
{
|
|
let expanded = argument
|
|
.replace("${CLAUDE_PLUGIN_ROOT}", &root.to_string_lossy())
|
|
.replace("${DS4SERVER_PLUGIN_ROOT}", &root.to_string_lossy());
|
|
resolve_inside(root, Path::new(&expanded))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ponytail_mode_command(event: &HookEvent) -> bool {
|
|
let HookEvent::UserPromptSubmit { prompt, .. } = event else {
|
|
return false;
|
|
};
|
|
let mut words = prompt.split_whitespace();
|
|
matches!(words.next(), Some("/ponytail") | Some("$ponytail"))
|
|
&& !matches!(words.next(), Some("default"))
|
|
}
|
|
|
|
fn validate_relative(path: &str, label: &str) -> Result<String, String> {
|
|
let path = Path::new(path);
|
|
if path.is_absolute()
|
|
|| path.components().any(|component| {
|
|
matches!(
|
|
component,
|
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
|
)
|
|
})
|
|
{
|
|
return Err(format!(
|
|
"Extension {label} path must stay inside the package"
|
|
));
|
|
}
|
|
let normalized = path
|
|
.components()
|
|
.filter_map(|component| match component {
|
|
Component::Normal(value) => Some(value.to_string_lossy()),
|
|
Component::CurDir => None,
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/");
|
|
if normalized.is_empty() {
|
|
return Err(format!("Extension {label} path is empty"));
|
|
}
|
|
Ok(normalized)
|
|
}
|
|
|
|
fn resolve_inside(root: &Path, path: &Path) -> Result<PathBuf, String> {
|
|
let root = root.canonicalize().map_err(|error| error.to_string())?;
|
|
let path = path
|
|
.canonicalize()
|
|
.map_err(|error| format!("Could not resolve {}: {error}", path.display()))?;
|
|
if !path.starts_with(&root) {
|
|
return Err(format!(
|
|
"Path escapes the extension package: {}",
|
|
path.display()
|
|
));
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
fn reject_symlink_escapes(package_root: &Path, path: &Path) -> Result<(), String> {
|
|
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
|
let entry = entry.map_err(|error| error.to_string())?;
|
|
let metadata = fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?;
|
|
if metadata.file_type().is_symlink() {
|
|
resolve_inside(package_root, &entry.path())?;
|
|
} else if metadata.is_dir() {
|
|
reject_symlink_escapes(package_root, &entry.path())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn valid_id(id: &str) -> bool {
|
|
(1..=64).contains(&id.len())
|
|
&& !id.starts_with('-')
|
|
&& !id.ends_with('-')
|
|
&& !id.contains("--")
|
|
&& id
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
|
}
|
|
|
|
fn validate_source_url(source: &str) -> Result<(), String> {
|
|
let url = url::Url::parse(source).map_err(|error| format!("Invalid extension URL: {error}"))?;
|
|
if url.scheme() != "https"
|
|
|| url.host_str().is_none()
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
{
|
|
return Err(
|
|
"Extension source must be an HTTPS Git URL without embedded credentials".into(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn checkout_requested(
|
|
repository: &git2::Repository,
|
|
requested: Option<&str>,
|
|
) -> Result<git2::Oid, String> {
|
|
let object = if let Some(requested) = requested {
|
|
if requested.contains(['\0', '\n', '\r']) || requested.starts_with('-') {
|
|
return Err("Extension ref contains invalid characters".into());
|
|
}
|
|
[
|
|
requested.to_owned(),
|
|
format!("refs/tags/{requested}"),
|
|
format!("refs/remotes/origin/{requested}"),
|
|
]
|
|
.into_iter()
|
|
.find_map(|candidate| repository.revparse_single(&candidate).ok())
|
|
.ok_or_else(|| format!("Extension ref {requested} was not found"))?
|
|
} else {
|
|
repository
|
|
.head()
|
|
.and_then(|head| head.peel(git2::ObjectType::Commit))
|
|
.map_err(|error| format!("Could not resolve extension HEAD: {error}"))?
|
|
};
|
|
let commit = object
|
|
.peel_to_commit()
|
|
.map_err(|error| format!("Extension ref is not a commit: {error}"))?;
|
|
repository
|
|
.checkout_tree(
|
|
commit.as_object(),
|
|
Some(git2::build::CheckoutBuilder::new().force()),
|
|
)
|
|
.map_err(|error| format!("Could not check out extension ref: {error}"))?;
|
|
repository
|
|
.set_head_detached(commit.id())
|
|
.map_err(|error| format!("Could not pin extension commit: {error}"))?;
|
|
Ok(commit.id())
|
|
}
|
|
|
|
fn promote_package(
|
|
root: &Path,
|
|
id: &str,
|
|
staging: &Path,
|
|
backup_label: Option<&str>,
|
|
) -> Result<Option<PathBuf>, String> {
|
|
let directory = root.join("packages").join(id);
|
|
fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
|
|
let current = directory.join("current");
|
|
let backup = backup_label.map(|label| temporary_path(&directory, label));
|
|
if current.exists() {
|
|
let backup = backup
|
|
.as_ref()
|
|
.ok_or_else(|| "Extension package already exists".to_owned())?;
|
|
fs::rename(¤t, backup).map_err(|error| error.to_string())?;
|
|
}
|
|
if let Err(error) = fs::rename(staging, ¤t) {
|
|
if let Some(backup) = &backup {
|
|
let _ = fs::rename(backup, ¤t);
|
|
}
|
|
return Err(format!("Could not activate extension package: {error}"));
|
|
}
|
|
Ok(backup)
|
|
}
|
|
|
|
fn rollback_package(root: &Path, id: &str, backup: Option<&Path>) {
|
|
let current = root.join("packages").join(id).join("current");
|
|
if current.exists() {
|
|
let _ = fs::remove_dir_all(¤t);
|
|
}
|
|
if let Some(backup) = backup {
|
|
let _ = fs::rename(backup, current);
|
|
}
|
|
}
|
|
|
|
fn temporary_path(parent: &Path, label: &str) -> PathBuf {
|
|
let stamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos();
|
|
parent.join(format!(".{label}-{}-{stamp}", std::process::id()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::os::unix::fs::{PermissionsExt, symlink};
|
|
use std::sync::Arc;
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"ds4-extension-{name}-{}-{}",
|
|
std::process::id(),
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
fs::create_dir_all(root.join(".codex-plugin")).unwrap();
|
|
fs::create_dir_all(root.join("skills/demo")).unwrap();
|
|
fs::create_dir_all(root.join("hooks")).unwrap();
|
|
fs::write(
|
|
root.join("skills/demo/SKILL.md"),
|
|
"---\nname: demo\ndescription: Demonstrate extension skills\n---\nInstructions\n",
|
|
)
|
|
.unwrap();
|
|
fs::write(root.join("hooks/hook"), "fixture hook").unwrap();
|
|
fs::write(
|
|
root.join("hooks/hooks.json"),
|
|
r#"{"hooks":{"SessionStart":[{"matcher":"startup|resume|compact","hooks":[{"type":"command","command":"${CLAUDE_PLUGIN_ROOT}/hooks/hook","timeout":5}]}]}}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
root.join(MANIFEST_PATH),
|
|
r#"{"name":"fixture","version":"1.0.0","description":"Fixture","author":{"name":"DS4"},"skills":"./skills","hooks":"./hooks/hooks.json"}"#,
|
|
)
|
|
.unwrap();
|
|
root
|
|
}
|
|
|
|
#[test]
|
|
fn manifest_and_paths_are_validated_without_following_escapes() {
|
|
let root = fixture("manifest");
|
|
let package = validate_package(
|
|
&root,
|
|
"https://example.com/fixture.git",
|
|
None,
|
|
git2::Oid::ZERO_SHA1,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(package.id, "fixture");
|
|
assert_eq!(package.skill_count, 1);
|
|
assert_eq!(package.hook_names(), "SessionStart");
|
|
|
|
fs::write(
|
|
root.join(MANIFEST_PATH),
|
|
r#"{"name":"fixture","version":"1","schemaVersion":2,"skills":"../outside"}"#,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
validate_package(&root, "https://example.com/x", None, git2::Oid::ZERO_SHA1)
|
|
.unwrap_err()
|
|
.contains("schema 2")
|
|
);
|
|
fs::write(
|
|
root.join(MANIFEST_PATH),
|
|
r#"{"name":"fixture","version":"1","skills":"/tmp"}"#,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
validate_package(&root, "https://example.com/x", None, git2::Oid::ZERO_SHA1)
|
|
.unwrap_err()
|
|
.contains("inside")
|
|
);
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn hook_output_is_bounded_typed_and_event_matched() {
|
|
let output = parse_hook_output(
|
|
"fixture",
|
|
"SessionStart",
|
|
r#"{"systemMessage":"ready","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"rules"}}"#.into(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(output.additional_context.as_deref(), Some("rules"));
|
|
assert_eq!(output.system_message.as_deref(), Some("ready"));
|
|
assert!(
|
|
parse_hook_output(
|
|
"fixture",
|
|
"SessionStart",
|
|
r#"{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit"}}"#.into(),
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(read_bounded(&b"x"[..]).is_ok());
|
|
assert!(read_bounded(&vec![b'x'; MAX_HOOK_OUTPUT + 1][..]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn direct_hook_runner_isolated_environment_and_handles_failures() {
|
|
let root = fixture("runner");
|
|
let script = root.join("hooks/run.sh");
|
|
fs::write(
|
|
&script,
|
|
"#!/bin/sh\nread input\nprintf '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"%s|%s|%s\"}}' \"$PLUGIN_DATA\" \"$HOME\" \"$DS4SERVER_EVENT_NAME\"\n",
|
|
)
|
|
.unwrap();
|
|
let mut permissions = fs::metadata(&script).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(&script, permissions).unwrap();
|
|
let package = InstalledExtension {
|
|
id: "fixture".into(),
|
|
name: "fixture".into(),
|
|
version: "1".into(),
|
|
description: String::new(),
|
|
author: String::new(),
|
|
enabled: true,
|
|
trusted: true,
|
|
source_url: "https://example.com/x".into(),
|
|
requested_ref: None,
|
|
resolved_commit: "0".into(),
|
|
skills_path: None,
|
|
skill_count: 0,
|
|
hooks_path: None,
|
|
hooks: Vec::new(),
|
|
last_error: None,
|
|
};
|
|
let binding = HookBinding {
|
|
event: "SessionStart".into(),
|
|
matcher: None,
|
|
argv: vec!["${CLAUDE_PLUGIN_ROOT}/hooks/run.sh".into()],
|
|
timeout_seconds: 1,
|
|
status_message: None,
|
|
};
|
|
let data = root.with_extension("data");
|
|
let output = run_hook(
|
|
&package,
|
|
&binding,
|
|
&HookEvent::SessionStart {
|
|
session_id: 7,
|
|
reason: "startup",
|
|
},
|
|
(&root, &data, &root),
|
|
"fixture-model",
|
|
&AtomicBool::new(false),
|
|
)
|
|
.unwrap();
|
|
let context = output.additional_context.unwrap();
|
|
assert!(context.contains("sessions/7"));
|
|
assert!(context.contains("/home|SessionStart"));
|
|
fs::remove_dir_all(root).unwrap();
|
|
fs::remove_dir_all(data).unwrap();
|
|
}
|
|
|
|
fn executable(path: &Path, body: &str) {
|
|
fs::write(path, body).unwrap();
|
|
let mut permissions = fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
|
|
fn test_extension(argv: Vec<String>, timeout_seconds: u64) -> InstalledExtension {
|
|
InstalledExtension {
|
|
id: "fixture".into(),
|
|
name: "fixture".into(),
|
|
version: "1".into(),
|
|
description: String::new(),
|
|
author: String::new(),
|
|
enabled: true,
|
|
trusted: true,
|
|
source_url: "https://example.com/x".into(),
|
|
requested_ref: None,
|
|
resolved_commit: "0".into(),
|
|
skills_path: None,
|
|
skill_count: 0,
|
|
hooks_path: None,
|
|
hooks: vec![HookBinding {
|
|
event: "SessionStart".into(),
|
|
matcher: None,
|
|
argv,
|
|
timeout_seconds,
|
|
status_message: Some("fixture status".into()),
|
|
}],
|
|
last_error: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn registry_persists_enable_disable_errors_and_uninstall() {
|
|
let root = fixture("registry").with_extension("registry-data");
|
|
let package = root.join("packages/fixture/current");
|
|
fs::create_dir_all(&package).unwrap();
|
|
fs::create_dir_all(root.join("data/fixture")).unwrap();
|
|
let mut registry = ExtensionRegistry::empty(&root);
|
|
registry.extensions.push(test_extension(Vec::new(), 1));
|
|
registry.extensions[0].enabled = false;
|
|
registry.save().unwrap();
|
|
assert!(!ExtensionRegistry::load(&root).unwrap().extensions[0].enabled);
|
|
assert!(
|
|
ExtensionRegistry::set_enabled(&root, "fixture", true)
|
|
.unwrap()
|
|
.extensions[0]
|
|
.enabled
|
|
);
|
|
assert!(
|
|
!ExtensionRegistry::set_enabled(&root, "fixture", false)
|
|
.unwrap()
|
|
.extensions[0]
|
|
.enabled
|
|
);
|
|
|
|
let mut duplicate: Value =
|
|
serde_json::from_slice(&fs::read(root.join("registry.json")).unwrap()).unwrap();
|
|
let copy = duplicate["extensions"][0].clone();
|
|
duplicate["extensions"].as_array_mut().unwrap().push(copy);
|
|
fs::write(
|
|
root.join("registry.json"),
|
|
serde_json::to_vec(&duplicate).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
ExtensionRegistry::load(&root)
|
|
.unwrap_err()
|
|
.contains("duplicate")
|
|
);
|
|
registry.save().unwrap();
|
|
|
|
let removed = ExtensionRegistry::uninstall(&root, "fixture").unwrap();
|
|
assert!(removed.extensions.is_empty());
|
|
assert!(!root.join("packages/fixture").exists());
|
|
assert!(!root.join("data/fixture").exists());
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_skill_names_and_update_rollback_are_deterministic() {
|
|
let root = fixture("conflicts").with_extension("registry");
|
|
let mut registry = ExtensionRegistry::empty(&root);
|
|
for id in ["first", "second"] {
|
|
let skills = root.join(format!("packages/{id}/current/skills/shared"));
|
|
fs::create_dir_all(&skills).unwrap();
|
|
fs::write(
|
|
skills.join("SKILL.md"),
|
|
"---\nname: shared\ndescription: Shared fixture skill\n---\nRules\n",
|
|
)
|
|
.unwrap();
|
|
let mut extension = test_extension(Vec::new(), 1);
|
|
extension.id = id.into();
|
|
extension.name = id.into();
|
|
extension.skills_path = Some("skills".into());
|
|
extension.skill_count = 1;
|
|
registry.extensions.push(extension);
|
|
}
|
|
assert!(
|
|
registry
|
|
.validate_skill_conflicts()
|
|
.unwrap_err()
|
|
.contains("both first and second")
|
|
);
|
|
|
|
let current = root.join("packages/update/current");
|
|
fs::create_dir_all(¤t).unwrap();
|
|
fs::write(current.join("version"), "old").unwrap();
|
|
let staging = root.join("staging");
|
|
fs::create_dir_all(&staging).unwrap();
|
|
fs::write(staging.join("version"), "new").unwrap();
|
|
let backup = promote_package(&root, "update", &staging, Some("previous"))
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(fs::read_to_string(current.join("version")).unwrap(), "new");
|
|
rollback_package(&root, "update", Some(&backup));
|
|
assert_eq!(fs::read_to_string(current.join("version")).unwrap(), "old");
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn symlink_and_variable_escapes_are_rejected() {
|
|
let root = fixture("escapes");
|
|
let outside = root.with_extension("outside");
|
|
fs::create_dir_all(&outside).unwrap();
|
|
symlink(&outside, root.join("skills/escape")).unwrap();
|
|
assert!(reject_symlink_escapes(&root, &root.join("skills")).is_err());
|
|
assert!(
|
|
validate_command_paths(
|
|
&root,
|
|
&["${CLAUDE_PLUGIN_ROOT}/hooks/hook$UNTRUSTED".into()]
|
|
)
|
|
.unwrap_err()
|
|
.contains("unsupported variable")
|
|
);
|
|
fs::remove_dir_all(root).unwrap();
|
|
fs::remove_dir_all(outside).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn hook_failures_are_bounded_actionable_and_non_shell() {
|
|
let root = fixture("failures");
|
|
let data = root.with_extension("data");
|
|
let event = HookEvent::SessionStart {
|
|
session_id: 9,
|
|
reason: "startup",
|
|
};
|
|
let run = |script: &str, timeout, cancel: &AtomicBool| {
|
|
let path = root.join("hooks/failure.sh");
|
|
executable(&path, script);
|
|
let extension = test_extension(
|
|
vec!["${CLAUDE_PLUGIN_ROOT}/hooks/failure.sh".into()],
|
|
timeout,
|
|
);
|
|
run_hook(
|
|
&extension,
|
|
&extension.hooks[0],
|
|
&event,
|
|
(&root, &data, &root),
|
|
"model",
|
|
cancel,
|
|
)
|
|
};
|
|
assert!(
|
|
run(
|
|
"#!/bin/sh\necho bad >&2\nexit 7\n",
|
|
1,
|
|
&AtomicBool::new(false)
|
|
)
|
|
.unwrap_err()
|
|
.contains("bad")
|
|
);
|
|
assert!(
|
|
run("#!/bin/sh\nprintf '\\377'\n", 1, &AtomicBool::new(false))
|
|
.unwrap_err()
|
|
.contains("UTF-8")
|
|
);
|
|
assert!(
|
|
parse_hook_output("fixture", "SessionStart", "{broken".into())
|
|
.unwrap_err()
|
|
.contains("malformed JSON")
|
|
);
|
|
let missing = test_extension(vec!["ds4server-definitely-missing".into()], 1);
|
|
assert!(
|
|
run_hook(
|
|
&missing,
|
|
&missing.hooks[0],
|
|
&event,
|
|
(&root, &data, &root),
|
|
"model",
|
|
&AtomicBool::new(false),
|
|
)
|
|
.unwrap_err()
|
|
.contains("Install it or disable the extension")
|
|
);
|
|
assert!(
|
|
run("#!/bin/sh\nsleep 3\n", 1, &AtomicBool::new(false))
|
|
.unwrap_err()
|
|
.contains("timeout")
|
|
);
|
|
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
let trigger = Arc::clone(&cancel);
|
|
thread::spawn(move || {
|
|
thread::sleep(Duration::from_millis(50));
|
|
trigger.store(true, Ordering::Relaxed);
|
|
});
|
|
assert!(
|
|
run("#!/bin/sh\nsleep 3\n", 2, &cancel)
|
|
.unwrap_err()
|
|
.contains("cancelled")
|
|
);
|
|
|
|
let process_group = root.join("hooks/process-group.sh");
|
|
executable(
|
|
&process_group,
|
|
"#!/bin/sh\nsleep 30 &\necho $! > \"$PLUGIN_DATA/child.pid\"\nwait\n",
|
|
);
|
|
let extension = test_extension(
|
|
vec!["${CLAUDE_PLUGIN_ROOT}/hooks/process-group.sh".into()],
|
|
1,
|
|
);
|
|
assert!(
|
|
run_hook(
|
|
&extension,
|
|
&extension.hooks[0],
|
|
&event,
|
|
(&root, &data, &root),
|
|
"model",
|
|
&AtomicBool::new(false),
|
|
)
|
|
.unwrap_err()
|
|
.contains("timeout")
|
|
);
|
|
let child_pid = fs::read_to_string(data.join("sessions/9/child.pid"))
|
|
.unwrap()
|
|
.trim()
|
|
.parse::<i32>()
|
|
.unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(1);
|
|
while Instant::now() < deadline {
|
|
// SAFETY: signal 0 performs only an existence check for this PID.
|
|
if unsafe { libc::kill(child_pid, 0) } != 0 {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
// SAFETY: signal 0 performs only an existence check for this PID.
|
|
assert_ne!(unsafe { libc::kill(child_pid, 0) }, 0);
|
|
fs::remove_dir_all(root).unwrap();
|
|
fs::remove_dir_all(data).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_matchers_preserve_declared_event_order() {
|
|
let root = fixture("ordering").with_extension("runtime");
|
|
let package = root.join("packages/fixture/current");
|
|
fs::create_dir_all(package.join("hooks")).unwrap();
|
|
executable(
|
|
&package.join("hooks/run.sh"),
|
|
"#!/bin/sh\ninput=$(cat)\nevent=$(printf '%s' \"$input\" | sed -n 's/.*\"hook_event_name\":\"\\([^\"]*\\)\".*/\\1/p')\nprintf '{\"hookSpecificOutput\":{\"hookEventName\":\"%s\",\"additionalContext\":\"%s\"}}' \"$event\" \"$event\"\n",
|
|
);
|
|
let mut extension = test_extension(vec!["${CLAUDE_PLUGIN_ROOT}/hooks/run.sh".into()], 1);
|
|
extension.hooks.push(HookBinding {
|
|
event: "UserPromptSubmit".into(),
|
|
matcher: None,
|
|
..extension.hooks[0].clone()
|
|
});
|
|
let second_package = root.join("packages/second/current/hooks");
|
|
fs::create_dir_all(&second_package).unwrap();
|
|
fs::copy(package.join("hooks/run.sh"), second_package.join("run.sh")).unwrap();
|
|
let mut second = extension.clone();
|
|
second.id = "second".into();
|
|
second.name = "second".into();
|
|
let mut registry = ExtensionRegistry::empty(&root);
|
|
registry.extensions.push(extension);
|
|
registry.extensions.push(second);
|
|
let result = registry.dispatch(
|
|
&[
|
|
HookEvent::SessionStart {
|
|
session_id: 1,
|
|
reason: "startup",
|
|
},
|
|
HookEvent::UserPromptSubmit {
|
|
session_id: 1,
|
|
prompt: "hello".into(),
|
|
},
|
|
],
|
|
&package,
|
|
"model",
|
|
&AtomicBool::new(false),
|
|
);
|
|
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
|
assert_eq!(
|
|
result
|
|
.outputs
|
|
.iter()
|
|
.map(|output| (output.extension_id.as_str(), output.event.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
("fixture", "SessionStart"),
|
|
("fixture", "UserPromptSubmit"),
|
|
("second", "SessionStart"),
|
|
("second", "UserPromptSubmit"),
|
|
]
|
|
);
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "downloads the pinned Ponytail reference integration"]
|
|
fn pinned_ponytail_runs_all_lifecycle_modes() {
|
|
let root = fixture("ponytail-live").with_extension("installed");
|
|
let project = root.join("project");
|
|
fs::create_dir_all(&project).unwrap();
|
|
let mut registry = ExtensionRegistry::install(
|
|
&root,
|
|
"https://github.com/DietrichGebert/ponytail.git",
|
|
Some("2ed6c52c9d7e5e56942508591085fd45dea277d3"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(registry.extensions[0].version, "4.9.0");
|
|
assert_eq!(registry.extensions[0].skill_count, 6);
|
|
registry = ExtensionRegistry::trust_and_enable(&root, "ponytail").unwrap();
|
|
let cancel = AtomicBool::new(false);
|
|
let startup = registry.dispatch(
|
|
&[HookEvent::SessionStart {
|
|
session_id: 42,
|
|
reason: "startup",
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(startup.errors.is_empty(), "{:?}", startup.errors);
|
|
assert!(startup.outputs.iter().any(|output| {
|
|
output
|
|
.additional_context
|
|
.as_deref()
|
|
.is_some_and(|context| context.contains("Ponytail"))
|
|
}));
|
|
for mode in ["lite", "full", "ultra"] {
|
|
let switched = registry.dispatch(
|
|
&[HookEvent::UserPromptSubmit {
|
|
session_id: 42,
|
|
prompt: format!("/ponytail {mode}"),
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(switched.errors.is_empty(), "{:?}", switched.errors);
|
|
assert!(switched.outputs.iter().any(|output| {
|
|
output.event == "SessionStart"
|
|
&& output
|
|
.additional_context
|
|
.as_deref()
|
|
.is_some_and(|context| context.contains(mode))
|
|
}));
|
|
}
|
|
let status = registry.dispatch(
|
|
&[HookEvent::UserPromptSubmit {
|
|
session_id: 42,
|
|
prompt: "/ponytail status".into(),
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(status.errors.is_empty(), "{:?}", status.errors);
|
|
assert!(
|
|
status
|
|
.status()
|
|
.is_some_and(|status| status.contains("ULTRA")),
|
|
"{:?}",
|
|
status.outputs
|
|
);
|
|
let off = registry.dispatch(
|
|
&[HookEvent::UserPromptSubmit {
|
|
session_id: 42,
|
|
prompt: "/ponytail off".into(),
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(off.errors.is_empty(), "{:?}", off.errors);
|
|
assert!(
|
|
off.outputs.iter().any(|output| {
|
|
output.event == "SessionStart" && output.additional_context.as_deref() == Some("")
|
|
}),
|
|
"{:?}",
|
|
off.outputs
|
|
);
|
|
let full = registry.dispatch(
|
|
&[HookEvent::UserPromptSubmit {
|
|
session_id: 42,
|
|
prompt: "/ponytail full".into(),
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(full.errors.is_empty(), "{:?}", full.errors);
|
|
let default = registry.dispatch(
|
|
&[HookEvent::UserPromptSubmit {
|
|
session_id: 42,
|
|
prompt: "/ponytail default lite".into(),
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(default.errors.is_empty(), "{:?}", default.errors);
|
|
assert_eq!(
|
|
fs::read_to_string(root.join("data/ponytail/sessions/42/.ponytail-active"))
|
|
.unwrap()
|
|
.trim(),
|
|
"full"
|
|
);
|
|
let new_session = registry.dispatch(
|
|
&[HookEvent::SessionStart {
|
|
session_id: 43,
|
|
reason: "startup",
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel,
|
|
);
|
|
assert!(new_session.errors.is_empty(), "{:?}", new_session.errors);
|
|
assert!(new_session.outputs.iter().any(|output| {
|
|
output
|
|
.additional_context
|
|
.as_deref()
|
|
.is_some_and(|context| context.contains("lite"))
|
|
}));
|
|
for event in [
|
|
HookEvent::SessionStart {
|
|
session_id: 42,
|
|
reason: "compact",
|
|
},
|
|
HookEvent::SessionStart {
|
|
session_id: 42,
|
|
reason: "resume",
|
|
},
|
|
HookEvent::SubagentStart {
|
|
session_id: 42,
|
|
round: 1,
|
|
},
|
|
] {
|
|
let result = registry.dispatch(&[event], &project, "model", &cancel);
|
|
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
|
assert!(result.outputs.iter().any(|output| {
|
|
output
|
|
.additional_context
|
|
.as_deref()
|
|
.is_some_and(|context| context.contains("full"))
|
|
}));
|
|
}
|
|
registry = ExtensionRegistry::set_enabled(&root, "ponytail", false).unwrap();
|
|
assert!(
|
|
registry
|
|
.dispatch(
|
|
&[HookEvent::SessionStart {
|
|
session_id: 42,
|
|
reason: "resume"
|
|
}],
|
|
&project,
|
|
"model",
|
|
&cancel
|
|
)
|
|
.outputs
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
ExtensionRegistry::uninstall(&root, "ponytail")
|
|
.unwrap()
|
|
.extensions
|
|
.is_empty()
|
|
);
|
|
fs::remove_dir_all(root).unwrap();
|
|
}
|
|
}
|