orcs_lua/lib.rs
1//! Lua scripting support for ORCS components.
2//!
3//! This crate enables writing ORCS Components and Children in Lua scripts.
4//!
5//! # Architecture
6//!
7//! ```text
8//! ┌─────────────────────────────────────────────────────┐
9//! │ LuaComponent (Rust) │
10//! │ impl Component for LuaComponent │
11//! │ ┌───────────────────────────────────────────────┐ │
12//! │ │ lua: Lua (mlua) │ │
13//! │ │ id: ComponentId │ │
14//! │ │ callbacks: LuaCallbacks │ │
15//! │ └───────────────────────────────────────────────┘ │
16//! │ │ │
17//! │ ▼ │
18//! │ ┌───────────────────────────────────────────────┐ │
19//! │ │ Lua Script (.lua) │ │
20//! │ │ return { │ │
21//! │ │ id = "my-component", │ │
22//! │ │ subscriptions = {"Echo"}, │ │
23//! │ │ on_request = function(req) ... end, │ │
24//! │ │ on_signal = function(sig) ... end, │ │
25//! │ │ } │ │
26//! │ └───────────────────────────────────────────────┘ │
27//! └─────────────────────────────────────────────────────┘
28//! ```
29//!
30//! # Example Lua Script
31//!
32//! ```lua
33//! -- echo_component.lua
34//! return {
35//! id = "lua-echo",
36//! subscriptions = {"Echo"},
37//!
38//! on_request = function(request)
39//! if request.operation == "echo" then
40//! return { success = true, data = request.payload }
41//! end
42//! return { success = false, error = "unknown operation" }
43//! end,
44//!
45//! on_signal = function(signal)
46//! if signal.kind == "Veto" then
47//! return "Abort"
48//! end
49//! return "Ignored"
50//! end,
51//! }
52//! ```
53//!
54//! # Sandbox
55//!
56//! All file operations (`orcs.read`, `orcs.write`, `orcs.grep`, `orcs.glob`,
57//! `orcs.mkdir`, `orcs.remove`, `orcs.mv`) are sandboxed via
58//! [`SandboxPolicy`](orcs_runtime::sandbox::SandboxPolicy). The sandbox is
59//! injected at construction time and controls:
60//!
61//! - Which filesystem paths are accessible for reads and writes
62//! - The working directory for `orcs.exec()` commands
63//! - The value of `orcs.pwd` in Lua
64//!
65//! Dangerous Lua stdlib functions (`io.*`, `os.execute`, `loadfile`, etc.)
66//! are disabled after registration to prevent sandbox bypass.
67//!
68//! # Hot Reload
69//!
70//! `LuaComponent::reload()` allows reloading the script from file.
71//!
72//! # Script Loading
73//!
74//! Scripts are loaded from filesystem search paths:
75//!
76//! ```ignore
77//! use orcs_lua::ScriptLoader;
78//! use orcs_runtime::sandbox::ProjectSandbox;
79//! use std::sync::Arc;
80//!
81//! let sandbox = Arc::new(ProjectSandbox::new(".").expect("sandbox init"));
82//!
83//! let loader = ScriptLoader::new(sandbox)
84//! .with_path("~/.orcs/components")
85//! .with_path("/versioned/builtins/components");
86//! let component = loader.load("echo")?;
87//! ```
88
89pub(crate) mod builtin_tools;
90mod child;
91mod component;
92pub(crate) mod context_wrapper;
93mod error;
94pub mod hook_helpers;
95pub mod http_command;
96pub mod llm_adapter;
97pub mod llm_command;
98mod loader;
99mod lua_env;
100pub mod orcs_helpers;
101pub(crate) mod resolve_loop;
102pub mod sandbox_eval;
103pub mod sanitize;
104#[cfg(any(test, feature = "test-utils"))]
105pub mod scenario;
106#[cfg(any(test, feature = "test-utils"))]
107pub mod testing;
108pub mod tool_registry;
109pub mod tools;
110mod types;
111
112pub use child::LuaChild;
113pub use component::{LuaComponent, LuaComponentLoader};
114pub use error::LuaError;
115pub use hook_helpers::{
116 load_hooks_from_config, register_hook_function, register_hook_stub, register_unhook_function,
117 HookLoadError, HookLoadResult, LuaHook,
118};
119pub use llm_command::{llm_request_impl, register_llm_deny_stub};
120pub use loader::{LoadResult, LoadWarning, ScriptLoader};
121pub use lua_env::LuaEnv;
122pub use orcs_helpers::{ensure_orcs_table, register_base_orcs_functions};
123pub use tools::register_tool_functions;
124pub use types::{LuaRequest, LuaResponse, LuaSignal};
125
126/// Extracts `(grant_pattern, description)` from a `ComponentError::Suspended`
127/// wrapped inside an `mlua::Error`.
128///
129/// Recurses through `CallbackError` wrappers since mlua nests callback errors.
130/// Used by `dispatch_intents_to_results` to convert intent-level permission
131/// denials into LLM-visible tool_result errors instead of aborting the
132/// resolve loop.
133pub(crate) fn extract_suspended_info(err: &mlua::Error) -> Option<(String, String)> {
134 match err {
135 mlua::Error::ExternalError(ext) => ext
136 .downcast_ref::<orcs_component::ComponentError>()
137 .and_then(|ce| match ce {
138 orcs_component::ComponentError::Suspended {
139 grant_pattern,
140 pending_request,
141 ..
142 } => {
143 let desc = pending_request
144 .get("description")
145 .and_then(|v| v.as_str())
146 .unwrap_or("unknown operation")
147 .to_string();
148 Some((grant_pattern.clone(), desc))
149 }
150 _ => None,
151 }),
152 mlua::Error::CallbackError { cause, .. } => extract_suspended_info(cause),
153 _ => None,
154 }
155}
156
157#[cfg(test)]
158mod extract_suspended_info_tests {
159 use super::*;
160 use orcs_component::ComponentError;
161 use std::sync::Arc;
162
163 #[test]
164 fn extracts_grant_pattern_and_description() {
165 let err = mlua::Error::ExternalError(Arc::new(ComponentError::Suspended {
166 approval_id: "ap-001".into(),
167 grant_pattern: "intent:write".into(),
168 pending_request: serde_json::json!({
169 "command": "intent:write",
170 "description": "Write to file: /tmp/test.txt",
171 }),
172 }));
173 let result = extract_suspended_info(&err);
174 assert!(result.is_some(), "should extract from Suspended");
175 let (pattern, desc) = result.expect("already asserted Some");
176 assert_eq!(pattern, "intent:write");
177 assert_eq!(desc, "Write to file: /tmp/test.txt");
178 }
179
180 #[test]
181 fn extracts_through_callback_error() {
182 let inner = mlua::Error::ExternalError(Arc::new(ComponentError::Suspended {
183 approval_id: "ap-002".into(),
184 grant_pattern: "intent:remove".into(),
185 pending_request: serde_json::json!({
186 "description": "Remove file: /tmp/old.txt",
187 }),
188 }));
189 let err = mlua::Error::CallbackError {
190 traceback: "stack trace".into(),
191 cause: Arc::new(inner),
192 };
193 let (pattern, desc) =
194 extract_suspended_info(&err).expect("should extract through CallbackError");
195 assert_eq!(pattern, "intent:remove");
196 assert_eq!(desc, "Remove file: /tmp/old.txt");
197 }
198
199 #[test]
200 fn falls_back_to_unknown_when_no_description() {
201 let err = mlua::Error::ExternalError(Arc::new(ComponentError::Suspended {
202 approval_id: "ap-003".into(),
203 grant_pattern: "intent:mkdir".into(),
204 pending_request: serde_json::json!({"command": "intent:mkdir"}),
205 }));
206 let (pattern, desc) =
207 extract_suspended_info(&err).expect("should extract even without description");
208 assert_eq!(pattern, "intent:mkdir");
209 assert_eq!(desc, "unknown operation");
210 }
211
212 #[test]
213 fn returns_none_for_non_suspended() {
214 let err =
215 mlua::Error::ExternalError(Arc::new(ComponentError::ExecutionFailed("timeout".into())));
216 assert!(
217 extract_suspended_info(&err).is_none(),
218 "ExecutionFailed should not match"
219 );
220 }
221
222 #[test]
223 fn returns_none_for_runtime_error() {
224 let err = mlua::Error::RuntimeError("some error".into());
225 assert!(
226 extract_suspended_info(&err).is_none(),
227 "RuntimeError should not match"
228 );
229 }
230}