Files
DS4Server/src/extensions.rs
Georg Bauer 32c3a1a47a
Some checks failed
Weekly OSV dependency audit / dependency-audit (push) Failing after 5s
Use login shell PATH for extension hooks
2026-08-30 22:40:57 +02:00

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 &registry.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(&current, backup).map_err(|error| error.to_string())?;
}
if let Err(error) = fs::rename(staging, &current) {
if let Some(backup) = &backup {
let _ = fs::rename(backup, &current);
}
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(&current);
}
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(&current).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();
}
}