Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -76,6 +76,10 @@ pub struct Config {
pub agent: String,
#[serde(default)]
pub routes: Vec<RouteRule>,
/// 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<String, String>,
/// Canonical root of the single user-owned assistant repository.
#[serde(default)]
pub assistant_root: String,
Expand Down Expand Up @@ -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,
Expand Down
103 changes: 103 additions & 0 deletions src/gateway/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 80 additions & 6 deletions src/gateway/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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<String> {
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<String> {
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
Expand All @@ -828,11 +847,66 @@ fn command(ctx: &Ctx, job: &Job) -> Option<String> {
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 <message args>`: 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,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/test_support.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use uuid::Uuid;
Expand Down Expand Up @@ -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,
Expand Down