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, pub(crate) argv: Vec, pub(crate) timeout_seconds: u64, pub(crate) status_message: Option, } #[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, pub(crate) resolved_commit: String, pub(crate) skills_path: Option, pub(crate) skill_count: usize, pub(crate) hooks_path: Option, pub(crate) hooks: Vec, pub(crate) last_error: Option, } impl InstalledExtension { pub(crate) fn hook_names(&self) -> String { let mut names = self .hooks .iter() .map(|hook| hook.event.as_str()) .collect::>() .into_iter() .collect::>(); 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, #[serde(skip)] root: PathBuf, } impl ExtensionRegistry { pub(crate) fn load(root: &Path) -> Result { let path = root.join("registry.json"); let mut registry = match fs::read(&path) { Ok(bytes) => serde_json::from_slice::(&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, 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 { 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 { 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 { 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 { 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 { 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 { 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::>(); (!statuses.is_empty()).then(|| statuses.into_iter().collect::>().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::::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, pub(crate) system_message: Option, } #[derive(Clone, Debug, Default)] pub(crate) struct HookBatchResult { pub(crate) outputs: Vec, pub(crate) errors: BTreeMap, invoked: BTreeSet, } impl HookBatchResult { pub(crate) fn status(&self) -> Option { let statuses = self .outputs .iter() .filter_map(|output| output.system_message.as_deref()) .collect::>(); (!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, #[serde(default)] skills: Option, #[serde(default)] hooks: Option, #[serde(default, alias = "schemaVersion")] schema_version: Option, #[serde(flatten)] extra: BTreeMap, } #[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>, } #[derive(Deserialize)] struct RawHookGroup { #[serde(default)] matcher: Option, hooks: Vec, } #[derive(Deserialize)] struct RawHook { #[serde(rename = "type")] kind: String, command: String, #[serde(default = "default_timeout")] timeout: u64, #[serde(default, rename = "statusMessage")] status_message: Option, } fn default_timeout() -> u64 { 5 } fn validate_package( root: &Path, source_url: &str, requested_ref: Option, commit: git2::Oid, ) -> Result { 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, 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 { 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 { 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::(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 { 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, 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 { 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::>() .join("/"); if normalized.is_empty() { return Err(format!("Extension {label} path is empty")); } Ok(normalized) } fn resolve_inside(root: &Path, path: &Path) -> Result { 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 { 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, 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, 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::() .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::>(), [ ("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(); } }