1use std::collections::BTreeMap;
13use std::ffi::OsString;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17use std::sync::Arc;
18use std::time::{Duration, SystemTime};
19
20use byob_sandbox::{Limits, Policy, RunOptions, Sandbox, TempDir};
21use byob_tools::{Access, FileChange, Tool, ToolContext, ToolOutput, ToolSpec};
22use serde_json::Value;
23
24use crate::PluginRef;
25use crate::discover::Plugin;
26use crate::manifest::{ManifestTool, ToolKind, path_inside};
27use crate::names::{ToolNames, namespaced};
28use crate::pyenv::PluginPython;
29
30pub const SCRIPT_TIMEOUT: Duration = Duration::from_secs(60);
32pub const MAX_SCRIPT_OUTPUT: usize = 256 * 1024;
34const MAX_SCANNED_FILES: usize = 5000;
36
37pub fn default_python() -> Vec<OsString> {
39 vec![OsString::from(if cfg!(windows) { "python" } else { "python3" })]
40}
41
42#[derive(Debug, Clone)]
44pub struct ScriptRun {
45 pub program: Vec<OsString>,
47 pub script: PathBuf,
48 pub args: Vec<OsString>,
50 pub cwd: PathBuf,
51 pub stdin: Vec<u8>,
52 pub env: BTreeMap<String, String>,
54 pub timeout: Duration,
55 pub max_output: usize,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct ScriptOutcome {
61 pub stdout: String,
62 pub stderr: String,
63 pub exit_code: Option<i32>,
65 pub timed_out: bool,
66 pub stopped: bool,
67 pub truncated: bool,
69}
70
71impl ScriptOutcome {
72 pub fn success(&self) -> bool {
73 self.exit_code == Some(0) && !self.timed_out && !self.stopped
74 }
75}
76
77pub fn run_script_unsandboxed(run: &ScriptRun, cancelled: &dyn Fn() -> bool) -> Result<ScriptOutcome, String> {
80 let Some((program, lead)) = run.program.split_first() else { return Err("no interpreter configured".into()) };
81 let mut cmd = Command::new(program);
82 cmd.args(lead).arg(&run.script).args(&run.args).current_dir(&run.cwd).envs(&run.env);
83 execute(cmd, run, cancelled)
84}
85
86pub fn run_script_sandboxed(
90 run: &ScriptRun,
91 sandbox: &Sandbox,
92 plugin_dir: &Path,
93 network: bool,
94 cancelled: &dyn Fn() -> bool,
95) -> Result<ScriptOutcome, String> {
96 run_script_sandboxed_reading(run, sandbox, &[plugin_dir.to_path_buf()], network, cancelled)
97}
98
99pub fn run_script_sandboxed_reading(
102 run: &ScriptRun,
103 sandbox: &Sandbox,
104 read: &[PathBuf],
105 network: bool,
106 cancelled: &dyn Fn() -> bool,
107) -> Result<ScriptOutcome, String> {
108 let Some((program, lead)) = run.program.split_first() else { return Err("no interpreter configured".into()) };
109 let cwd = run.cwd.canonicalize().map_err(|e| format!("{}: {e}", run.cwd.display()))?;
110 let tmp = TempDir::new("byob-plugin-")?;
111 let mut policy = Policy::for_workspace(&cwd, tmp.path())
112 .network(network)
113 .limits(Limits::for_timeout(run.timeout.as_secs()))
114 .allow_path_dirs(std::env::var_os("PATH").as_deref())
115 .allow_unsandboxed(true);
117 for p in read {
118 policy = policy.allow_read(p);
119 }
120 for p in byob_sandbox::interpreter_read_paths(program) {
121 policy = policy.allow_read(p);
122 }
123 let mut args: Vec<OsString> = lead.to_vec();
124 args.push(run.script.clone().into_os_string());
125 args.extend(run.args.iter().cloned());
126 let mut cmd = sandbox.command(&policy, program, &args);
127 cmd.current_dir(&cwd);
128 byob_sandbox::clean_env(&mut cmd, tmp.path());
129 cmd.envs(&run.env);
130 let out = execute(cmd, run, cancelled)?;
131 drop(tmp);
132 Ok(out)
133}
134
135fn execute(cmd: Command, run: &ScriptRun, cancelled: &dyn Fn() -> bool) -> Result<ScriptOutcome, String> {
136 let opts = RunOptions { stdin: run.stdin.clone(), timeout: run.timeout, max_output: run.max_output };
137 let o = byob_sandbox::run(cmd, &opts, cancelled)?;
138 if let Some(e) = o.sandbox_error() {
139 return Err(format!("the sandbox couldn't start: {e}"));
140 }
141 Ok(ScriptOutcome {
142 stdout: String::from_utf8_lossy(&o.stdout.bytes()).into_owned(),
143 stderr: String::from_utf8_lossy(&o.stderr.bytes()).into_owned(),
144 exit_code: o.exit_code,
145 timed_out: o.timed_out,
146 stopped: o.cancelled,
147 truncated: o.stdout.truncated() || o.stderr.truncated(),
148 })
149}
150
151pub fn fill_title(template: &str, args: &Value) -> String {
153 let mut out = String::new();
154 let mut rest = template;
155 while let Some(open) = rest.find('{') {
156 out.push_str(&rest[..open]);
157 let after = &rest[open + 1..];
158 let key_len = after.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(after.len());
159 if key_len > 0 && after[key_len..].starts_with('}') {
160 let key = &after[..key_len];
161 match args.get(key) {
162 Some(Value::String(s)) => out.push_str(s),
163 Some(v) => out.push_str(&v.to_string()),
164 None => out.push_str(&rest[open..open + key_len + 2]),
165 }
166 rest = &after[key_len + 1..];
167 } else {
168 out.push('{');
169 rest = after;
170 }
171 }
172 out.push_str(rest);
173 out.chars().take(120).collect()
174}
175
176fn snapshot(dir: &Path) -> BTreeMap<PathBuf, Option<SystemTime>> {
178 let mut m = BTreeMap::new();
179 let mut stack = vec![(dir.to_path_buf(), 0u8)];
180 while let Some((d, depth)) = stack.pop() {
181 let Ok(rd) = fs::read_dir(&d) else { continue };
182 for e in rd.flatten() {
183 if m.len() >= MAX_SCANNED_FILES {
184 return m;
185 }
186 if e.file_name().to_string_lossy().starts_with('.') {
187 continue;
188 }
189 let Ok(meta) = e.metadata() else { continue };
190 if meta.is_dir() {
191 if depth < 2 {
192 stack.push((e.path(), depth + 1));
193 }
194 } else {
195 m.insert(e.path(), meta.modified().ok());
196 }
197 }
198 }
199 m
200}
201
202pub struct PythonTool {
204 name: String,
205 plugin: PluginRef,
206 decl: ManifestTool,
207 script: String,
208 interpreter: Vec<OsString>,
209 python: Option<PluginPython>,
211 sandbox: Option<Sandbox>,
212 timeout: Duration,
213}
214
215impl PythonTool {
216 pub fn new(name: String, plugin: PluginRef, decl: ManifestTool, interpreter: Vec<OsString>) -> Option<Self> {
218 let ToolKind::Script(script) = &decl.kind else { return None };
219 let script = script.clone();
220 Some(Self { name, plugin, decl, script, interpreter, python: None, sandbox: None, timeout: SCRIPT_TIMEOUT })
221 }
222
223 pub fn with_python(mut self, python: Option<PluginPython>) -> Self {
225 self.python = python;
226 self
227 }
228
229 pub fn with_sandbox(mut self, sandbox: Option<Sandbox>) -> Self {
231 self.sandbox = sandbox;
232 self
233 }
234
235 pub fn with_timeout(mut self, timeout: Duration) -> Self {
236 self.timeout = timeout;
237 self
238 }
239
240 pub fn plugin(&self) -> &PluginRef {
241 &self.plugin
242 }
243
244 pub fn declared(&self) -> &ManifestTool {
247 &self.decl
248 }
249
250 fn finish(&self, r: &ScriptOutcome, workspace: &Path, before: &BTreeMap<PathBuf, Option<SystemTime>>) -> ToolOutput {
251 let name = &self.plugin.name;
252 if r.stopped {
253 return ToolOutput::err("Stopped by the user.");
254 }
255 if r.timed_out {
256 return ToolOutput::err(format!("{name} took longer than {} seconds and was stopped.", self.timeout.as_secs()));
257 }
258 if r.exit_code != Some(0) {
259 let output = [r.stdout.trim(), r.stderr.trim()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join("\n");
260 return ToolOutput::err(format!("{name} ran into a problem:\n{output}"));
261 }
262 let out = r.stdout.trim();
263 let mut text = out.to_string();
264 let mut listed: Vec<PathBuf> = Vec::new();
265 if out.starts_with('{')
266 && let Ok(Value::Object(j)) = serde_json::from_str::<Value>(out)
267 {
268 if let Some(t) = j.get("text").and_then(Value::as_str) {
269 text = t.to_string();
270 }
271 if let Some(files) = j.get("files").and_then(Value::as_array) {
272 listed = files.iter().filter_map(Value::as_str).filter(|f| path_inside(f)).map(|f| workspace.join(f)).filter(|p| p.is_file()).collect();
273 }
274 }
275 if r.truncated {
276 text.push_str("\n…(output cut: it was too long)");
277 }
278 let after = snapshot(workspace);
279 let mut changes: Vec<FileChange> = after
280 .iter()
281 .filter(|(f, m)| before.get(*f) != Some(*m))
282 .map(|(f, _)| if before.contains_key(f) { FileChange::Modified(f.clone()) } else { FileChange::Created(f.clone()) })
283 .collect();
284 for f in listed {
285 if !changes.iter().any(|c| matches!(c, FileChange::Created(p) | FileChange::Modified(p) if *p == f)) {
286 changes.push(FileChange::Modified(f));
287 }
288 }
289 if text.is_empty() {
290 text = "(no result)".into();
291 }
292 ToolOutput { text, failed: false, changes }
293 }
294}
295
296impl Tool for PythonTool {
297 fn spec(&self) -> ToolSpec {
298 ToolSpec { name: self.name.clone(), description: self.decl.description.clone(), parameters: self.decl.params.clone() }
299 }
300
301 fn title(&self, args: &Value) -> String {
302 match &self.decl.title {
303 Some(t) => fill_title(t, args),
304 None => format!("Used {}: {}", self.plugin.name, self.decl.name),
305 }
306 }
307
308 fn access(&self, _args: &Value) -> Access {
309 Access::Execute
310 }
311
312 fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
313 if ctx.cancelled() {
314 return ToolOutput::err("Stopped by the user.");
315 }
316 let script = self.plugin.dir.join(&self.script);
317 if !path_inside(&self.script) || !script.is_file() {
318 return ToolOutput::err(format!("{}: {} is missing", self.plugin.name, self.script));
319 }
320 let args = if args.is_object() { args.clone() } else { Value::Object(serde_json::Map::new()) };
321 let chat = ctx.workspace.to_string_lossy().into_owned();
322 let dir = self.plugin.dir.to_string_lossy().into_owned();
323 let mut env = self.plugin.env.clone();
324 for (k, v) in [("HOB_CHAT_DIR", &chat), ("BYOB_CHAT_DIR", &chat), ("HOB_PLUGIN_DIR", &dir), ("BYOB_PLUGIN_DIR", &dir)] {
325 env.insert(k.into(), v.clone());
326 }
327 let mut read = vec![self.plugin.dir.clone()];
328 let program = match &self.python {
329 None => self.interpreter.clone(),
330 Some(p) => match p.python_until(&|| ctx.cancelled()) {
332 Ok(py) => {
333 read.extend(py.read);
334 env.extend(py.env);
335 py.program
336 }
337 Err(_) if ctx.cancelled() => return ToolOutput::err("Stopped by the user."),
338 Err(e) => return ToolOutput::err(format!("{} couldn't run: {e}", self.plugin.name)),
339 },
340 };
341 let run = ScriptRun {
342 program,
343 script,
344 args: Vec::new(),
345 cwd: ctx.workspace.to_path_buf(),
346 stdin: serde_json::to_vec(&args).unwrap_or_else(|_| b"{}".to_vec()),
347 env,
348 timeout: self.timeout,
349 max_output: MAX_SCRIPT_OUTPUT,
350 };
351 let before = snapshot(ctx.workspace);
352 let result = match &self.sandbox {
353 Some(sb) => run_script_sandboxed_reading(&run, sb, &read, self.plugin.network, &|| ctx.cancelled()),
354 None => run_script_unsandboxed(&run, &|| ctx.cancelled()),
355 };
356 match result {
357 Ok(r) => self.finish(&r, ctx.workspace, &before),
358 Err(e) => ToolOutput::err(format!("{} couldn't run: {e}", self.plugin.name)),
359 }
360 }
361}
362
363pub fn python_tools(plugin: &Plugin, settings: &BTreeMap<String, String>, interpreter: &[OsString], names: &mut ToolNames) -> Vec<Arc<PythonTool>> {
367 python_tools_in(plugin, settings, interpreter, names, None)
368}
369
370pub fn python_tools_in(
372 plugin: &Plugin,
373 settings: &BTreeMap<String, String>,
374 interpreter: &[OsString],
375 names: &mut ToolNames,
376 sandbox: Option<&Sandbox>,
377) -> Vec<Arc<PythonTool>> {
378 python_tools_with(plugin, settings, interpreter, names, sandbox, None)
379}
380
381pub fn python_tools_with(
383 plugin: &Plugin,
384 settings: &BTreeMap<String, String>,
385 interpreter: &[OsString],
386 names: &mut ToolNames,
387 sandbox: Option<&Sandbox>,
388 python: Option<&PluginPython>,
389) -> Vec<Arc<PythonTool>> {
390 let r = PluginRef::new(plugin, settings);
391 plugin
392 .manifest
393 .tools
394 .iter()
395 .filter(|t| matches!(&t.kind, ToolKind::Script(s) if plugin.dir.join(s).is_file()))
396 .filter_map(|t| {
397 let name = names.claim(&t.name, &namespaced(plugin.id(), &t.name));
398 PythonTool::new(name, r.clone(), t.clone(), interpreter.to_vec()).map(|t| Arc::new(t.with_sandbox(sandbox.cloned()).with_python(python.cloned())))
399 })
400 .collect()
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use serde_json::json;
407
408 #[test]
409 fn titles() {
410 let a = json!({"amount": 5, "from": "EUR", "x": null});
411 assert_eq!(fill_title("Converted {amount} {from} {to} {x}", &a), "Converted 5 EUR {to} null");
412 assert_eq!(fill_title("{ not a key } {", &a), "{ not a key } {");
413 assert_eq!(fill_title(&"é".repeat(200), &a).chars().count(), 120);
414 }
415
416 #[test]
417 fn missing_interpreter_is_an_error() {
418 let run = ScriptRun {
419 program: vec![OsString::from("/definitely/not/an/interpreter")],
420 script: PathBuf::from("x"),
421 args: vec![],
422 cwd: std::env::temp_dir(),
423 stdin: vec![],
424 env: BTreeMap::new(),
425 timeout: SCRIPT_TIMEOUT,
426 max_output: 10,
427 };
428 assert!(run_script_unsandboxed(&run, &|| false).unwrap_err().starts_with("couldn't start"));
429 let empty = ScriptRun { program: vec![], ..run };
430 assert!(run_script_unsandboxed(&empty, &|| false).is_err());
431 }
432}