1use std::path::{Component, Path};
4use std::process::Stdio;
5use std::sync::Arc;
6
7use serde_json::Value;
8
9use crate::memory_backend::MemoryBinding;
10use crate::session::types::ToolEnv;
11use crate::skills::registry::{RegisteredPluginCommand, RegisteredPluginCommandBackend};
12use crate::tools::{ToolCapabilities, ToolChannels, ToolLimits};
13use crate::{ToolContext, ToolRegistry};
14
15#[derive(Clone, Copy)]
21pub struct CommandChat<'a> {
22 pub session_id: Option<&'a str>,
24 pub cwd: Option<&'a Path>,
26 pub memory_backend: &'a MemoryBinding,
29 pub env: Option<&'a ToolEnv>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PluginCommandOutput {
36 pub status: Option<i32>,
37 pub stdout: String,
38 pub stderr: String,
39}
40
41fn validate_command_path(command: &str) -> crate::Result<()> {
42 let path = Path::new(command);
43 if path.is_absolute() {
44 return Err(crate::RuntimeError::Tool(
45 "plugin command must be relative to plugin root or resolved from PATH".to_string(),
46 ));
47 }
48 if path.components().any(|c| matches!(c, Component::ParentDir)) {
49 return Err(crate::RuntimeError::Tool(
50 "plugin command may not contain '..' path components".to_string(),
51 ));
52 }
53 Ok(())
54}
55
56fn interpolate_slash_args(value: Value, slash_args: &str) -> Value {
57 match value {
58 Value::String(s) if s == "${args}" => Value::String(slash_args.to_string()),
59 Value::String(s) => Value::String(s.replace("${args}", slash_args)),
60 Value::Array(items) => Value::Array(
61 items
62 .into_iter()
63 .map(|v| interpolate_slash_args(v, slash_args))
64 .collect(),
65 ),
66 Value::Object(obj) => Value::Object(
67 obj.into_iter()
68 .map(|(k, v)| (k, interpolate_slash_args(v, slash_args)))
69 .collect(),
70 ),
71 other => other,
72 }
73}
74
75pub async fn execute_plugin_command(
80 command: &RegisteredPluginCommand,
81 slash_args: &str,
82 env: Option<&ToolEnv>,
83) -> crate::Result<PluginCommandOutput> {
84 match &command.backend {
85 RegisteredPluginCommandBackend::Shell {
86 command: executable,
87 args,
88 } => {
89 validate_command_path(executable)?;
90
91 let mut cmd = tokio::process::Command::new(executable);
92 cmd.current_dir(&command.plugin_root)
93 .args(args)
94 .args(slash_words(slash_args))
95 .stdin(Stdio::null())
96 .stdout(Stdio::piped())
97 .stderr(Stdio::piped());
98 if let Some(env) = env {
99 cmd.env_clear().envs(env.spawn_vars());
100 }
101
102 let output = cmd.output().await.map_err(|e| {
103 crate::RuntimeError::Tool(format!(
104 "failed to run plugin command /{}:{}: {}",
105 command.plugin, command.name, e
106 ))
107 })?;
108
109 Ok(PluginCommandOutput {
110 status: output.status.code(),
111 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
112 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
113 })
114 }
115 RegisteredPluginCommandBackend::SkillPrompt { skill, prompt } => {
116 let mut text = prompt.replace("${args}", slash_args);
117 if !slash_args.trim().is_empty() && !prompt.contains("${args}") {
118 text.push('\n');
119 text.push_str(slash_args);
120 }
121 Ok(PluginCommandOutput {
122 status: Some(0),
123 stdout: format!(
124 "Skill prompt for /{}:{} (skill: {})\n{}",
125 command.plugin, command.name, skill, text
126 ),
127 stderr: String::new(),
128 })
129 }
130 RegisteredPluginCommandBackend::ExtensionTool { .. } => Err(crate::RuntimeError::Tool(
131 "extension-backed plugin command requires execute_plugin_command_with_tools"
132 .to_string(),
133 )),
134 RegisteredPluginCommandBackend::Interactive { .. } => Err(crate::RuntimeError::Tool(
135 "interactive plugin command requires ExtensionManager::invoke_command".to_string(),
136 )),
137 }
138}
139
140pub async fn execute_plugin_command_with_tools(
146 command: &RegisteredPluginCommand,
147 slash_args: &str,
148 tools: Arc<tokio::sync::RwLock<ToolRegistry>>,
149 chat: CommandChat<'_>,
150) -> crate::Result<PluginCommandOutput> {
151 if let RegisteredPluginCommandBackend::ExtensionTool { tool, input } = &command.backend {
152 let runtime_tool_name = format!("{}:{}", command.plugin, tool);
153 let params = interpolate_slash_args(input.clone(), slash_args);
154 let registry = tools.read().await;
155 let tool = registry
156 .get(&runtime_tool_name)
157 .ok_or_else(|| {
158 crate::RuntimeError::Tool(format!(
159 "extension tool '{}' is not registered",
160 runtime_tool_name
161 ))
162 })?
163 .clone();
164 drop(registry);
165 let stdout = tool.execute(params, command_tool_context(chat)).await?;
166 Ok(PluginCommandOutput {
167 status: Some(0),
168 stdout,
169 stderr: String::new(),
170 })
171 } else {
172 execute_plugin_command(command, slash_args, chat.env).await
173 }
174}
175
176fn command_tool_context(chat: CommandChat<'_>) -> ToolContext {
180 ToolContext {
181 channels: ToolChannels {
182 tx_delta: None,
183 tx_events: None,
184 },
185 capabilities: ToolCapabilities {
186 session_allow_all: None,
187 launch_cancel: None,
188 memory_backend: Some(chat.memory_backend.clone()),
189 telemetry: None,
190 tool_register_tx: None,
191 session_manager: None,
192 subagent_registry: None,
193 event_queue: None,
194 delegation_parent: None,
195 codex_parent_plan: None,
196 secret_prompt: None,
197 orchestration: None,
198 tool_activation: None,
199 mcp_leases: None,
200 extension_leases: None,
201 cwd: chat.cwd.map(Path::to_path_buf),
202 env: chat.env.cloned(),
203 env_stripped: Vec::new(),
204 env_warned: Default::default(),
205 session_id: chat.session_id.map(str::to_string),
206 hook_bus: None,
207 continuation: None,
208 },
209 limits: ToolLimits {
210 max_tool_output: 30_000,
211 max_tool_buffer: 256 * 1024,
212 bash_timeout: 30,
213 bash_max_timeout: 300,
214 subagent_timeout: 300,
215 },
216 }
217}
218
219fn slash_words(input: &str) -> Vec<String> {
220 input.split_whitespace().map(str::to_string).collect()
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::Tool;
227 use async_trait::async_trait;
228 use std::path::PathBuf;
229
230 fn cmd(command: &str, args: Vec<&str>, root: PathBuf) -> RegisteredPluginCommand {
231 RegisteredPluginCommand {
232 plugin: "p".to_string(),
233 name: "hello".to_string(),
234 description: None,
235 backend: RegisteredPluginCommandBackend::Shell {
236 command: command.to_string(),
237 args: args.into_iter().map(str::to_string).collect(),
238 },
239 plugin_root: root,
240 }
241 }
242
243 fn tempdir() -> PathBuf {
244 let dir = std::env::temp_dir().join(format!(
245 "synaps-plugin-cmd-test-{}-{}",
246 std::process::id(),
247 crate::epoch_millis()
248 ));
249 std::fs::create_dir_all(&dir).unwrap();
250 dir
251 }
252
253 #[tokio::test]
254 async fn execute_plugin_command_runs_from_plugin_root_and_appends_slash_args() {
255 let root = tempdir();
256 let command = cmd("printf", vec!["cwd=%s arg=%s", "."], root);
257
258 let output = execute_plugin_command(&command, "extra", None).await.unwrap();
259
260 assert_eq!(output.status, Some(0));
261 assert_eq!(output.stdout, "cwd=. arg=extra");
262 }
263
264 #[tokio::test]
265 async fn execute_plugin_command_rejects_parent_dir_command_path() {
266 let root = tempdir();
267 let command = cmd("../evil", vec![], root);
268
269 let result = execute_plugin_command(&command, "", None).await;
270
271 assert!(result.is_err());
272 }
273
274 #[tokio::test]
275 async fn skill_prompt_command_outputs_prompt_with_args() {
276 let command = RegisteredPluginCommand {
277 plugin: "p".to_string(),
278 name: "review".to_string(),
279 description: None,
280 backend: RegisteredPluginCommandBackend::SkillPrompt {
281 skill: "reviewer".to_string(),
282 prompt: "Review: ${args}".to_string(),
283 },
284 plugin_root: PathBuf::from("/tmp/p"),
285 };
286
287 let output = execute_plugin_command(&command, "diff", None).await.unwrap();
288 assert_eq!(output.status, Some(0));
289 assert!(output.stdout.contains("Review: diff"));
290 }
291
292 struct EchoTool;
293
294 #[async_trait]
295 impl Tool for EchoTool {
296 fn name(&self) -> &str {
297 "p:echo"
298 }
299 fn description(&self) -> &str {
300 "echo"
301 }
302 fn parameters(&self) -> Value {
303 serde_json::json!({"type":"object"})
304 }
305 async fn execute(&self, params: Value, _ctx: ToolContext) -> crate::Result<String> {
306 Ok(format!("echo {}", params["text"].as_str().unwrap()))
307 }
308 }
309
310 #[tokio::test]
311 async fn extension_tool_command_executes_namespaced_registered_tool() {
312 let command = RegisteredPluginCommand {
313 plugin: "p".to_string(),
314 name: "echo".to_string(),
315 description: None,
316 backend: RegisteredPluginCommandBackend::ExtensionTool {
317 tool: "echo".to_string(),
318 input: serde_json::json!({"text":"${args}"}),
319 },
320 plugin_root: PathBuf::from("/tmp/p"),
321 };
322 let registry = Arc::new(tokio::sync::RwLock::new(ToolRegistry::without_subagent()));
323 registry.write().await.register(Arc::new(EchoTool));
324 let runtime = crate::Runtime::new_headless();
325
326 let output =
327 execute_plugin_command_with_tools(&command, "hello", registry, runtime.command_chat())
328 .await
329 .unwrap();
330
331 assert_eq!(output.status, Some(0));
332 assert_eq!(output.stdout, "echo hello");
333 }
334}