| 1 | //! Extension slash commands: owned registrations in the user command registry. |
| 2 | //! |
| 3 | //! A command mirrors a tool. The host proposes it with `registry/register` |
| 4 | //! (`kind = "command"`); [`super::registry::OwnerRegistry`] admits or refuses |
| 5 | //! it under the same owner/generation/handle rules; the admitted commands of |
| 6 | //! the owners an engine's workspace desires are loaded into the existing user |
| 7 | //! command registry (`commands::user_registry`, at the lowest |
| 8 | //! precedence, so a built-in or a markdown command always wins the spelling); |
| 9 | //! and a user invocation becomes `command/run` to the host. Revocation, |
| 10 | //! deactivation, crash and generation change remove the registrations the |
| 11 | //! same way they remove tools, and each change bumps [`epoch`] so the user |
| 12 | //! registry reloads. |
| 13 | //! |
| 14 | //! What a command can return, chosen from what the user-command machinery |
| 15 | //! already does: text shown to the user (a `System` transcript cell, like a |
| 16 | //! built-in's message) and/or a prompt submitted as the user's next message |
| 17 | //! (`AppAction::SendMessage`, like a markdown command's expanded template). |
| 18 | //! The host never calls the model or a tool itself: anything the model does |
| 19 | //! after a `submit` goes through the normal turn, tool approval included. |
| 20 | //! Invoking a command is the user's own action, so it needs no approval of |
| 21 | //! its own; it still re-checks that the owner is live, that its reviewed |
| 22 | //! receipt is current, and that the host is running before anything is sent. |
| 23 | //! |
| 24 | //! **The built-in command table is a port.** This module is runtime-side and |
| 25 | //! may not reach into `commands`, so registration asks |
| 26 | //! [`BuiltinCommandCatalog`] whether a built-in command answers to a name. The |
| 27 | //! commands side implements it (`commands::BuiltinCommandNames`) and the |
| 28 | //! composition root installs it once at startup ([`install_builtin_commands`]). |
| 29 | //! With none installed a command registration is refused, never accepted |
| 30 | //! unchecked. |
| 31 | //! |
| 32 | //! Known limitations: |
| 33 | //! * The UI event loop awaits the command (like `/balance`), bounded by |
| 34 | //! `SupervisionOptions::command_run_deadline` (30 s) and then cancelled with |
| 35 | //! `$/cancel`. A keypress does not cancel it earlier. |
| 36 | //! * Commands are TUI-only: the Runtime API's `GET /v1/commands` omits them |
| 37 | //! and cannot run them. |
| 38 | //! * A clash with a user, workspace or manifest (markdown) command is not a |
| 39 | //! registration-time refusal, because only the user registry knows the |
| 40 | //! workspace: the markdown command wins and the extension command is left |
| 41 | //! out with a load error. |
| 42 | //! * The handler gets no mutable agent or session handle, and no attachments. It is |
| 43 | //! told the workspace the command was loaded for (`ExtensionCommandRef`) and |
| 44 | //! its plugin's data directory, plus the invoking session id when known, |
| 45 | //! all read-only strings. User commands have no Engine turn or agent identity. |
| 46 | //! * A command's result text is bounded and stripped of terminal escapes; a |
| 47 | //! prompt over [`MAX_PROMPT_BYTES`] is refused, never truncated. |
| 48 | |
| 49 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 50 | use std::sync::{Arc, OnceLock}; |
| 51 | |
| 52 | use serde_json::Value; |
| 53 | |
| 54 | use super::ManagerShared; |
| 55 | use super::protocol::{CommandResultWire, CommandRunParams, CoreRequest}; |
| 56 | use super::registry::CommandRegistration; |
| 57 | use super::supervisor::HostCallError; |
| 58 | use crate::plugins::types::PluginAuthority; |
| 59 | |
| 60 | /// How long the user waits for one command before it is cancelled. |
| 61 | pub const COMMAND_RUN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30); |
| 62 | /// Longest result text shown; the rest is cut with a marker. |
| 63 | pub const MAX_TEXT_BYTES: usize = 64 * 1024; |
| 64 | /// Longest prompt a command may submit. Over this it is refused outright. |
| 65 | pub const MAX_PROMPT_BYTES: usize = 128 * 1024; |
| 66 | |
| 67 | /// What registration needs to know about the built-in slash commands, and |
| 68 | /// nothing more: read-only, implemented by the commands side. |
| 69 | pub trait BuiltinCommandCatalog: Send + Sync { |
| 70 | /// Whether a built-in command answers to `name` (lower case): a canonical |
| 71 | /// name, an alias, or one of the mode aliases the dispatcher answers ahead |
| 72 | /// of the registry. |
| 73 | fn answers_to(&self, name: &str) -> bool; |
| 74 | } |
| 75 | |
| 76 | static BUILTIN_COMMANDS: OnceLock<Arc<dyn BuiltinCommandCatalog>> = OnceLock::new(); |
| 77 | |
| 78 | /// Install the process's catalog, once at startup. The first install wins and |
| 79 | /// returns `true`; a later one is ignored. |
| 80 | pub fn install_builtin_commands(catalog: Arc<dyn BuiltinCommandCatalog>) -> bool { |
| 81 | BUILTIN_COMMANDS.set(catalog).is_ok() |
| 82 | } |
| 83 | |
| 84 | /// The installed catalog. `None` means none was installed (or, in a test, the |
| 85 | /// thread asked for none): command registration is refused. |
| 86 | pub(crate) fn builtin_commands() -> Option<Arc<dyn BuiltinCommandCatalog>> { |
| 87 | #[cfg(test)] |
| 88 | if let Some(chosen) = TEST_CATALOG.with(|cell| cell.borrow().clone()) { |
| 89 | return chosen; |
| 90 | } |
| 91 | BUILTIN_COMMANDS.get().cloned() |
| 92 | } |
| 93 | |
| 94 | #[cfg(test)] |
| 95 | thread_local! { |
| 96 | /// `Some(None)` is a thread that asked for no catalog at all. |
| 97 | static TEST_CATALOG: std::cell::RefCell<Option<Option<Arc<dyn BuiltinCommandCatalog>>>> = |
| 98 | const { std::cell::RefCell::new(None) }; |
| 99 | } |
| 100 | |
| 101 | /// Test-only: route [`builtin_commands`] on this thread to a catalog (or to |
| 102 | /// none) until dropped, restoring what was there before, so tests never touch |
| 103 | /// the process-wide install. |
| 104 | #[cfg(test)] |
| 105 | pub(crate) struct BuiltinCommandsGuard(Option<Option<Arc<dyn BuiltinCommandCatalog>>>); |
| 106 | |
| 107 | #[cfg(test)] |
| 108 | impl BuiltinCommandsGuard { |
| 109 | pub(crate) fn install(catalog: Arc<dyn BuiltinCommandCatalog>) -> Self { |
| 110 | Self(TEST_CATALOG.with(|cell| cell.replace(Some(Some(catalog))))) |
| 111 | } |
| 112 | |
| 113 | /// This thread has no catalog, whatever the process installed. |
| 114 | pub(crate) fn absent() -> Self { |
| 115 | Self(TEST_CATALOG.with(|cell| cell.replace(Some(None)))) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | #[cfg(test)] |
| 120 | impl Drop for BuiltinCommandsGuard { |
| 121 | fn drop(&mut self) { |
| 122 | TEST_CATALOG.with(|cell| *cell.borrow_mut() = self.0.take()); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | static EPOCH: AtomicU64 = AtomicU64::new(0); |
| 127 | |
| 128 | /// Something that may have changed which commands are live: a registration, |
| 129 | /// a revocation, an activation, a host exit, an attachment's desired set. |
| 130 | /// Spurious bumps are harmless (one extra registry reload). |
| 131 | pub(crate) fn bump_epoch() { |
| 132 | EPOCH.fetch_add(1, Ordering::SeqCst); |
| 133 | } |
| 134 | |
| 135 | /// The user registry reloads when this moves. |
| 136 | #[must_use] |
| 137 | pub(crate) fn epoch() -> u64 { |
| 138 | EPOCH.load(Ordering::SeqCst) |
| 139 | } |
| 140 | |
| 141 | /// A reference to one admitted command, carried by the user-registry entry |
| 142 | /// and the dispatch action. It names the exact registration: a handle is |
| 143 | /// never reused, so a stale reference can only fail, never run a newer one. |
| 144 | /// It holds no owner token. |
| 145 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 146 | pub struct ExtensionCommandRef { |
| 147 | pub handle: u64, |
| 148 | pub plugin_id: String, |
| 149 | pub generation: u64, |
| 150 | /// `extension:<plugin>`, the origin shown beside the command's output. |
| 151 | pub origin: String, |
| 152 | /// The workspace whose user registry loaded the command: what the handler |
| 153 | /// is told as the place the user ran it. |
| 154 | pub workspace: std::path::PathBuf, |
| 155 | pub selection: Option<super::composition_scope::SelectionRevision>, |
| 156 | pub scope: Option<super::protocol::EntryRef>, |
| 157 | pub content_hash: String, |
| 158 | } |
| 159 | |
| 160 | /// One live command as the user registry loads it. |
| 161 | #[derive(Debug, Clone)] |
| 162 | pub struct ExtensionCommandEntry { |
| 163 | pub registration: CommandRegistration, |
| 164 | /// The owner's reviewed authority, so the user registry hides the command |
| 165 | /// the moment the plugin is disabled or loses trust. |
| 166 | pub authority: PluginAuthority, |
| 167 | /// The workspace this entry was loaded for. |
| 168 | pub workspace: std::path::PathBuf, |
| 169 | pub selection: Option<super::composition_scope::SelectionRevision>, |
| 170 | } |
| 171 | |
| 172 | impl ExtensionCommandEntry { |
| 173 | #[must_use] |
| 174 | pub fn reference(&self) -> ExtensionCommandRef { |
| 175 | ExtensionCommandRef { |
| 176 | handle: self.registration.handle, |
| 177 | plugin_id: self.registration.owner.plugin_id.clone(), |
| 178 | generation: self.registration.owner.generation, |
| 179 | origin: format!("extension:{}", self.registration.plugin_name), |
| 180 | workspace: self.workspace.clone(), |
| 181 | selection: self.selection, |
| 182 | scope: self.registration.scope.clone(), |
| 183 | content_hash: self.registration.content_hash.clone(), |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | /// What a command asked the core to do with its answer. |
| 189 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 190 | pub enum CommandOutcome { |
| 191 | /// Show `text` (possibly empty) to the user. |
| 192 | Show { text: String }, |
| 193 | /// Submit `prompt` as the user's next message; show `note` beside it. |
| 194 | Submit { |
| 195 | prompt: String, |
| 196 | note: Option<String>, |
| 197 | }, |
| 198 | } |
| 199 | |
| 200 | fn strip_escapes(text: &str) -> String { |
| 201 | let mut clean = String::with_capacity(text.len()); |
| 202 | codewhale_secrets::sanitize::strip_ansi_into(text, &mut clean); |
| 203 | clean |
| 204 | } |
| 205 | |
| 206 | /// Strip terminal escapes and bound the text. A command's output is |
| 207 | /// plugin-controlled and goes straight into the transcript. |
| 208 | fn display_text(text: &str) -> String { |
| 209 | let mut clean = strip_escapes(text); |
| 210 | if clean.len() > MAX_TEXT_BYTES { |
| 211 | let mut end = MAX_TEXT_BYTES; |
| 212 | while !clean.is_char_boundary(end) { |
| 213 | end -= 1; |
| 214 | } |
| 215 | clean.truncate(end); |
| 216 | clean.push_str("\n… (output truncated)"); |
| 217 | } |
| 218 | clean |
| 219 | } |
| 220 | |
| 221 | /// The phrase shown after `/<name> (extension:<plugin>):` on failure. |
| 222 | fn map_call_error(error: HostCallError) -> String { |
| 223 | match error { |
| 224 | HostCallError::Cancelled(reason) => format!("cancelled: {reason}"), |
| 225 | HostCallError::Timeout { after, .. } => { |
| 226 | format!("timed out after {}s and was cancelled", after.as_secs()) |
| 227 | } |
| 228 | HostCallError::Exited(reason) => format!("extension host is down: exited: {reason}"), |
| 229 | HostCallError::Busy => "extension host is busy; try again".to_string(), |
| 230 | HostCallError::Rpc { code, message } => { |
| 231 | if code == super::protocol::error_code::NOT_AVAILABLE { |
| 232 | format!("not available: {message}") |
| 233 | } else { |
| 234 | format!("failed: {message}") |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | /// Run one command for the user. Errors are the text to show as a failure. |
| 241 | pub(crate) async fn run( |
| 242 | shared: &ManagerShared, |
| 243 | command: &ExtensionCommandRef, |
| 244 | raw_input: &str, |
| 245 | session_id: Option<&str>, |
| 246 | ) -> Result<CommandOutcome, String> { |
| 247 | let bound = command |
| 248 | .selection |
| 249 | .and_then(|revision| shared.plugins_for_selection(revision, session_id)); |
| 250 | let caller = bound.as_ref().map(|(plugins, _)| Arc::clone(plugins)); |
| 251 | let agent_id = bound.and_then(|(_, agent_id)| agent_id); |
| 252 | shared.check_selection( |
| 253 | command.selection, |
| 254 | caller.as_deref(), |
| 255 | &command.plugin_id, |
| 256 | &command.content_hash, |
| 257 | command.scope.as_ref(), |
| 258 | )?; |
| 259 | let (host, registration) = shared.live_host_for_command(command).await?; |
| 260 | shared.check_selection( |
| 261 | command.selection, |
| 262 | caller.as_deref(), |
| 263 | &command.plugin_id, |
| 264 | &command.content_hash, |
| 265 | command.scope.as_ref(), |
| 266 | )?; |
| 267 | let deadline = shared.options.supervision.command_run_deadline; |
| 268 | let request = CoreRequest::CommandRun(CommandRunParams { |
| 269 | handle: registration.handle, |
| 270 | command_id: uuid::Uuid::new_v4().simple().to_string(), |
| 271 | raw_input: raw_input.to_string(), |
| 272 | deadline_ms: u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX), |
| 273 | workspace: command.workspace.to_str().map(str::to_owned), |
| 274 | session_id: session_id.filter(|id| !id.is_empty()).map(str::to_owned), |
| 275 | agent_id, |
| 276 | origin_turn_id: None, |
| 277 | }); |
| 278 | let value: Value = host |
| 279 | .call(request, Some(registration.owner.plugin_id.clone())) |
| 280 | .await |
| 281 | .map_err(map_call_error)?; |
| 282 | shared.check_selection( |
| 283 | command.selection, |
| 284 | caller.as_deref(), |
| 285 | &command.plugin_id, |
| 286 | &command.content_hash, |
| 287 | command.scope.as_ref(), |
| 288 | )?; |
| 289 | shared.live_host_for_command(command).await?; |
| 290 | shared.check_selection( |
| 291 | command.selection, |
| 292 | caller.as_deref(), |
| 293 | &command.plugin_id, |
| 294 | &command.content_hash, |
| 295 | command.scope.as_ref(), |
| 296 | )?; |
| 297 | let wire: CommandResultWire = serde_json::from_value(value) |
| 298 | .map_err(|error| format!("returned a malformed result: {error}"))?; |
| 299 | match wire { |
| 300 | CommandResultWire::Success { text } => Ok(CommandOutcome::Show { |
| 301 | text: text.as_deref().map(display_text).unwrap_or_default(), |
| 302 | }), |
| 303 | CommandResultWire::Error { text } => Err(display_text(&text)), |
| 304 | CommandResultWire::Submit { prompt, text } => { |
| 305 | let prompt = strip_escapes(&prompt); |
| 306 | if prompt.trim().is_empty() { |
| 307 | return Err("submitted an empty prompt".to_string()); |
| 308 | } |
| 309 | if prompt.len() > MAX_PROMPT_BYTES { |
| 310 | return Err(format!( |
| 311 | "returned a prompt of {} bytes, over the {MAX_PROMPT_BYTES}-byte limit; it was not submitted", |
| 312 | prompt.len() |
| 313 | )); |
| 314 | } |
| 315 | Ok(CommandOutcome::Submit { |
| 316 | prompt, |
| 317 | note: text.as_deref().map(display_text).filter(|t| !t.is_empty()), |
| 318 | }) |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 |