1use everruns_core::capabilities::{
20 Capability, CapabilityLocalization, CapabilityStatus, RiskLevel,
21};
22use everruns_core::*;
23use std::result::Result;
24
25mod code_mode;
26use crate::exec_tool_result::ExecToolResultPayload;
27use crate::session_file::SessionFile;
28use crate::tool_types::ToolHints;
29use crate::tools::{Tool, ToolExecutionResult};
30use crate::typed_id::SessionId;
31use async_trait::async_trait;
32pub use code_mode::{LUA_CODE_MODE_CAPABILITY_ID, LuaCodeModeCapability};
33use everruns_core::session_files::SessionFileSystem;
34use everruns_core::tool_context::ToolContext;
35#[cfg(test)]
36use everruns_provider::error;
37use everruns_provider::{tool_types, typed_id};
38use serde_json::{Value, json};
39use std::sync::Arc;
40use std::time::Duration;
41
42pub const LUA_CAPABILITY_ID: &str = "lua";
43
44const DEFAULT_TIMEOUT_MS: u64 = 30_000;
45const MAX_TIMEOUT_MS: u64 = 60_000;
46const MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
48const MAX_INSTRUCTIONS: u64 = 50_000_000;
50const MAX_OUTPUT_BYTES: usize = 64 * 1024;
52
53const TOOL_DESCRIPTION: &str = r#"Execute a Lua 5.4 script in an isolated, sandboxed environment.
54
55The session filesystem is available through the `fs` table (rooted at /workspace),
56and `json.encode` / `json.decode` are available for structured data. Use `print(...)`
57for output and `return <value>` to send a JSON-serializable result back.
58
59No network, no host process access, no `io`/`os.execute`. CPU, memory, and runtime
60are bounded."#;
61
62const SYSTEM_PROMPT: &str = r#"You can run Lua 5.4 scripts via the `lua` tool for logic, math, and structured
63data processing over the session workspace.
64
65Host API:
66- `fs.read(path)`, `fs.write(path, s)`, `fs.append(path, s)`, `fs.exists(path)`
67- `fs.list(path)`, `fs.stat(path)`, `fs.remove(path[, recursive])`, `fs.mkdir(path)`
68- `fs.grep(pattern[, path])` (indexed search)
69- `json.encode(value)`, `json.decode(string)`
70- `base64.encode(string)`, `base64.decode(string)`
71- `print(...)` for output; `return value` to return a JSON-serializable result.
72
73The full Lua 5.4 standard library is available (`string.*` incl. `format`/
74`find`/`match`/`gsub`, `table.*` incl. `sort`, `math.*`, `os.time`/`os.date`).
75
76When enabled by the environment (otherwise these globals are nil):
77- `http.get(url)` / `http.post(url, body)` -> `{ status, body }`, allow-listed
78 hosts only.
79- `tools.<name>(args_table)` -> result, to call other available tools.
80
81Disabled for sandboxing (do not use — nil): `io`, `os.execute`/`os.getenv`,
82`require`/`package`, `load`/`dofile`. Use `fs` for files; raw sockets are not
83available (use `http` when present)."#;
84
85pub struct LuaCapability;
91
92impl Capability for LuaCapability {
93 fn id(&self) -> &str {
94 LUA_CAPABILITY_ID
95 }
96
97 fn name(&self) -> &str {
98 "Lua"
99 }
100
101 fn description(&self) -> &str {
102 r#"Execute Lua scripts in an isolated, sandboxed environment.
103
104> [!NOTE]
105> Scripts run in a virtual environment with no host or network access. The
106> session filesystem is available via the `fs` table and `json` is available
107> for structured data."#
108 }
109
110 fn localizations(&self) -> Vec<CapabilityLocalization> {
111 vec![CapabilityLocalization::text(
112 "uk",
113 "Lua",
114 r#"Виконуйте Lua-скрипти в ізольованому середовищі-пісочниці.
115
116> [!NOTE]
117> Скрипти виконуються у віртуальному середовищі без доступу до хоста чи мережі.
118> Файлова система сесії доступна через таблицю `fs`, а для структурованих даних
119> доступний `json`."#,
120 )]
121 }
122
123 fn status(&self) -> CapabilityStatus {
124 CapabilityStatus::Available
125 }
126
127 fn risk_level(&self) -> RiskLevel {
128 RiskLevel::High
131 }
132
133 fn icon(&self) -> Option<&str> {
134 Some("code")
135 }
136
137 fn category(&self) -> Option<&str> {
138 Some("Execution")
139 }
140
141 fn system_prompt_addition(&self) -> Option<&str> {
142 Some(SYSTEM_PROMPT)
143 }
144
145 fn tools(&self) -> Vec<Box<dyn Tool>> {
146 vec![Box::new(LuaTool)]
147 }
148
149 fn dependencies(&self) -> Vec<&'static str> {
150 vec!["session_file_system"]
151 }
152
153 fn features(&self) -> Vec<&'static str> {
154 vec!["file_system"]
155 }
156}
157
158pub struct LuaTool;
164
165#[async_trait]
166impl Tool for LuaTool {
167 fn name(&self) -> &str {
168 "lua"
169 }
170
171 fn display_name(&self) -> Option<&str> {
172 Some("Lua")
173 }
174
175 fn description(&self) -> &str {
176 TOOL_DESCRIPTION
177 }
178
179 fn parameters_schema(&self) -> Value {
180 json!({
181 "type": "object",
182 "properties": {
183 "script": {
184 "type": "string",
185 "description": "Lua 5.4 source to execute."
186 },
187 "working_dir": {
188 "type": "string",
189 "default": crate::session_path::WORKSPACE_PREFIX,
190 "description": "Working directory (informational; `fs.*` paths default to /workspace and resolve through the session filesystem)."
191 },
192 "timeout_ms": {
193 "type": "integer",
194 "default": DEFAULT_TIMEOUT_MS,
195 "description": "Wall-clock timeout in milliseconds (capped at 60000)."
196 },
197 "output": crate::tool_output_sanitizer::output_verbosity_schema(),
198 },
199 "required": ["script"],
200 })
201 }
202
203 fn hints(&self) -> ToolHints {
204 ToolHints::default()
205 .with_long_running(true)
206 .with_persist_output(true)
207 .with_concurrency_class("session_workspace")
211 .with_cpu_bound(true)
212 }
213
214 fn requires_context(&self) -> bool {
215 true
216 }
217
218 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
219 ToolExecutionResult::tool_error(
220 "lua requires context. This tool must be executed with session context.",
221 )
222 }
223
224 async fn execute_with_context(
225 &self,
226 arguments: Value,
227 context: &ToolContext,
228 ) -> ToolExecutionResult {
229 let script = match arguments.get("script").and_then(|v| v.as_str()) {
230 Some(s) => s.to_string(),
231 None => return ToolExecutionResult::tool_error("Missing required parameter: script"),
232 };
233
234 let timeout_ms = arguments
235 .get("timeout_ms")
236 .and_then(|v| v.as_u64())
237 .unwrap_or(DEFAULT_TIMEOUT_MS)
238 .min(MAX_TIMEOUT_MS);
239
240 let output_mode = arguments
241 .get("output")
242 .and_then(|v| v.as_str())
243 .unwrap_or("auto")
244 .to_string();
245
246 let file_store = match &context.file_store {
247 Some(store) => store.clone(),
248 None => {
249 return ToolExecutionResult::tool_error(
250 "File system not available in this context",
251 );
252 }
253 };
254
255 let vfs = Arc::new(LuaVfs::new(context.session_id, file_store));
256 let limits = LuaLimits {
257 memory_bytes: MEMORY_LIMIT_BYTES,
258 max_instructions: MAX_INSTRUCTIONS,
259 timeout: Duration::from_millis(timeout_ms),
260 max_output_bytes: MAX_OUTPUT_BYTES,
261 };
262
263 let allowed_tools = gated_code_mode_tools(context);
267
268 let http_enabled = context.egress_service.is_some()
271 && context
272 .network_access
273 .as_ref()
274 .is_some_and(|a| !a.allowed.is_empty());
275
276 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
278 let emit_context = context.clone();
279 let emit_task = tokio::spawn(async move {
280 while let Some(chunk) = rx.recv().await {
281 if !chunk.is_empty() {
282 emit_context.emit_tool_output("lua", &chunk, "stdout").await;
283 }
284 }
285 });
286
287 let outcome = tokio::time::timeout(
289 limits.timeout + Duration::from_secs(2),
290 engine::run(
291 &script,
292 vfs,
293 context.clone(),
294 allowed_tools,
295 http_enabled,
296 &limits,
297 tx,
298 ),
299 )
300 .await;
301
302 let _ = emit_task.await;
303
304 let outcome = match outcome {
305 Ok(o) => o,
306 Err(_) => {
307 return ToolExecutionResult::tool_error(format!(
308 "Lua execution timed out after {}ms",
309 timeout_ms
310 ));
311 }
312 };
313
314 if let Some(err) = outcome.error {
315 let clean = crate::tool_output_sanitizer::clean_exec_output(&outcome.stdout);
316 return ToolExecutionResult::tool_error(if clean.is_empty() {
317 format!("Lua error: {err}")
318 } else {
319 format!("Lua error: {err}\n--- output ---\n{clean}")
320 });
321 }
322
323 let payload = ExecToolResultPayload::new(&outcome.stdout, "", 0, &output_mode);
325 let ExecToolResultPayload {
326 stdout,
327 truncated,
328 total_lines,
329 raw_output,
330 ..
331 } = payload;
332
333 ToolExecutionResult::success_with_raw_output(
334 json!({
335 "stdout": stdout,
336 "result": outcome.return_value,
337 "success": true,
338 "truncated": truncated,
339 "total_lines": total_lines,
340 }),
341 raw_output,
342 )
343 }
344}
345
346pub fn is_code_mode_eligible(
361 name: &str,
362 policy: &crate::tool_types::ToolPolicy,
363 hints: &ToolHints,
364) -> bool {
365 use crate::tool_types::ToolPolicy;
366 name != "lua"
367 && name != "bash"
368 && *policy == ToolPolicy::Auto
369 && hints.destructive != Some(true)
370 && hints.cpu_bound != Some(true)
371}
372
373fn gated_code_mode_tools(context: &ToolContext) -> Vec<String> {
378 let Some(reg) = context.tool_registry.as_ref() else {
379 return Vec::new();
380 };
381 let mut out = Vec::new();
382 for name in reg.tool_names() {
383 let Some(tool) = reg.get(name) else { continue };
384 if is_code_mode_eligible(name, &tool.policy(), &tool.hints()) {
385 out.push(name.to_string());
386 }
387 }
388 out.sort();
389 out
390}
391
392#[derive(Debug, Clone)]
399pub struct LuaLimits {
400 pub memory_bytes: usize,
401 pub max_instructions: u64,
402 pub timeout: Duration,
403 pub max_output_bytes: usize,
404}
405
406#[derive(Debug, Default)]
408pub struct LuaOutcome {
409 pub stdout: String,
411 pub return_value: Option<Value>,
413 pub error: Option<String>,
415}
416
417impl LuaOutcome {
418 fn engine_error(msg: impl Into<String>) -> Self {
419 Self {
420 error: Some(msg.into()),
421 ..Default::default()
422 }
423 }
424}
425
426#[derive(Debug, Clone)]
432pub struct VfsEntry {
433 pub name: String,
434 pub is_dir: bool,
435 pub size: i64,
436}
437
438#[derive(Debug, Clone)]
440pub struct VfsGrepHit {
441 pub path: String,
442 pub line_number: usize,
443 pub line: String,
444}
445
446pub struct LuaVfs {
448 session_id: SessionId,
449 store: Arc<dyn SessionFileSystem>,
450}
451
452impl LuaVfs {
453 pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
454 Self { session_id, store }
455 }
456
457 pub async fn read(&self, path: &str) -> Result<String, String> {
458 let sp = crate::session_path::to_session_path(path);
459 match self.store.read_file(self.session_id, &sp).await {
460 Ok(Some(file)) => {
461 let content = file.content.unwrap_or_default();
462 let bytes = SessionFile::decode_content(&content, &file.encoding)
463 .map_err(|e| e.to_string())?;
464 Ok(String::from_utf8_lossy(&bytes).into_owned())
465 }
466 Ok(None) => Err(format!("file not found: {path}")),
467 Err(e) => Err(e.to_string()),
468 }
469 }
470
471 pub async fn write(&self, path: &str, content: &str) -> Result<(), String> {
472 let sp = crate::session_path::to_session_path(path);
473 let (encoded, encoding) = SessionFile::encode_content(content.as_bytes());
474 self.store
475 .write_file(self.session_id, &sp, &encoded, &encoding)
476 .await
477 .map(|_| ())
478 .map_err(|e| e.to_string())
479 }
480
481 pub async fn append(&self, path: &str, content: &str) -> Result<(), String> {
482 let mut existing = self.read(path).await.unwrap_or_default();
484 existing.push_str(content);
485 self.write(path, &existing).await
486 }
487
488 pub async fn exists(&self, path: &str) -> Result<bool, String> {
489 let sp = crate::session_path::to_session_path(path);
490 if matches!(
491 self.store.read_file(self.session_id, &sp).await,
492 Ok(Some(_))
493 ) {
494 return Ok(true);
495 }
496 Ok(self
497 .store
498 .list_directory(self.session_id, &sp)
499 .await
500 .is_ok())
501 }
502
503 pub async fn stat(&self, path: &str) -> Result<Option<VfsEntry>, String> {
504 let sp = crate::session_path::to_session_path(path);
505 match self.store.stat_file(self.session_id, &sp).await {
506 Ok(Some(stat)) => Ok(Some(VfsEntry {
507 name: stat.name,
508 is_dir: stat.is_directory,
509 size: stat.size_bytes,
510 })),
511 Ok(None) => Ok(None),
512 Err(e) => Err(e.to_string()),
513 }
514 }
515
516 pub async fn list(&self, path: &str) -> Result<Vec<VfsEntry>, String> {
517 let sp = crate::session_path::to_session_path(path);
518 self.store
519 .list_directory(self.session_id, &sp)
520 .await
521 .map(|entries| {
522 entries
523 .into_iter()
524 .map(|e| VfsEntry {
525 name: e.name,
526 is_dir: e.is_directory,
527 size: e.size_bytes,
528 })
529 .collect()
530 })
531 .map_err(|e| e.to_string())
532 }
533
534 pub async fn remove(&self, path: &str, recursive: bool) -> Result<bool, String> {
535 let sp = crate::session_path::to_session_path(path);
536 self.store
537 .delete_file(self.session_id, &sp, recursive)
538 .await
539 .map_err(|e| e.to_string())
540 }
541
542 pub async fn mkdir(&self, path: &str) -> Result<(), String> {
543 let sp = crate::session_path::to_session_path(path);
544 self.store
545 .create_directory(self.session_id, &sp)
546 .await
547 .map(|_| ())
548 .map_err(|e| e.to_string())
549 }
550
551 pub async fn grep(&self, pattern: &str, path: Option<&str>) -> Result<Vec<VfsGrepHit>, String> {
552 let scope = match path {
554 Some(p) => {
555 let sp = crate::session_path::to_session_path(p);
556 if sp == "/" { None } else { Some(sp) }
557 }
558 None => None,
559 };
560 self.store
561 .grep_files(self.session_id, pattern, scope.as_deref())
562 .await
563 .map(|matches| {
564 matches
565 .into_iter()
566 .map(|m| VfsGrepHit {
567 path: crate::session_path::to_display_path(&m.path),
569 line_number: m.line_number,
570 line: m.line,
571 })
572 .collect()
573 })
574 .map_err(|e| e.to_string())
575 }
576}
577
578mod engine {
601 use super::{LuaLimits, LuaOutcome, LuaVfs, ToolContext, VfsEntry, VfsGrepHit};
602 use mlua::{
603 HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Value as LuaValue, Variadic, VmState,
604 };
605 use std::sync::atomic::{AtomicU64, Ordering};
606 use std::sync::{Arc, Mutex};
607 use std::time::Instant;
608 use tokio::sync::{mpsc, oneshot};
609
610 const HOOK_EVERY: u32 = 100_000;
612 const HTTP_BODY_CAP: usize = 1024 * 1024;
614
615 enum Op {
618 Read(String),
619 Write(String, String),
620 Append(String, String),
621 Exists(String),
622 Stat(String),
623 List(String),
624 Remove(String, bool),
625 Mkdir(String),
626 Grep(String, Option<String>),
627 Http {
628 method: &'static str,
629 url: String,
630 body: Option<String>,
631 },
632 Tool {
633 name: String,
634 args: serde_json::Value,
635 },
636 }
637
638 enum Reply {
639 Str(String),
640 Bool(bool),
641 Unit,
642 Stat(Option<VfsEntry>),
643 List(Vec<VfsEntry>),
644 Grep(Vec<VfsGrepHit>),
645 Http { status: u16, body: String },
646 Json(serde_json::Value),
647 }
648
649 struct Request {
650 op: Op,
651 reply: oneshot::Sender<Result<Reply, String>>,
652 }
653
654 async fn dispatch(vfs: &LuaVfs, ctx: &ToolContext, op: Op) -> Result<Reply, String> {
655 match op {
656 Op::Read(p) => vfs.read(&p).await.map(Reply::Str),
657 Op::Write(p, c) => vfs.write(&p, &c).await.map(|_| Reply::Unit),
658 Op::Append(p, c) => vfs.append(&p, &c).await.map(|_| Reply::Unit),
659 Op::Exists(p) => vfs.exists(&p).await.map(Reply::Bool),
660 Op::Stat(p) => vfs.stat(&p).await.map(Reply::Stat),
661 Op::List(p) => vfs.list(&p).await.map(Reply::List),
662 Op::Remove(p, r) => vfs.remove(&p, r).await.map(Reply::Bool),
663 Op::Mkdir(p) => vfs.mkdir(&p).await.map(|_| Reply::Unit),
664 Op::Grep(pat, scope) => vfs.grep(&pat, scope.as_deref()).await.map(Reply::Grep),
665 Op::Http { method, url, body } => do_http(ctx, method, url, body).await,
666 Op::Tool { name, args } => do_tool(ctx, &name, args).await.map(Reply::Json),
667 }
668 }
669
670 async fn do_http(
673 ctx: &ToolContext,
674 method: &'static str,
675 url: String,
676 body: Option<String>,
677 ) -> Result<Reply, String> {
678 use crate::egress::{EgressRequest, EgressRequestKind};
679 let acl = ctx.network_access.as_ref();
680 let permitted = acl
681 .map(|a| !a.allowed.is_empty() && a.is_url_allowed(&url))
682 .unwrap_or(false);
683 if !permitted {
684 return Err(format!(
685 "network egress denied: {url} is not in the allow-list"
686 ));
687 }
688 let egress = ctx
689 .egress_service
690 .as_ref()
691 .ok_or_else(|| "network egress unavailable in this environment".to_string())?;
692 let mut req = EgressRequest::new(method, &url, EgressRequestKind::Capability)
693 .require_dns_pinning()
694 .timeout_ms(15_000);
695 if let Some(a) = acl {
696 req = req.network_access(Some(a.clone()));
697 }
698 if let Some(b) = body {
699 req = req
700 .header("content-type", "application/json")
701 .body(b.into_bytes());
702 }
703 let resp = egress.send(req).await.map_err(|e| e.to_string())?;
704 let slice = if resp.body.len() > HTTP_BODY_CAP {
705 &resp.body[..HTTP_BODY_CAP]
706 } else {
707 &resp.body[..]
708 };
709 Ok(Reply::Http {
710 status: resp.status,
711 body: String::from_utf8_lossy(slice).into_owned(),
712 })
713 }
714
715 async fn do_tool(
718 ctx: &ToolContext,
719 name: &str,
720 args: serde_json::Value,
721 ) -> Result<serde_json::Value, String> {
722 use crate::tools::ToolExecutionResult as R;
723 let reg = ctx
724 .tool_registry
725 .as_ref()
726 .ok_or_else(|| "tools unavailable in this environment".to_string())?;
727 let tool = reg
728 .get(name)
729 .ok_or_else(|| format!("unknown tool: {name}"))?;
730 let mut child = ctx.clone();
731 child.tool_registry = None;
732 child.tool_call_id = Some(format!("lua:{name}"));
733 match tool.execute_with_context(args, &child).await {
734 R::Success(v) => Ok(v),
735 R::SuccessWithImages { result, .. } => Ok(result),
736 R::ToolError(e) => Err(e),
737 R::InternalError(_) => Err("tool internal error".to_string()),
738 R::ConnectionRequired { provider } => {
739 Err(format!("tool requires a connection: {provider}"))
740 }
741 }
742 }
743
744 fn call(tx: &mpsc::UnboundedSender<Request>, op: Op) -> Result<Reply, String> {
746 let (reply_tx, reply_rx) = oneshot::channel();
747 tx.send(Request {
748 op,
749 reply: reply_tx,
750 })
751 .map_err(|_| "host channel closed".to_string())?;
752 reply_rx
753 .blocking_recv()
754 .map_err(|_| "host reply dropped".to_string())?
755 }
756
757 #[allow(clippy::too_many_arguments)]
758 pub(super) async fn run(
759 script: &str,
760 vfs: Arc<LuaVfs>,
761 ctx: ToolContext,
762 allowed_tools: Vec<String>,
763 http_enabled: bool,
764 limits: &LuaLimits,
765 output: mpsc::UnboundedSender<String>,
766 ) -> LuaOutcome {
767 let (req_tx, mut req_rx) = mpsc::unbounded_channel::<Request>();
768 let script = script.to_string();
769 let limits = limits.clone();
770
771 let join = tokio::task::spawn_blocking(move || {
772 run_blocking(script, limits, req_tx, output, allowed_tools, http_enabled)
773 });
774
775 while let Some(request) = req_rx.recv().await {
776 let result = dispatch(&vfs, &ctx, request.op).await;
777 let _ = request.reply.send(result);
778 }
779
780 match join.await {
781 Ok(outcome) => outcome,
782 Err(e) => LuaOutcome::engine_error(format!("lua task panicked: {e}")),
783 }
784 }
785
786 fn run_blocking(
787 script: String,
788 limits: LuaLimits,
789 req_tx: mpsc::UnboundedSender<Request>,
790 output: mpsc::UnboundedSender<String>,
791 allowed_tools: Vec<String>,
792 http_enabled: bool,
793 ) -> LuaOutcome {
794 let libs = StdLib::STRING | StdLib::TABLE | StdLib::MATH | StdLib::OS | StdLib::UTF8;
795 let lua = match Lua::new_with(libs, LuaOptions::default()) {
796 Ok(l) => l,
797 Err(e) => return LuaOutcome::engine_error(format!("lua init failed: {e}")),
798 };
799
800 let _ = lua.set_memory_limit(limits.memory_bytes);
801
802 if let Err(e) = scrub_globals(&lua) {
803 return LuaOutcome::engine_error(e);
804 }
805
806 let deadline = Instant::now() + limits.timeout;
807 let budget = limits.max_instructions;
808 let counted = Arc::new(AtomicU64::new(0));
809 {
810 let counted = counted.clone();
811 if let Err(e) = lua.set_hook(
814 HookTriggers::new().every_nth_instruction(HOOK_EVERY),
815 move |_lua, _debug| {
816 if Instant::now() >= deadline {
817 return Err(mlua::Error::runtime("lua: exceeded time limit"));
818 }
819 if counted.fetch_add(HOOK_EVERY as u64, Ordering::Relaxed) >= budget {
820 return Err(mlua::Error::runtime("lua: exceeded instruction budget"));
821 }
822 Ok(VmState::Continue)
823 },
824 ) {
825 return LuaOutcome::engine_error(format!("install hook: {e}"));
826 }
827 }
828
829 let stdout = Arc::new(Mutex::new(String::new()));
830 if let Err(e) = install_print(&lua, stdout.clone(), output, limits.max_output_bytes) {
831 return LuaOutcome::engine_error(format!("install print: {e}"));
832 }
833 if let Err(e) = install_json(&lua) {
834 return LuaOutcome::engine_error(format!("install json: {e}"));
835 }
836 if let Err(e) = install_base64(&lua) {
837 return LuaOutcome::engine_error(format!("install base64: {e}"));
838 }
839 if let Err(e) = install_fs(&lua, req_tx.clone()) {
840 return LuaOutcome::engine_error(format!("install fs: {e}"));
841 }
842 if http_enabled && let Err(e) = install_http(&lua, req_tx.clone()) {
843 return LuaOutcome::engine_error(format!("install http: {e}"));
844 }
845 if !allowed_tools.is_empty()
846 && let Err(e) = install_tools(&lua, req_tx, allowed_tools)
847 {
848 return LuaOutcome::engine_error(format!("install tools: {e}"));
849 }
850
851 let result: mlua::Result<LuaValue> = lua.load(&script).set_name("agent_script").eval();
852 let captured = stdout.lock().map(|s| s.clone()).unwrap_or_default();
853
854 match result {
855 Ok(value) => {
856 let return_value = match value {
857 LuaValue::Nil => None,
858 other => lua.from_value::<serde_json::Value>(other).ok(),
859 };
860 LuaOutcome {
861 stdout: captured,
862 return_value,
863 error: None,
864 }
865 }
866 Err(e) => LuaOutcome {
867 stdout: captured,
868 return_value: None,
869 error: Some(e.to_string()),
870 },
871 }
872 }
873
874 fn scrub_globals(lua: &Lua) -> Result<(), String> {
877 let g = lua.globals();
878 for name in [
879 "io",
880 "package",
881 "require",
882 "dofile",
883 "loadfile",
884 "load",
885 "loadstring",
886 "collectgarbage",
887 ] {
888 g.set(name, LuaValue::Nil).map_err(|e| e.to_string())?;
889 }
890 if let Ok(os) = g.get::<mlua::Table>("os") {
891 for name in [
892 "execute",
893 "getenv",
894 "exit",
895 "remove",
896 "rename",
897 "tmpname",
898 "setlocale",
899 ] {
900 os.set(name, LuaValue::Nil).map_err(|e| e.to_string())?;
901 }
902 }
903 if let Ok(string) = g.get::<mlua::Table>("string") {
904 string
905 .set("dump", LuaValue::Nil)
906 .map_err(|e| e.to_string())?;
907 }
908 Ok(())
909 }
910
911 fn install_print(
912 lua: &Lua,
913 buf: Arc<Mutex<String>>,
914 sink: mpsc::UnboundedSender<String>,
915 cap: usize,
916 ) -> mlua::Result<()> {
917 let print = lua.create_function(move |lua, args: Variadic<LuaValue>| {
918 let mut parts = Vec::with_capacity(args.len());
919 for a in args.iter() {
920 let s = lua
921 .coerce_string(a.clone())?
922 .map(|ls| ls.to_string_lossy())
923 .unwrap_or_else(|| "nil".to_string());
924 parts.push(s);
925 }
926 let mut line = parts.join("\t");
927 line.push('\n');
928 if let Ok(mut g) = buf.lock()
929 && g.len() < cap
930 {
931 g.push_str(&line);
932 }
933 let _ = sink.send(line);
934 Ok(())
935 })?;
936 lua.globals().set("print", print)?;
937 Ok(())
938 }
939
940 fn install_json(lua: &Lua) -> mlua::Result<()> {
941 let json = lua.create_table()?;
942 json.set(
943 "encode",
944 lua.create_function(|lua, value: LuaValue| {
945 let v: serde_json::Value = lua.from_value(value)?;
946 serde_json::to_string(&v).map_err(mlua::Error::external)
947 })?,
948 )?;
949 json.set(
950 "decode",
951 lua.create_function(|lua, s: String| {
952 let v: serde_json::Value =
953 serde_json::from_str(&s).map_err(mlua::Error::external)?;
954 lua.to_value(&v)
955 })?,
956 )?;
957 lua.globals().set("json", json)?;
958 Ok(())
959 }
960
961 fn install_base64(lua: &Lua) -> mlua::Result<()> {
962 use base64::Engine as _;
963 let table = lua.create_table()?;
964 table.set(
965 "encode",
966 lua.create_function(|lua, s: mlua::LuaString| {
967 let out = base64::engine::general_purpose::STANDARD.encode(s.as_bytes());
968 lua.create_string(out)
969 })?,
970 )?;
971 table.set(
972 "decode",
973 lua.create_function(|lua, s: mlua::LuaString| {
974 let bytes = base64::engine::general_purpose::STANDARD
975 .decode(s.as_bytes())
976 .map_err(mlua::Error::external)?;
977 lua.create_string(bytes)
978 })?,
979 )?;
980 lua.globals().set("base64", table)?;
981 Ok(())
982 }
983
984 fn install_http(lua: &Lua, tx: mpsc::UnboundedSender<Request>) -> mlua::Result<()> {
985 let http = lua.create_table()?;
986
987 let t = tx.clone();
988 http.set(
989 "get",
990 lua.create_function(move |lua, url: String| {
991 let reply = call(
992 &t,
993 Op::Http {
994 method: "GET",
995 url,
996 body: None,
997 },
998 )
999 .map_err(mlua::Error::runtime)?;
1000 http_reply_to_table(lua, reply)
1001 })?,
1002 )?;
1003
1004 let t = tx.clone();
1005 http.set(
1006 "post",
1007 lua.create_function(move |lua, (url, body): (String, Option<String>)| {
1008 let reply = call(
1009 &t,
1010 Op::Http {
1011 method: "POST",
1012 url,
1013 body,
1014 },
1015 )
1016 .map_err(mlua::Error::runtime)?;
1017 http_reply_to_table(lua, reply)
1018 })?,
1019 )?;
1020
1021 lua.globals().set("http", http)?;
1022 Ok(())
1023 }
1024
1025 fn http_reply_to_table(lua: &Lua, reply: Reply) -> mlua::Result<mlua::Table> {
1026 let Reply::Http { status, body } = reply else {
1027 return Err(mlua::Error::runtime("http: unexpected reply"));
1028 };
1029 let t = lua.create_table()?;
1030 t.set("status", status)?;
1031 t.set("body", body)?;
1032 Ok(t)
1033 }
1034
1035 fn install_tools(
1036 lua: &Lua,
1037 tx: mpsc::UnboundedSender<Request>,
1038 names: Vec<String>,
1039 ) -> mlua::Result<()> {
1040 let tools = lua.create_table()?;
1041 for name in names {
1042 let t = tx.clone();
1043 let n = name.clone();
1044 tools.set(
1045 name,
1046 lua.create_function(move |lua, args: LuaValue| {
1047 let json: serde_json::Value = match args {
1048 LuaValue::Nil => serde_json::json!({}),
1049 other => lua.from_value(other)?,
1050 };
1051 let reply = call(
1052 &t,
1053 Op::Tool {
1054 name: n.clone(),
1055 args: json,
1056 },
1057 )
1058 .map_err(mlua::Error::runtime)?;
1059 let Reply::Json(v) = reply else {
1060 return Err(mlua::Error::runtime("tool: unexpected reply"));
1061 };
1062 lua.to_value(&v)
1063 })?,
1064 )?;
1065 }
1066 lua.globals().set("tools", tools)?;
1067 Ok(())
1068 }
1069
1070 fn install_fs(lua: &Lua, tx: mpsc::UnboundedSender<Request>) -> mlua::Result<()> {
1071 let fs = lua.create_table()?;
1072
1073 let t = tx.clone();
1074 fs.set(
1075 "read",
1076 lua.create_function(move |lua, path: String| {
1077 match call(&t, Op::Read(path)).map_err(mlua::Error::runtime)? {
1078 Reply::Str(s) => lua.create_string(s),
1079 _ => Err(mlua::Error::runtime("vfs: unexpected reply")),
1080 }
1081 })?,
1082 )?;
1083
1084 let t = tx.clone();
1085 fs.set(
1086 "write",
1087 lua.create_function(move |_lua, (path, content): (String, String)| {
1088 call(&t, Op::Write(path, content)).map_err(mlua::Error::runtime)?;
1089 Ok(())
1090 })?,
1091 )?;
1092
1093 let t = tx.clone();
1094 fs.set(
1095 "append",
1096 lua.create_function(move |_lua, (path, content): (String, String)| {
1097 call(&t, Op::Append(path, content)).map_err(mlua::Error::runtime)?;
1098 Ok(())
1099 })?,
1100 )?;
1101
1102 let t = tx.clone();
1103 fs.set(
1104 "exists",
1105 lua.create_function(move |_lua, path: String| {
1106 let reply = call(&t, Op::Exists(path)).map_err(mlua::Error::runtime)?;
1107 Ok(matches!(reply, Reply::Bool(true)))
1108 })?,
1109 )?;
1110
1111 let t = tx.clone();
1112 fs.set(
1113 "stat",
1114 lua.create_function(move |lua, path: String| {
1115 match call(&t, Op::Stat(path)).map_err(mlua::Error::runtime)? {
1116 Reply::Stat(Some(entry)) => Ok(LuaValue::Table(entry_to_table(lua, &entry)?)),
1117 Reply::Stat(None) => Ok(LuaValue::Nil),
1118 _ => Err(mlua::Error::runtime("vfs: unexpected reply")),
1119 }
1120 })?,
1121 )?;
1122
1123 let t = tx.clone();
1124 fs.set(
1125 "list",
1126 lua.create_function(move |lua, path: String| {
1127 let Reply::List(entries) =
1128 call(&t, Op::List(path)).map_err(mlua::Error::runtime)?
1129 else {
1130 return Err(mlua::Error::runtime("vfs: unexpected reply"));
1131 };
1132 let arr = lua.create_table()?;
1133 for (i, entry) in entries.iter().enumerate() {
1134 arr.set(i + 1, entry_to_table(lua, entry)?)?;
1135 }
1136 Ok(arr)
1137 })?,
1138 )?;
1139
1140 let t = tx.clone();
1141 fs.set(
1142 "remove",
1143 lua.create_function(move |_lua, (path, recursive): (String, Option<bool>)| {
1144 let reply = call(&t, Op::Remove(path, recursive.unwrap_or(false)))
1145 .map_err(mlua::Error::runtime)?;
1146 Ok(matches!(reply, Reply::Bool(true)))
1147 })?,
1148 )?;
1149
1150 let t = tx.clone();
1151 fs.set(
1152 "mkdir",
1153 lua.create_function(move |_lua, path: String| {
1154 call(&t, Op::Mkdir(path)).map_err(mlua::Error::runtime)?;
1155 Ok(())
1156 })?,
1157 )?;
1158
1159 let t = tx.clone();
1160 fs.set(
1161 "grep",
1162 lua.create_function(move |lua, (pattern, path): (String, Option<String>)| {
1163 let Reply::Grep(hits) =
1164 call(&t, Op::Grep(pattern, path)).map_err(mlua::Error::runtime)?
1165 else {
1166 return Err(mlua::Error::runtime("vfs: unexpected reply"));
1167 };
1168 let arr = lua.create_table()?;
1169 for (i, h) in hits.iter().enumerate() {
1170 let row = lua.create_table()?;
1171 row.set("path", h.path.clone())?;
1172 row.set("line_number", h.line_number)?;
1173 row.set("line", h.line.clone())?;
1174 arr.set(i + 1, row)?;
1175 }
1176 Ok(arr)
1177 })?,
1178 )?;
1179
1180 lua.globals().set("fs", fs)?;
1181 Ok(())
1182 }
1183
1184 fn entry_to_table(lua: &Lua, entry: &VfsEntry) -> mlua::Result<mlua::Table> {
1185 let t = lua.create_table()?;
1186 t.set("name", entry.name.clone())?;
1187 t.set("is_dir", entry.is_dir)?;
1188 t.set("size", entry.size)?;
1189 Ok(t)
1190 }
1191}
1192
1193#[cfg(test)]
1198mod tests {
1199 use super::*;
1200
1201 #[test]
1204 fn capability_features() {
1205 let cap = LuaCapability;
1206 assert_eq!(cap.features(), vec!["file_system"]);
1207 }
1208
1209 #[test]
1210 fn schema_requires_script() {
1211 let schema = LuaTool.parameters_schema();
1212 let required = schema["required"].as_array().unwrap();
1213 assert!(required.iter().any(|v| v == "script"));
1214 assert!(schema["properties"].get("script").is_some());
1215 }
1216
1217 #[tokio::test]
1222 async fn execute_without_context_errors() {
1223 let result = LuaTool.execute(json!({"script": "return 1"})).await;
1224 assert!(
1225 matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("requires context"))
1226 );
1227 }
1228
1229 #[tokio::test]
1230 async fn execute_missing_script_errors() {
1231 let ctx = ToolContext::new(SessionId::new());
1232 let result = LuaTool.execute_with_context(json!({}), &ctx).await;
1233 assert!(
1234 matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("Missing required parameter"))
1235 );
1236 }
1237
1238 #[tokio::test]
1239 async fn execute_without_file_store_errors() {
1240 let ctx = ToolContext::new(SessionId::new());
1241 let result = LuaTool
1242 .execute_with_context(json!({"script": "return 1"}), &ctx)
1243 .await;
1244 assert!(
1245 matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("not available"))
1246 );
1247 }
1248
1249 struct EmptyFileStore;
1251
1252 #[async_trait]
1253 impl SessionFileSystem for EmptyFileStore {
1254 fn is_mount_resolver(&self) -> bool {
1255 false
1256 }
1257
1258 async fn read_file(
1259 &self,
1260 _session_id: SessionId,
1261 _path: &str,
1262 ) -> everruns_provider::error::Result<Option<SessionFile>> {
1263 Ok(None)
1264 }
1265
1266 async fn write_file(
1267 &self,
1268 session_id: SessionId,
1269 path: &str,
1270 content: &str,
1271 encoding: &str,
1272 ) -> everruns_provider::error::Result<SessionFile> {
1273 Ok(SessionFile {
1274 id: uuid::Uuid::new_v4(),
1275 session_id: session_id.into(),
1276 path: path.to_string(),
1277 name: path.rsplit('/').next().unwrap_or("").to_string(),
1278 is_directory: false,
1279 is_readonly: false,
1280 content: Some(content.to_string()),
1281 encoding: encoding.to_string(),
1282 size_bytes: content.len() as i64,
1283 created_at: chrono::Utc::now(),
1284 updated_at: chrono::Utc::now(),
1285 })
1286 }
1287
1288 async fn delete_file(
1289 &self,
1290 _session_id: SessionId,
1291 _path: &str,
1292 _recursive: bool,
1293 ) -> everruns_provider::error::Result<bool> {
1294 Ok(false)
1295 }
1296
1297 async fn list_directory(
1298 &self,
1299 _session_id: SessionId,
1300 _path: &str,
1301 ) -> everruns_provider::error::Result<Vec<crate::session_file::FileInfo>> {
1302 Ok(vec![])
1303 }
1304
1305 async fn stat_file(
1306 &self,
1307 _session_id: SessionId,
1308 _path: &str,
1309 ) -> everruns_provider::error::Result<Option<crate::FileStat>> {
1310 Ok(None)
1311 }
1312
1313 async fn grep_files(
1314 &self,
1315 _session_id: SessionId,
1316 _pattern: &str,
1317 _path_pattern: Option<&str>,
1318 ) -> everruns_provider::error::Result<Vec<crate::GrepMatch>> {
1319 Ok(vec![])
1320 }
1321
1322 async fn create_directory(
1323 &self,
1324 session_id: SessionId,
1325 path: &str,
1326 ) -> everruns_provider::error::Result<crate::session_file::FileInfo> {
1327 Ok(crate::session_file::FileInfo {
1328 id: uuid::Uuid::new_v4(),
1329 session_id: session_id.into(),
1330 path: path.to_string(),
1331 name: path.rsplit('/').next().unwrap_or("").to_string(),
1332 is_directory: true,
1333 is_readonly: false,
1334 size_bytes: 0,
1335 created_at: chrono::Utc::now(),
1336 updated_at: chrono::Utc::now(),
1337 })
1338 }
1339 }
1340
1341 mod engine_tests {
1346 use super::*;
1347 use std::collections::HashMap;
1348 use std::sync::Mutex;
1349
1350 async fn run(script: &str, store: Arc<dyn SessionFileSystem>) -> Value {
1351 let mut ctx = ToolContext::new(SessionId::new());
1352 ctx.file_store = Some(store);
1353 match LuaTool
1354 .execute_with_context(json!({ "script": script }), &ctx)
1355 .await
1356 {
1357 ToolExecutionResult::Success(v) => v,
1358 other => panic!("expected success, got {other:?}"),
1359 }
1360 }
1361
1362 #[tokio::test]
1363 async fn runs_logic_and_math() {
1364 let v = run("return 2 + 3 * 4", Arc::new(EmptyFileStore)).await;
1365 assert_eq!(v["result"], json!(14));
1366 assert_eq!(v["success"], json!(true));
1367 }
1368
1369 #[tokio::test]
1370 async fn captures_print_output() {
1371 let v = run("print('hello'); print('world')", Arc::new(EmptyFileStore)).await;
1372 assert_eq!(v["stdout"], json!("hello\nworld\n"));
1373 }
1374
1375 #[tokio::test]
1376 async fn sandbox_blocks_dangerous_globals() {
1377 let script = r#"
1380 return {
1381 io = io == nil,
1382 package = package == nil,
1383 require = require == nil,
1384 load = load == nil,
1385 dofile = dofile == nil,
1386 }
1387 "#;
1388 let v = run(script, Arc::new(EmptyFileStore)).await;
1389 for key in ["io", "package", "require", "load", "dofile"] {
1390 assert_eq!(v["result"][key], json!(true), "{key} should be nil");
1391 }
1392 }
1393
1394 #[tokio::test]
1396 async fn safe_os_subset_available() {
1397 let v = run("return type(os.time())", Arc::new(EmptyFileStore)).await;
1398 assert_eq!(v["result"], json!("number"));
1399 }
1400
1401 #[tokio::test]
1402 async fn fs_write_read_roundtrip() {
1403 let store = Arc::new(MapFileStore::default());
1404 let v = run(
1405 r#"
1406 fs.write("/workspace/a.txt", "hello")
1407 return fs.read("/workspace/a.txt")
1408 "#,
1409 store,
1410 )
1411 .await;
1412 assert_eq!(v["result"], json!("hello"));
1413 }
1414
1415 #[tokio::test]
1416 async fn json_roundtrip_through_vfs() {
1417 let store = Arc::new(MapFileStore::default());
1418 let v = run(
1419 r#"
1420 fs.write("/workspace/d.json", json.encode({ n = 42, name = "x" }))
1421 local t = json.decode(fs.read("/workspace/d.json"))
1422 return t.n
1423 "#,
1424 store,
1425 )
1426 .await;
1427 assert_eq!(v["result"], json!(42));
1428 }
1429
1430 #[tokio::test]
1431 async fn tonumber_shim_works() {
1432 let v = run(r#"return tonumber("41") + 1"#, Arc::new(EmptyFileStore)).await;
1433 assert_eq!(v["result"], json!(42));
1434 }
1435
1436 #[tokio::test]
1437 async fn base64_roundtrip() {
1438 let v = run(
1439 r#"return base64.decode(base64.encode("hello"))"#,
1440 Arc::new(EmptyFileStore),
1441 )
1442 .await;
1443 assert_eq!(v["result"], json!("hello"));
1444 }
1445
1446 #[tokio::test]
1449 async fn http_and_tools_disabled_by_default() {
1450 let v = run(
1452 "return { http = http == nil, tools = tools == nil }",
1453 Arc::new(EmptyFileStore),
1454 )
1455 .await;
1456 assert_eq!(v["result"]["http"], json!(true));
1457 assert_eq!(v["result"]["tools"], json!(true));
1458 }
1459
1460 #[tokio::test]
1461 async fn http_get_through_egress_allowlist() {
1462 let mut ctx = ToolContext::new(SessionId::new());
1463 ctx.file_store = Some(Arc::new(EmptyFileStore));
1464 ctx.egress_service = Some(Arc::new(MockEgress));
1465 ctx.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
1466 "93.184.216.34",
1467 ]));
1468 let v = match LuaTool
1469 .execute_with_context(
1470 json!({ "script": r#"local r = http.get("http://93.184.216.34/x"); return { s = r.status, b = r.body }"# }),
1471 &ctx,
1472 )
1473 .await
1474 {
1475 ToolExecutionResult::Success(v) => v,
1476 other => panic!("expected success, got {other:?}"),
1477 };
1478 assert_eq!(v["result"]["s"], json!(200));
1479 assert_eq!(v["result"]["b"], json!("pong"));
1480 }
1481
1482 #[tokio::test]
1483 async fn http_denies_allowlisted_loopback_ip_before_egress() {
1484 let mut ctx = ToolContext::new(SessionId::new());
1485 ctx.file_store = Some(Arc::new(EmptyFileStore));
1486 ctx.egress_service = Some(Arc::new(everruns_http::DirectEgressService::new()));
1487 ctx.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
1488 "127.0.0.1",
1489 ]));
1490 let result = LuaTool
1491 .execute_with_context(
1492 json!({ "script": r#"return http.get("http://127.0.0.1/latest/meta-data").status"# }),
1493 &ctx,
1494 )
1495 .await;
1496 assert!(
1497 matches!(result, ToolExecutionResult::ToolError(ref msg) if msg.contains("private/internal address")),
1498 "expected egress boundary denial, got {result:?}"
1499 );
1500 }
1501
1502 #[tokio::test]
1503 async fn http_denied_when_not_in_allowlist() {
1504 let mut ctx = ToolContext::new(SessionId::new());
1505 ctx.file_store = Some(Arc::new(EmptyFileStore));
1506 ctx.egress_service = Some(Arc::new(MockEgress));
1507 ctx.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
1508 "allowed.com",
1509 ]));
1510 let result = LuaTool
1511 .execute_with_context(
1512 json!({ "script": r#"return http.get("https://evil.com/x").status"# }),
1513 &ctx,
1514 )
1515 .await;
1516 assert!(
1517 matches!(result, ToolExecutionResult::ToolError(msg) if msg.contains("egress denied"))
1518 );
1519 }
1520
1521 #[tokio::test]
1522 async fn code_mode_calls_a_sibling_tool() {
1523 let mut registry = crate::tools::ToolRegistry::new();
1524 registry.register(EchoTool);
1525 let mut ctx = ToolContext::new(SessionId::new());
1526 ctx.file_store = Some(Arc::new(EmptyFileStore));
1527 ctx.tool_registry = Some(Arc::new(registry));
1528 let v = match LuaTool
1529 .execute_with_context(
1530 json!({ "script": r#"return tools.echo({ n = 5 }).n"# }),
1531 &ctx,
1532 )
1533 .await
1534 {
1535 ToolExecutionResult::Success(v) => v,
1536 other => panic!("expected success, got {other:?}"),
1537 };
1538 assert_eq!(v["result"], json!(5));
1539 }
1540
1541 #[tokio::test]
1542 async fn code_mode_excludes_execution_tools() {
1543 let mut registry = crate::tools::ToolRegistry::new();
1545 registry.register(EchoTool);
1546 registry.register(LuaTool);
1547 let mut ctx = ToolContext::new(SessionId::new());
1548 ctx.file_store = Some(Arc::new(EmptyFileStore));
1549 ctx.tool_registry = Some(Arc::new(registry));
1550 let v = run_with_ctx(
1551 "return { lua = tools.lua == nil, echo = tools.echo ~= nil }",
1552 &ctx,
1553 )
1554 .await;
1555 assert_eq!(
1556 v["result"]["lua"],
1557 json!(true),
1558 "lua tool must not be exposed"
1559 );
1560 assert_eq!(v["result"]["echo"], json!(true));
1561 }
1562
1563 async fn run_with_ctx(script: &str, ctx: &ToolContext) -> Value {
1564 match LuaTool
1565 .execute_with_context(json!({ "script": script }), ctx)
1566 .await
1567 {
1568 ToolExecutionResult::Success(v) => v,
1569 other => panic!("expected success, got {other:?}"),
1570 }
1571 }
1572
1573 struct EchoTool;
1575
1576 #[async_trait]
1577 impl Tool for EchoTool {
1578 fn name(&self) -> &str {
1579 "echo"
1580 }
1581 fn description(&self) -> &str {
1582 "Echo the argument."
1583 }
1584 fn parameters_schema(&self) -> Value {
1585 json!({ "type": "object" })
1586 }
1587 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
1588 ToolExecutionResult::success(arguments)
1589 }
1590 }
1591
1592 struct MockEgress;
1594
1595 #[async_trait]
1596 impl crate::egress::EgressService for MockEgress {
1597 async fn send(
1598 &self,
1599 request: crate::egress::EgressRequest,
1600 ) -> crate::egress::EgressResult<crate::egress::EgressResponse> {
1601 if !request.dns_pinning_required {
1602 return Err(crate::egress::EgressError::InvalidRequest(
1603 "missing DNS-pinned validation request".to_string(),
1604 ));
1605 }
1606 Ok(crate::egress::EgressResponse {
1607 status: 200,
1608 headers: std::collections::BTreeMap::new(),
1609 body: b"pong".to_vec(),
1610 })
1611 }
1612
1613 async fn send_stream(
1614 &self,
1615 _request: crate::egress::EgressRequest,
1616 ) -> crate::egress::EgressResult<crate::egress::EgressStreamResponse> {
1617 Err(crate::egress::EgressError::Transport(
1618 "streaming not supported in mock".to_string(),
1619 ))
1620 }
1621 }
1622
1623 #[tokio::test]
1624 async fn fs_addresses_paths_outside_workspace() {
1625 let store = Arc::new(MapFileStore::default());
1631 let v = run(
1632 r#"
1633 fs.write("/tmp/note.txt", "hi")
1634 return fs.read("/tmp/note.txt")
1635 "#,
1636 store,
1637 )
1638 .await;
1639 assert_eq!(v["result"], json!("hi"));
1640 }
1641
1642 #[tokio::test]
1643 async fn instruction_budget_terminates_infinite_loop() {
1644 let result = LuaTool
1646 .execute_with_context(
1647 json!({ "script": "while true do end", "timeout_ms": 1000 }),
1648 &{
1649 let mut ctx = ToolContext::new(SessionId::new());
1650 ctx.file_store = Some(Arc::new(EmptyFileStore));
1651 ctx
1652 },
1653 )
1654 .await;
1655 assert!(matches!(result, ToolExecutionResult::ToolError(_)));
1656 }
1657
1658 #[derive(Default)]
1660 struct MapFileStore {
1661 files: Mutex<HashMap<String, String>>,
1662 }
1663
1664 #[async_trait]
1665 impl SessionFileSystem for MapFileStore {
1666 fn is_mount_resolver(&self) -> bool {
1667 false
1668 }
1669
1670 async fn read_file(
1671 &self,
1672 session_id: SessionId,
1673 path: &str,
1674 ) -> everruns_provider::error::Result<Option<SessionFile>> {
1675 let files = self.files.lock().unwrap();
1676 Ok(files.get(path).map(|content| SessionFile {
1677 id: uuid::Uuid::new_v4(),
1678 session_id: session_id.into(),
1679 path: path.to_string(),
1680 name: path.rsplit('/').next().unwrap_or("").to_string(),
1681 is_directory: false,
1682 is_readonly: false,
1683 content: Some(content.clone()),
1684 encoding: "text".to_string(),
1685 size_bytes: content.len() as i64,
1686 created_at: chrono::Utc::now(),
1687 updated_at: chrono::Utc::now(),
1688 }))
1689 }
1690
1691 async fn write_file(
1692 &self,
1693 session_id: SessionId,
1694 path: &str,
1695 content: &str,
1696 encoding: &str,
1697 ) -> everruns_provider::error::Result<SessionFile> {
1698 self.files
1699 .lock()
1700 .unwrap()
1701 .insert(path.to_string(), content.to_string());
1702 Ok(SessionFile {
1703 id: uuid::Uuid::new_v4(),
1704 session_id: session_id.into(),
1705 path: path.to_string(),
1706 name: path.rsplit('/').next().unwrap_or("").to_string(),
1707 is_directory: false,
1708 is_readonly: false,
1709 content: Some(content.to_string()),
1710 encoding: encoding.to_string(),
1711 size_bytes: content.len() as i64,
1712 created_at: chrono::Utc::now(),
1713 updated_at: chrono::Utc::now(),
1714 })
1715 }
1716
1717 async fn delete_file(
1718 &self,
1719 _session_id: SessionId,
1720 path: &str,
1721 _recursive: bool,
1722 ) -> everruns_provider::error::Result<bool> {
1723 Ok(self.files.lock().unwrap().remove(path).is_some())
1724 }
1725
1726 async fn list_directory(
1727 &self,
1728 session_id: SessionId,
1729 path: &str,
1730 ) -> everruns_provider::error::Result<Vec<crate::session_file::FileInfo>> {
1731 let prefix = if path == "/" {
1732 "/".to_string()
1733 } else {
1734 format!("{path}/")
1735 };
1736 let files = self.files.lock().unwrap();
1737 Ok(files
1738 .iter()
1739 .filter(|(p, _)| p.starts_with(&prefix))
1740 .map(|(p, c)| crate::session_file::FileInfo {
1741 id: uuid::Uuid::new_v4(),
1742 session_id: session_id.into(),
1743 path: p.clone(),
1744 name: p.rsplit('/').next().unwrap_or("").to_string(),
1745 is_directory: false,
1746 is_readonly: false,
1747 size_bytes: c.len() as i64,
1748 created_at: chrono::Utc::now(),
1749 updated_at: chrono::Utc::now(),
1750 })
1751 .collect())
1752 }
1753
1754 async fn stat_file(
1755 &self,
1756 _session_id: SessionId,
1757 path: &str,
1758 ) -> everruns_provider::error::Result<Option<crate::FileStat>> {
1759 let files = self.files.lock().unwrap();
1760 Ok(files.get(path).map(|c| crate::FileStat {
1761 path: path.to_string(),
1762 name: path.rsplit('/').next().unwrap_or("").to_string(),
1763 is_directory: false,
1764 is_readonly: false,
1765 size_bytes: c.len() as i64,
1766 created_at: chrono::Utc::now(),
1767 updated_at: chrono::Utc::now(),
1768 }))
1769 }
1770
1771 async fn grep_files(
1772 &self,
1773 _session_id: SessionId,
1774 pattern: &str,
1775 path_pattern: Option<&str>,
1776 ) -> everruns_provider::error::Result<Vec<crate::GrepMatch>> {
1777 let re = regex::Regex::new(pattern)
1778 .map_err(|e| crate::error::AgentLoopError::store(e.to_string()))?;
1779 let files = self.files.lock().unwrap();
1780 let mut out = Vec::new();
1781 for (p, c) in files.iter() {
1782 if let Some(pp) = path_pattern
1783 && !p.starts_with(pp)
1784 {
1785 continue;
1786 }
1787 for (i, line) in c.lines().enumerate() {
1788 if re.is_match(line) {
1789 out.push(crate::GrepMatch {
1790 path: p.clone(),
1791 line_number: i + 1,
1792 line: line.to_string(),
1793 });
1794 }
1795 }
1796 }
1797 Ok(out)
1798 }
1799
1800 async fn create_directory(
1801 &self,
1802 session_id: SessionId,
1803 path: &str,
1804 ) -> everruns_provider::error::Result<crate::session_file::FileInfo> {
1805 Ok(crate::session_file::FileInfo {
1806 id: uuid::Uuid::new_v4(),
1807 session_id: session_id.into(),
1808 path: path.to_string(),
1809 name: path.rsplit('/').next().unwrap_or("").to_string(),
1810 is_directory: true,
1811 is_readonly: false,
1812 size_bytes: 0,
1813 created_at: chrono::Utc::now(),
1814 updated_at: chrono::Utc::now(),
1815 })
1816 }
1817 }
1818 }
1819}