featherbit/plugins/script/mod.rs
1//! Scripted plugin host (`script`).
2//!
3//! Runs user-provided scripts as graph nodes behind the same `Plugin` trait
4//! as native plugins. Scripts are parsed and validated once at policy-compile
5//! time (in `from_config`), not per request; script failures surface as
6//! `PluginExecutionError` exactly like native failures, routing through the
7//! node's error port.
8
9pub mod lua_runtime;
10
11use async_trait::async_trait;
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15use crate::context::Context;
16use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
17
18/// Executes a scripted plugin as a graph node.
19///
20/// The script receives the full `Context` (request, response, message) and
21/// returns a possibly modified copy; anything it writes into `ctx.message`
22/// is visible to downstream nodes. Currently only the Lua (Luau) runtime is
23/// supported.
24pub struct ScriptPlugin {
25 runtime: ScriptRuntime,
26}
27
28/// Dispatch over the available scripting runtimes (only Lua today; Python is
29/// planned but not implemented).
30enum ScriptRuntime {
31 Lua(lua_runtime::LuaRuntime),
32}
33
34impl ScriptPlugin {
35 /// Builds the plugin from node config, loading and validating the script
36 /// immediately so bad scripts fail at policy-compile time.
37 ///
38 /// Accepted keys:
39 /// - `runtime` (string, default `"lua"`): scripting runtime; any other
40 /// value is an error.
41 /// - `source` (string): path to a script file, read at compile time.
42 /// - `inline` (string): script text embedded in the config. One of
43 /// `source` or `inline` is required (`source` wins if both are set);
44 /// omitting both is an error, as is an unreadable `source` file.
45 /// - `timeout_ms` (integer, default `5000`): script execution timeout;
46 /// currently stored by the Lua runtime but not yet enforced.
47 /// - `modules_path` (string, default: the `source` script's parent
48 /// directory; none for `inline`): directory the sandboxed `require`
49 /// resolves modules from.
50 ///
51 /// ```yaml
52 /// type: script
53 /// config:
54 /// runtime: lua
55 /// source: scripts/enrich.lua
56 /// timeout_ms: 2000
57 /// ```
58 ///
59 /// The script must define a global `execute(ctx)` function that returns
60 /// the (possibly modified) context table:
61 ///
62 /// ```lua
63 /// function execute(ctx)
64 /// ctx.request.headers["x-enriched"] = {"true"}
65 /// ctx.message.user_tier = "gold"
66 /// return ctx
67 /// end
68 /// ```
69 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
70 let runtime_name = config
71 .get("runtime")
72 .and_then(|v| v.as_str())
73 .unwrap_or("lua");
74
75 let source_path = config
76 .get("source")
77 .and_then(|v| v.as_str())
78 .map(String::from);
79
80 let inline_source = config
81 .get("inline")
82 .and_then(|v| v.as_str())
83 .map(String::from);
84
85 if source_path.is_none() && inline_source.is_none() {
86 return Err("script plugin requires 'source' or 'inline'".to_string());
87 }
88
89 let source = if let Some(ref path) = source_path {
90 std::fs::read_to_string(path)
91 .map_err(|e| format!("Failed to read script '{}': {}", path, e))?
92 } else {
93 inline_source.unwrap()
94 };
95
96 let timeout_ms = config
97 .get("timeout_ms")
98 .and_then(|v| v.as_u64())
99 .unwrap_or(5000);
100
101 // modules_path: explicit config, or derive from the script's parent directory
102 let modules_path = config
103 .get("modules_path")
104 .and_then(|v| v.as_str())
105 .map(PathBuf::from)
106 .or_else(|| {
107 source_path
108 .as_ref()
109 .and_then(|p| PathBuf::from(p).parent().map(|p| p.to_path_buf()))
110 });
111
112 let runtime = match runtime_name {
113 "lua" => {
114 let rt = lua_runtime::LuaRuntime::new(&source, timeout_ms, modules_path)?;
115 ScriptRuntime::Lua(rt)
116 }
117 other => {
118 return Err(format!("Unknown runtime: '{}' — supported: lua", other));
119 }
120 };
121
122 Ok(Self { runtime })
123 }
124}
125
126#[async_trait]
127impl Plugin for ScriptPlugin {
128 fn plugin_type(&self) -> &str {
129 "script"
130 }
131
132 async fn execute(
133 &self,
134 ctx: Context,
135 _named_inputs: &HashMap<String, serde_json::Value>,
136 ) -> PluginResult {
137 match &self.runtime {
138 ScriptRuntime::Lua(rt) => match rt.execute(ctx) {
139 Ok(new_ctx) => Ok(PluginOutput {
140 context: new_ctx,
141 named_outputs: HashMap::new(),
142 }),
143 Err(e) => Err(PluginExecutionError {
144 context: e.context,
145 error: e.error,
146 }),
147 },
148 }
149 }
150}