diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cdc1b80..abd2dcd 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -324,7 +324,31 @@ These messages are handled by the gateway before backend dispatch: | `/clear`, `/new`, `/reset` | Start a fresh backend session for that conversation | | `/stop` | Stop the active request; already queued messages continue in order | | `/help` | Return the available chat commands | +| any command in `[command_hooks]` | Run the configured shell command; stdout is the reply, no backend turn | Starting a fresh session preserves canonical history. Push can seed the new backend session with bounded recent turns from the exact channel-qualified conversation. + +### Command hooks + +`[command_hooks]` maps chat slash commands to deterministic shell commands. +The reply is the command's trimmed stdout, relayed verbatim — no agent turn, +no tokens. Message arguments are appended to the command line as trailing +positional arguments (`$1`, `$2`, ... after the command's own arguments): + +```toml +[command_hooks] +status = "/usr/local/bin/status-report" +version = "sh ~/assistant/scripts/version.sh --short" +``` + +With this config, `/status` runs `/usr/local/bin/status-report` and +`/status agents` runs `/usr/local/bin/status-report agents`. Hooks run with a +timeout; a failing or timed-out hook replies with a short error and never +falls back to the backend. Unknown slash commands still reach the backend as +regular messages. Hook output is delivered like any gateway reply and is +recorded in canonical history. + +Never put secrets in hook commands: the config is read at startup and hook +commands run with the gateway's permissions. diff --git a/src/config.rs b/src/config.rs index 0d075da..c2a2e7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ //! Gateway configuration loaded from a TOML file. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use std::time::Duration; @@ -76,6 +76,10 @@ pub struct Config { pub agent: String, #[serde(default)] pub routes: Vec, + /// Gateway-level slash commands: `/name [args...]` runs the mapped shell + /// command and relays its stdout verbatim, without an agent turn. + #[serde(default)] + pub command_hooks: HashMap, /// Canonical root of the single user-owned assistant repository. #[serde(default)] pub assistant_root: String, @@ -921,6 +925,7 @@ mod tests { voice_name: DEFAULT_VOICE_NAME.to_string(), agent: "codex".to_string(), routes: Vec::new(), + command_hooks: HashMap::new(), assistant_root: root.to_string_lossy().to_string(), jobs_dir: root.join("jobs").to_string_lossy().to_string(), jobs_agent: None, diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs index c7a0bd0..5602deb 100644 --- a/src/gateway/tests.rs +++ b/src/gateway/tests.rs @@ -1600,6 +1600,108 @@ async fn telegram_filters_before_agent_and_replies_to_originating_chat() { let _ = std::fs::remove_dir_all(assistant_dir); } +#[tokio::test(flavor = "current_thread")] +async fn command_hook_runs_and_relays_stdout_without_agent() { + let state_path = temp_state_path(); + let sessions_dir = temp_path("command-hook-sessions"); + let assistant_dir = temp_path("command-hook-assistant"); + std::fs::create_dir_all(&assistant_dir).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let hook_path = temp_path("command-hook-script"); + std::fs::write( + &hook_path, + "#!/bin/sh\necho \"status: $1 rows=$2 args=$3,$4\"\n", + ) + .unwrap(); + let mut cfg = test_config( + &state_path, + sessions_dir.to_str().unwrap(), + assistant_dir.to_str().unwrap(), + ); + cfg.channel = "telegram".to_string(); + cfg.self_handles.clear(); + cfg.allow_from.clear(); + cfg.telegram_bot_token = Some("secret".to_string()); + cfg.telegram_allow_user_ids = vec![7]; + cfg.command_hooks.insert( + "status".to_string(), + format!("sh {} status 42", hook_path.to_str().unwrap()), + ); + let mut gateway = Gateway::new(cfg).unwrap(); + gateway.ctx.runners = Arc::new(fake_runners(calls.clone())); + + run_messages( + &mut gateway, + vec![ + telegram_message(1, 7, 7, false, "/status"), + telegram_message(2, 7, 7, false, "/status foo bar"), + ], + ) + .await; + + assert_eq!(calls.lock().unwrap().len(), 0); + assert_eq!( + gateway.ctx.sent_replies.lock().unwrap().as_slice(), + [ + ("7".to_string(), "status: status rows=42 args=,".to_string()), + ( + "7".to_string(), + "status: status rows=42 args=foo,bar".to_string() + ) + ] + ); + + let _ = std::fs::remove_file(&state_path); + let _ = std::fs::remove_file(format!("{state_path}.audit.jsonl")); + let _ = std::fs::remove_file(&hook_path); + let _ = std::fs::remove_dir_all(sessions_dir); + let _ = std::fs::remove_dir_all(assistant_dir); +} + +#[tokio::test(flavor = "current_thread")] +async fn unknown_slash_command_falls_through_to_agent() { + let state_path = temp_state_path(); + let sessions_dir = temp_path("slash-fallthrough-sessions"); + let assistant_dir = temp_path("slash-fallthrough-assistant"); + std::fs::create_dir_all(&assistant_dir).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let mut cfg = test_config( + &state_path, + sessions_dir.to_str().unwrap(), + assistant_dir.to_str().unwrap(), + ); + cfg.channel = "telegram".to_string(); + cfg.self_handles.clear(); + cfg.allow_from.clear(); + cfg.telegram_bot_token = Some("secret".to_string()); + cfg.telegram_allow_user_ids = vec![7]; + cfg.command_hooks + .insert("status".to_string(), "true".to_string()); + let mut gateway = Gateway::new(cfg).unwrap(); + gateway.ctx.runners = Arc::new(fake_runners(calls.clone())); + + run_messages( + &mut gateway, + vec![ + telegram_message(1, 7, 7, false, "/status"), + telegram_message(2, 7, 7, false, "/bogus details"), + ], + ) + .await; + + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!( + crate::prompt::current_message(&calls[0].prompt).as_deref(), + Some("/bogus details") + ); + + let _ = std::fs::remove_file(&state_path); + let _ = std::fs::remove_file(format!("{state_path}.audit.jsonl")); + let _ = std::fs::remove_dir_all(sessions_dir); + let _ = std::fs::remove_dir_all(assistant_dir); +} + #[tokio::test(flavor = "current_thread")] async fn telegram_topic_gets_own_thread_and_reply_targets_the_topic() { let state_path = temp_state_path(); @@ -3974,6 +4076,7 @@ fn test_config(state_path: &str, _sessions_dir: &str, assistant_dir: &str) -> Co voice_name: crate::config::DEFAULT_VOICE_NAME.to_string(), agent: "codex".to_string(), routes: Vec::new(), + command_hooks: HashMap::new(), assistant_root: assistant_dir.to_string(), jobs_dir: format!("{state_path}.jobs"), jobs_agent: None, diff --git a/src/gateway/worker.rs b/src/gateway/worker.rs index ecba8df..90ae818 100644 --- a/src/gateway/worker.rs +++ b/src/gateway/worker.rs @@ -10,6 +10,8 @@ use anyhow::{Context, Result}; use tokio::sync::{mpsc, watch}; use tracing::{error, info, warn}; +use std::process::Stdio; + use crate::agent::{Request, RunError}; use crate::history::{DeliveryStatus, OutboundMessage, OutboundOrigin}; use crate::image::{PreparedImages, MAX_IMAGE_BYTES, MAX_IMAGE_COUNT}; @@ -103,7 +105,7 @@ where } if job.image_attachments.is_empty() { - if let Some(reply) = command(ctx, &job) { + if let Some(reply) = command(ctx, &job).await { let delivery = record_and_deliver(ctx, &job, OutboundOrigin::Gateway, &reply).await; if delivery.is_ok() { info!( @@ -814,10 +816,27 @@ fn complete_job(ctx: &Ctx, job: &Job, reason: &str) { complete_row(&ctx.store, &ctx.ack, ctx.channel.id(), job.row_id); } +/// Splits `/word args...` input into `(word, args)` for command routing. +/// Returns `None` for anything that is not a slash command with a word. +fn slash_command(text: &str) -> Option<(&str, &str)> { + let rest = text.trim().strip_prefix('/')?; + if rest.is_empty() { + return None; + } + Some(match rest.split_once(' ') { + Some((word, args)) => (word, args.trim()), + None => (rest, ""), + }) +} + /// Handles gateway-level slash commands before anything reaches the agent. -fn command(ctx: &Ctx, job: &Job) -> Option { - match job.text.trim().to_lowercase().as_str() { - "/clear" | "/new" | "/reset" => match ctx.store.lock().unwrap().rotate( +/// Built-ins are hardcoded; `[command_hooks]` maps `/name` to a shell command +/// whose stdout is relayed verbatim (deterministic, no agent turn). Unknown +/// slash commands fall through to the agent as before. +async fn command(ctx: &Ctx, job: &Job) -> Option { + let (word, args) = slash_command(&job.text)?; + match word.to_lowercase().as_str() { + "clear" | "new" | "reset" => match ctx.store.lock().unwrap().rotate( &job.thread, job.backend.as_str(), ctx.runners @@ -828,11 +847,66 @@ fn command(ctx: &Ctx, job: &Job) -> Option { Ok(()) => Some("Started a fresh conversation.".to_string()), Err(_) => Some("Couldn't reset the conversation.".to_string()), }, - "/help" => Some( + "help" => Some( "Commands:\n/clear - start a fresh conversation\n/stop - stop the active request\n/help - this message" .to_string(), ), - _ => None, + word => { + let hook = ctx.cfg.command_hooks.get(word)?; + Some(run_command_hook(hook, args).await) + } + } +} + +/// Runs a command hook through `/bin/sh`, with the message arguments passed as +/// positional parameters (`$1`, `$2`, ...). The trimmed stdout is the reply; +/// failures reply with a short deterministic error instead of falling back to +/// the agent, so a broken hook never turns into a surprise LLM turn. +/// ponytail: plain spawn + timeout, no process-group teardown like the +/// timeout hook needs; unify with that hardened runner if hooks ever leak +/// backgrounded descendants. +async fn run_command_hook(hook: &str, args: &str) -> String { + // `sh -c "{hook} \"$@\"" sh `: the hook's own fixed args + // come first, message args are appended to the same command line. The + // bare `sh` is $0 for the -c string. + let child = tokio::process::Command::new("/bin/sh") + .arg("-c") + .arg(format!("{hook} \"$@\"")) + .arg("sh") + .args(args.split_whitespace()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(Duration::from_secs(COMMAND_HOOK_TIMEOUT_SECS), child).await { + Err(_) => "Command hook timed out.".to_string(), + Ok(Err(error)) => format!("Command hook failed to run: {error}"), + Ok(Ok(output)) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + "Command hook produced no output.".to_string() + } else { + stdout + } + } + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stderr = truncate_chars(&stderr, 500); + if stderr.is_empty() { + format!("Command hook exited with {}", output.status) + } else { + format!("Command hook exited with {}: {stderr}", output.status) + } + } + } +} + +const COMMAND_HOOK_TIMEOUT_SECS: u64 = 15; + +fn truncate_chars(text: &str, max: usize) -> &str { + match text.char_indices().nth(max) { + Some((index, _)) => &text[..index], + None => text, } } diff --git a/src/test_support.rs b/src/test_support.rs index 9d8d601..89e61c5 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; use uuid::Uuid; @@ -61,6 +62,7 @@ pub fn test_config() -> crate::config::Config { voice_name: crate::config::DEFAULT_VOICE_NAME.to_string(), agent: "codex".to_string(), routes: Vec::new(), + command_hooks: HashMap::new(), assistant_root: "/fake/assistant".to_string(), jobs_dir: "/fake/jobs".to_string(), jobs_agent: None,