Skip to main content

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}