Skip to main content

imp_lua/
bridge.rs

1use std::process::{Command, Stdio};
2
3use async_trait::async_trait;
4use imp_core::storage;
5use imp_core::tools::lua::{parameter_schema_from_lua, tool_output_from_lua_result};
6use imp_core::tools::{Tool, ToolContext, ToolOutput, ToolRegistry};
7use imp_core::ui::{ComponentSpec, SelectOption};
8use imp_core::Error as CoreError;
9use imp_llm::auth::AuthStore;
10use mlua::{Function, Lua, MultiValue, Table, Value};
11use serde_json::{json, Value as JsonValue};
12use std::sync::{Arc, Mutex};
13
14use crate::sandbox::{
15    LuaCallContext, LuaCommandHandle, LuaError, LuaHookHandle, LuaRuntime, LuaToolHandle,
16};
17
18/// A `Tool` implementation backed by a Lua function registered with
19/// `imp.register_tool()`.
20pub struct LuaTool {
21    name: String,
22    label: String,
23    description: String,
24    readonly: bool,
25    params: serde_json::Value,
26    runtime: Arc<Mutex<LuaRuntime>>,
27    handle_index: usize,
28}
29
30#[async_trait]
31impl Tool for LuaTool {
32    fn name(&self) -> &str {
33        &self.name
34    }
35
36    fn label(&self) -> &str {
37        &self.label
38    }
39
40    fn description(&self) -> &str {
41        &self.description
42    }
43
44    fn parameters(&self) -> serde_json::Value {
45        parameter_schema_from_lua(&self.params)
46    }
47
48    fn is_readonly(&self) -> bool {
49        self.readonly
50    }
51
52    async fn execute(
53        &self,
54        call_id: &str,
55        params: serde_json::Value,
56        ctx: ToolContext,
57    ) -> imp_core::Result<ToolOutput> {
58        let runtime = Arc::clone(&self.runtime);
59        let handle_index = self.handle_index;
60        let call_id = call_id.to_string();
61        let ctx_json = json!({
62            "cwd": ctx.cwd.display().to_string(),
63            "cancelled": ctx.is_cancelled(),
64        });
65        let call_ctx = LuaCallContext::from(ctx);
66
67        tokio::task::spawn_blocking(move || {
68            let runtime_guard = runtime
69                .lock()
70                .map_err(|_| CoreError::Tool("Lua runtime lock poisoned".into()))?;
71
72            // Make the ToolContext available to imp.tool() during this execution.
73            runtime_guard.set_call_context(call_ctx);
74
75            let result = (|| {
76                let tools = runtime_guard.tools();
77                let handles = tools
78                    .lock()
79                    .map_err(|_| CoreError::Tool("Lua tool registry lock poisoned".into()))?;
80                let handle = handles.get(handle_index).ok_or_else(|| {
81                    CoreError::Tool(format!("Lua tool handle {handle_index} not found"))
82                })?;
83
84                let execute_fn: Function = runtime_guard
85                    .lua()
86                    .registry_value(&handle.execute_key)
87                    .map_err(lua_tool_error)?;
88                let lua_params =
89                    json_to_lua_value(runtime_guard.lua(), &params).map_err(lua_tool_error)?;
90                let lua_ctx =
91                    json_to_lua_value(runtime_guard.lua(), &ctx_json).map_err(lua_tool_error)?;
92                let result: Value = execute_fn
93                    .call((call_id.as_str(), lua_params, lua_ctx))
94                    .map_err(lua_tool_error)?;
95
96                tool_output_from_lua_result(lua_value_to_json(result))
97            })();
98
99            runtime_guard.clear_call_context();
100            result
101        })
102        .await
103        .map_err(|error| CoreError::Tool(format!("Lua tool task failed: {error}")))?
104    }
105}
106
107/// Register all currently loaded Lua tools with imp-core's tool registry.
108pub fn load_lua_tools(runtime: Arc<Mutex<LuaRuntime>>, registry: &mut ToolRegistry) {
109    let handles = {
110        let runtime_guard = runtime
111            .lock()
112            .expect("Lua runtime lock poisoned while loading tools");
113        let tools = runtime_guard.tools();
114        let handles = tools
115            .lock()
116            .expect("Lua tool registry lock poisoned while loading tools");
117
118        handles
119            .iter()
120            .enumerate()
121            .map(|(index, handle)| LuaTool {
122                name: handle.name.clone(),
123                label: handle.label.clone(),
124                description: handle.description.clone(),
125                readonly: handle.readonly,
126                params: handle.params.clone(),
127                runtime: Arc::clone(&runtime),
128                handle_index: index,
129            })
130            .collect::<Vec<_>>()
131    };
132
133    for tool in handles {
134        registry.register(Arc::new(tool));
135    }
136}
137
138fn lua_tool_error(error: mlua::Error) -> CoreError {
139    CoreError::Tool(format!("Lua tool error: {error}"))
140}
141
142/// Extract header key-value pairs from an optional Lua table.
143fn extract_header_pairs(headers: Option<Table>) -> mlua::Result<Vec<(String, String)>> {
144    let mut pairs = Vec::new();
145    if let Some(tbl) = headers {
146        for pair in tbl.pairs::<String, String>() {
147            let (k, v) = pair?;
148            pairs.push((k, v));
149        }
150    }
151    Ok(pairs)
152}
153
154fn ui_unavailable_result(lua: &Lua) -> mlua::Result<Value> {
155    ui_error_result(lua, "unavailable")
156}
157
158fn ui_cancelled_result(lua: &Lua) -> mlua::Result<Value> {
159    ui_error_result(lua, "cancelled")
160}
161
162fn ui_invalid_result(lua: &Lua, message: impl AsRef<str>) -> mlua::Result<Value> {
163    let result = lua.create_table()?;
164    result.set("ok", false)?;
165    result.set("reason", "invalid")?;
166    result.set("message", message.as_ref())?;
167    Ok(Value::Table(result))
168}
169
170fn ui_error_result(lua: &Lua, reason: &str) -> mlua::Result<Value> {
171    let result = lua.create_table()?;
172    result.set("ok", false)?;
173    result.set("reason", reason)?;
174    Ok(Value::Table(result))
175}
176
177fn ui_ok_result(lua: &Lua, value: JsonValue) -> mlua::Result<Value> {
178    let result = lua.create_table()?;
179    result.set("ok", true)?;
180    result.set("value", json_to_lua_value(lua, &value)?)?;
181    Ok(Value::Table(result))
182}
183
184fn option_from_json(value: &JsonValue) -> Option<SelectOption> {
185    match value {
186        JsonValue::String(label) => Some(SelectOption {
187            label: label.clone(),
188            description: None,
189        }),
190        JsonValue::Object(object) => Some(SelectOption {
191            label: object.get("label")?.as_str()?.to_string(),
192            description: object
193                .get("description")
194                .and_then(JsonValue::as_str)
195                .map(str::to_string),
196        }),
197        _ => None,
198    }
199}
200
201fn options_from_spec(spec: &JsonValue) -> Result<Vec<SelectOption>, String> {
202    let values = spec
203        .get("options")
204        .and_then(JsonValue::as_array)
205        .ok_or_else(|| "ui request requires an options array".to_string())?;
206    values
207        .iter()
208        .map(|value| option_from_json(value).ok_or_else(|| "invalid ui option".to_string()))
209        .collect()
210}
211
212fn component_from_spec(spec: &JsonValue) -> Result<ComponentSpec, String> {
213    let component = spec.get("component").unwrap_or(spec);
214    serde_json::from_value(component.clone()).map_err(|error| error.to_string())
215}
216
217fn execute_ui_request(
218    lua: &Lua,
219    spec: JsonValue,
220    call_ctx: &Arc<Mutex<Option<LuaCallContext>>>,
221) -> mlua::Result<Value> {
222    let ctx = {
223        let ctx_guard = call_ctx
224            .lock()
225            .map_err(|_| mlua::Error::external("call context lock poisoned"))?;
226        match ctx_guard.as_ref() {
227            Some(ctx) => ctx.to_tool_context(),
228            None => return ui_unavailable_result(lua),
229        }
230    };
231
232    if !ctx.ui.has_ui() {
233        return ui_unavailable_result(lua);
234    }
235
236    let kind = spec
237        .get("kind")
238        .and_then(JsonValue::as_str)
239        .unwrap_or("custom");
240    let title = spec.get("title").and_then(JsonValue::as_str).unwrap_or("");
241    let message = spec
242        .get("message")
243        .or_else(|| spec.get("context"))
244        .and_then(JsonValue::as_str)
245        .unwrap_or("");
246
247    let handle = tokio::runtime::Handle::try_current()
248        .map_err(|_| mlua::Error::external("imp.ui requires a tokio runtime"))?;
249
250    match kind {
251        "confirm" => match handle.block_on(ctx.ui.confirm(title, message)) {
252            Some(value) => ui_ok_result(lua, JsonValue::Bool(value)),
253            None => ui_cancelled_result(lua),
254        },
255        "select" => {
256            let options = match options_from_spec(&spec) {
257                Ok(options) => options,
258                Err(message) => return ui_invalid_result(lua, message),
259            };
260            match handle.block_on(ctx.ui.select_with_context(title, message, &options)) {
261                Some(index) => ui_ok_result(
262                    lua,
263                    json!({
264                        "index": index + 1,
265                        "label": options.get(index).map(|option| option.label.clone()),
266                    }),
267                ),
268                None => ui_cancelled_result(lua),
269            }
270        }
271        "multi_select" | "multi-select" => {
272            let options = match options_from_spec(&spec) {
273                Ok(options) => options,
274                Err(message) => return ui_invalid_result(lua, message),
275            };
276            match handle.block_on(ctx.ui.multi_select_with_context(title, message, &options)) {
277                Some(indices) => {
278                    let selected: Vec<JsonValue> = indices
279                        .into_iter()
280                        .map(|index| {
281                            json!({
282                                "index": index + 1,
283                                "label": options.get(index).map(|option| option.label.clone()),
284                            })
285                        })
286                        .collect();
287                    ui_ok_result(lua, JsonValue::Array(selected))
288                }
289                None => ui_cancelled_result(lua),
290            }
291        }
292        "input" => {
293            let placeholder = spec
294                .get("placeholder")
295                .and_then(JsonValue::as_str)
296                .or_else(|| spec.get("default").and_then(JsonValue::as_str))
297                .unwrap_or("");
298            match handle.block_on(ctx.ui.input_with_context(title, message, placeholder)) {
299                Some(value) => ui_ok_result(lua, JsonValue::String(value)),
300                None => ui_cancelled_result(lua),
301            }
302        }
303        "custom" => {
304            let component = match component_from_spec(&spec) {
305                Ok(component) => component,
306                Err(message) => return ui_invalid_result(lua, message),
307            };
308            match handle.block_on(ctx.ui.custom(component)) {
309                Some(value) => ui_ok_result(lua, value),
310                None => ui_cancelled_result(lua),
311            }
312        }
313        other => ui_invalid_result(lua, format!("unknown ui request kind '{other}'")),
314    }
315}
316
317/// Set up the `imp` global table with host API functions.
318///
319/// Exposes to Lua:
320/// - imp.on(event, handler)           — subscribe to hook events
321/// - imp.register_tool(def)           — register a custom tool
322/// - imp.exec(command, args, opts)    — run a shell command
323/// - imp.register_command(name, def)  — register a slash command
324/// - imp.events.on() / imp.events.emit() — inter-extension event bus
325/// - imp.tool(name, params)           — call a native imp tool
326/// - imp.ui.request(spec)             — ask the active UI for confirm/select/input/custom
327/// - imp.ui.confirm(title, message)   — ergonomic yes/no helper
328/// - imp.secret(provider, field?)     — read a saved imp secret field
329/// - imp.secret_fields(provider)      — read all saved fields for a provider
330/// - imp.env(name)                    — read an env var (scoped by allowed list)
331/// - imp.http.get(url, headers?)      — HTTP GET
332/// - imp.http.post(url, body, headers?) — HTTP POST
333pub fn setup_host_api(runtime: &LuaRuntime) -> Result<(), LuaError> {
334    let lua = runtime.lua();
335
336    let imp = lua.create_table()?;
337
338    // ── imp.on(event_name, handler) ──────────────────────────────
339    let hooks = runtime.hooks();
340    let on_fn = lua.create_function(move |lua_inner, (event, handler): (String, Function)| {
341        let key = lua_inner.create_registry_value(handler)?;
342        let handle = LuaHookHandle {
343            event,
344            handler_key: key,
345        };
346        hooks.lock().unwrap().push(handle);
347        Ok(())
348    })?;
349    imp.set("on", on_fn)?;
350
351    // ── imp.register_tool(definition) ────────────────────────────
352    let tools = runtime.tools();
353    let register_tool_fn = lua.create_function(move |lua_inner, def: Table| {
354        let name: String = def.get("name")?;
355        let label: String = def
356            .get::<Option<String>>("label")?
357            .unwrap_or_else(|| name.clone());
358        let description: String = def
359            .get::<Option<String>>("description")?
360            .unwrap_or_default();
361        let readonly: bool = def.get::<Option<bool>>("readonly")?.unwrap_or(false);
362
363        let params_val: Value = def.get("params")?;
364        let params = lua_value_to_json(params_val);
365
366        let execute_fn: Function = def.get("execute")?;
367        let key = lua_inner.create_registry_value(execute_fn)?;
368
369        let handle = LuaToolHandle {
370            name,
371            label,
372            description,
373            readonly,
374            params,
375            execute_key: key,
376        };
377        tools.lock().unwrap().push(handle);
378        Ok(())
379    })?;
380    imp.set("register_tool", register_tool_fn)?;
381
382    // ── imp.exec(command, args, opts) ────────────────────────────
383    let allow_shell_exec = runtime.allow_shell_exec();
384    let exec_fn = lua.create_function(
385        move |lua_inner, (cmd, args, opts): (String, Option<Table>, Option<Table>)| {
386            if !allow_shell_exec.load(std::sync::atomic::Ordering::Relaxed) {
387                return Err(mlua::Error::external(
388                    "imp.exec() is disabled for this runtime",
389                ));
390            }
391            let output = if let Some(args_table) = args {
392                let mut command = Command::new(&cmd);
393                for pair in args_table.sequence_values::<String>() {
394                    command.arg(pair?);
395                }
396
397                if let Some(opts_table) = &opts {
398                    if let Ok(Some(cwd)) = opts_table.get::<Option<String>>("cwd") {
399                        command.current_dir(cwd);
400                    }
401                    if let Ok(Some(env_table)) = opts_table.get::<Option<Table>>("env") {
402                        for pair in env_table.pairs::<String, String>() {
403                            let (name, value) = pair?;
404                            command.env(name, value);
405                        }
406                    }
407                }
408
409                command.stdin(Stdio::null()).output()
410            } else {
411                let mut command = Command::new("sh");
412                command.arg("-c").arg(&cmd);
413
414                if let Some(opts_table) = &opts {
415                    if let Ok(Some(cwd)) = opts_table.get::<Option<String>>("cwd") {
416                        command.current_dir(cwd);
417                    }
418                    if let Ok(Some(env_table)) = opts_table.get::<Option<Table>>("env") {
419                        for pair in env_table.pairs::<String, String>() {
420                            let (name, value) = pair?;
421                            command.env(name, value);
422                        }
423                    }
424                }
425
426                command.stdin(Stdio::null()).output()
427            }
428            .map_err(mlua::Error::external)?;
429
430            let result = lua_inner.create_table()?;
431            result.set(
432                "stdout",
433                String::from_utf8_lossy(&output.stdout).to_string(),
434            )?;
435            result.set(
436                "stderr",
437                String::from_utf8_lossy(&output.stderr).to_string(),
438            )?;
439            result.set("exit_code", output.status.code().unwrap_or(-1))?;
440
441            Ok(result)
442        },
443    )?;
444    imp.set("exec", exec_fn)?;
445
446    // ── imp.register_command(name, definition) ───────────────────
447    let commands = runtime.commands();
448    let register_command_fn =
449        lua.create_function(move |lua_inner, (name, def): (String, Table)| {
450            let description: String = def
451                .get::<Option<String>>("description")?
452                .unwrap_or_default();
453            let handler: Function = def.get("handler")?;
454            let key = lua_inner.create_registry_value(handler)?;
455
456            let handle = LuaCommandHandle {
457                name,
458                description,
459                handler_key: key,
460            };
461            commands.lock().unwrap().push(handle);
462            Ok(())
463        })?;
464    imp.set("register_command", register_command_fn)?;
465
466    // ── imp.events (inter-extension event bus) ───────────────────
467    let events = lua.create_table()?;
468
469    // Store handlers in a Lua table: { event_name = { handler1, handler2, ... } }
470    let handlers_table = lua.create_table()?;
471    lua.set_named_registry_value("__imp_event_handlers", handlers_table)?;
472
473    let events_on = lua.create_function(|lua_inner, (name, handler): (String, Function)| {
474        let handlers: Table = lua_inner.named_registry_value("__imp_event_handlers")?;
475        let list: Table = match handlers.get::<Option<Table>>(name.as_str())? {
476            Some(t) => t,
477            None => {
478                let t = lua_inner.create_table()?;
479                handlers.set(name.as_str(), t.clone())?;
480                t
481            }
482        };
483        let len = list.raw_len();
484        list.set(len + 1, handler)?;
485        Ok(())
486    })?;
487    events.set("on", events_on)?;
488
489    let events_emit = lua.create_function(|lua_inner, (name, data): (String, Value)| {
490        let handlers: Table = lua_inner.named_registry_value("__imp_event_handlers")?;
491        if let Some(list) = handlers.get::<Option<Table>>(name.as_str())? {
492            for pair in list.sequence_values::<Function>() {
493                let handler = pair?;
494                // Errors in event handlers are caught and ignored so one bad
495                // extension callback cannot destabilize the host runtime.
496                let _ = handler.call::<()>(data.clone());
497            }
498        }
499        Ok(())
500    })?;
501    events.set("emit", events_emit)?;
502
503    imp.set("events", events)?;
504
505    // ── imp.tool(name, params) — call a native imp tool ──────────
506    let native_tools = runtime.native_tools();
507    let tool_call_ctx = runtime.call_context();
508    let allow_native_tool_calls = runtime.allow_native_tool_calls();
509    let imp_tool_fn = lua.create_function(
510        move |lua_inner, (name, params): (String, Value)| -> mlua::Result<MultiValue> {
511            if !allow_native_tool_calls.load(std::sync::atomic::Ordering::Relaxed) {
512                return Err(mlua::Error::external(
513                    "imp.tool() is disabled for this runtime",
514                ));
515            }
516
517            // Look up the tool.
518            let tool = {
519                let tools_guard = native_tools
520                    .lock()
521                    .map_err(|_| mlua::Error::external("native tools lock poisoned"))?;
522                tools_guard
523                    .get(&name)
524                    .cloned()
525                    .ok_or_else(|| mlua::Error::external(format!("tool '{name}' not found")))?
526            };
527
528            // Build a ToolContext from the stored call context.
529            let ctx = {
530                let ctx_guard = tool_call_ctx
531                    .lock()
532                    .map_err(|_| mlua::Error::external("call context lock poisoned"))?;
533                ctx_guard
534                    .as_ref()
535                    .ok_or_else(|| {
536                        mlua::Error::external("imp.tool() called outside of tool execution context")
537                    })?
538                    .to_tool_context()
539            };
540
541            let params_json = lua_value_to_json(params);
542
543            // Execute the tool — async via block_on (safe from spawn_blocking).
544            let handle = tokio::runtime::Handle::try_current()
545                .map_err(|_| mlua::Error::external("imp.tool() requires a tokio runtime"))?;
546
547            let output = handle
548                .block_on(tool.execute("lua-call", params_json, ctx))
549                .map_err(|e| mlua::Error::external(format!("tool error: {e}")))?;
550
551            // Convert ToolOutput → Lua multi-return: (result, err).
552            let mut mv = MultiValue::new();
553            if output.is_error {
554                let err_text = output
555                    .text_content()
556                    .unwrap_or("tool execution failed")
557                    .to_string();
558                mv.push_back(Value::Nil);
559                mv.push_back(Value::String(lua_inner.create_string(&err_text)?));
560            } else if let Some(text) = output.text_content() {
561                mv.push_back(Value::String(lua_inner.create_string(text)?));
562            } else {
563                mv.push_back(Value::Nil);
564            }
565            Ok(mv)
566        },
567    )?;
568    imp.set("tool", imp_tool_fn)?;
569
570    // ── imp.ui — programmatic host interaction ───────────────────
571    let ui = lua.create_table()?;
572    let ui_call_ctx = runtime.call_context();
573    let ui_request_fn = lua.create_function(move |lua_inner, spec: Value| {
574        execute_ui_request(lua_inner, lua_value_to_json(spec), &ui_call_ctx)
575    })?;
576    ui.set("request", ui_request_fn)?;
577
578    let confirm_call_ctx = runtime.call_context();
579    let ui_confirm_fn = lua.create_function(
580        move |lua_inner, (title, message): (String, Option<String>)| -> mlua::Result<Value> {
581            let result = execute_ui_request(
582                lua_inner,
583                json!({
584                    "kind": "confirm",
585                    "title": title,
586                    "message": message.unwrap_or_default(),
587                }),
588                &confirm_call_ctx,
589            )?;
590            let json = lua_value_to_json(result);
591            if json.get("ok").and_then(JsonValue::as_bool) == Some(true) {
592                Ok(match json.get("value").and_then(JsonValue::as_bool) {
593                    Some(value) => Value::Boolean(value),
594                    None => Value::Nil,
595                })
596            } else {
597                Ok(Value::Nil)
598            }
599        },
600    )?;
601    ui.set("confirm", ui_confirm_fn)?;
602
603    imp.set("ui", ui)?;
604
605    // ── imp.update(text) — stream progress to the TUI ─────────────
606    let update_call_ctx = runtime.call_context();
607    let imp_update_fn = lua.create_function(move |_lua, text: String| {
608        let ctx_guard = update_call_ctx
609            .lock()
610            .map_err(|_| mlua::Error::external("call context lock poisoned"))?;
611        if let Some(ref ctx) = *ctx_guard {
612            let _ = ctx.update_tx.try_send(imp_core::tools::ToolUpdate {
613                content: vec![imp_core::imp_llm::ContentBlock::Text { text }],
614                details: serde_json::Value::Null,
615            });
616        }
617        Ok(())
618    })?;
619    imp.set("update", imp_update_fn)?;
620
621    // ── imp.secret(provider, field?) — read a saved secret field ──────────
622    let allow_secrets = runtime.allow_secrets();
623    let secret_fn = lua.create_function(
624        move |lua_inner, (provider, field): (String, Option<String>)| -> mlua::Result<Value> {
625            if !allow_secrets.load(std::sync::atomic::Ordering::Relaxed) {
626                return Err(mlua::Error::external(
627                    "imp.secret() is disabled for this runtime",
628                ));
629            }
630            let auth_path =
631                storage::existing_global_auth_path().unwrap_or_else(storage::global_auth_path);
632            let auth_store =
633                AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
634            let field = field.unwrap_or_else(|| "api_key".to_string());
635            match auth_store.resolve_secret_field(&provider, &field) {
636                Ok(value) => Ok(Value::String(lua_inner.create_string(&value)?)),
637                Err(error) => Err(mlua::Error::external(error.to_string())),
638            }
639        },
640    )?;
641    imp.set("secret", secret_fn)?;
642
643    // ── imp.secret_fields(provider) — read all saved secret fields ─────────
644    let allow_secrets = runtime.allow_secrets();
645    let secret_fields_fn =
646        lua.create_function(move |lua_inner, provider: String| -> mlua::Result<Value> {
647            if !allow_secrets.load(std::sync::atomic::Ordering::Relaxed) {
648                return Err(mlua::Error::external(
649                    "imp.secret_fields() is disabled for this runtime",
650                ));
651            }
652            let auth_path =
653                storage::existing_global_auth_path().unwrap_or_else(storage::global_auth_path);
654            let auth_store =
655                AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
656            match auth_store.resolve_secret_fields(&provider) {
657                Ok(fields) => {
658                    let table = lua_inner.create_table()?;
659                    for (field, value) in fields {
660                        table.set(field, value)?;
661                    }
662                    Ok(Value::Table(table))
663                }
664                Err(error) => Err(mlua::Error::external(error.to_string())),
665            }
666        })?;
667    imp.set("secret_fields", secret_fields_fn)?;
668
669    // ── imp.env(name) — read a scoped env var ────────────────────
670    let allowed_env = runtime.allowed_env();
671    let env_fn = lua.create_function(move |lua_inner, name: String| {
672        let allowed = allowed_env
673            .lock()
674            .map_err(|_| mlua::Error::external("allowed_env lock poisoned"))?;
675        // If the allow-list is empty or the var is not listed, deny access.
676        if !allowed.contains(&name) {
677            return Ok(Value::Nil);
678        }
679        match std::env::var(&name) {
680            Ok(val) => Ok(Value::String(lua_inner.create_string(&val)?)),
681            Err(_) => Ok(Value::Nil),
682        }
683    })?;
684    imp.set("env", env_fn)?;
685
686    // ── imp.http — HTTP GET / POST via reqwest ───────────────────
687    let http = lua.create_table()?;
688    let allow_http = runtime.allow_http();
689
690    let http_get_fn =
691        lua.create_function(move |lua_inner, (url, headers): (String, Option<Table>)| {
692            if !allow_http.load(std::sync::atomic::Ordering::Relaxed) {
693                return Err(mlua::Error::external(
694                    "imp.http.get() is disabled for this runtime",
695                ));
696            }
697            let header_pairs = extract_header_pairs(headers)?;
698
699            let handle = tokio::runtime::Handle::try_current()
700                .map_err(|_| mlua::Error::external("imp.http requires a tokio runtime"))?;
701
702            let (status, body) = handle
703                .block_on(async {
704                    let client = reqwest::Client::new();
705                    let mut builder = client.get(&url);
706                    for (k, v) in &header_pairs {
707                        builder = builder.header(k.as_str(), v.as_str());
708                    }
709                    let resp = builder.send().await.map_err(|e| e.to_string())?;
710                    let status = resp.status().as_u16();
711                    let body = resp.text().await.map_err(|e| e.to_string())?;
712                    Ok::<_, String>((status, body))
713                })
714                .map_err(mlua::Error::external)?;
715
716            let result = lua_inner.create_table()?;
717            result.set("status", status)?;
718            result.set("body", body)?;
719            Ok(result)
720        })?;
721    http.set("get", http_get_fn)?;
722
723    let allow_http = runtime.allow_http();
724    let http_post_fn = lua.create_function(
725        move |lua_inner, (url, body, headers): (String, String, Option<Table>)| {
726            if !allow_http.load(std::sync::atomic::Ordering::Relaxed) {
727                return Err(mlua::Error::external(
728                    "imp.http.post() is disabled for this runtime",
729                ));
730            }
731            let header_pairs = extract_header_pairs(headers)?;
732
733            let handle = tokio::runtime::Handle::try_current()
734                .map_err(|_| mlua::Error::external("imp.http requires a tokio runtime"))?;
735
736            let (status, resp_body) = handle
737                .block_on(async {
738                    let client = reqwest::Client::new();
739                    let mut builder = client.post(&url).body(body);
740                    for (k, v) in &header_pairs {
741                        builder = builder.header(k.as_str(), v.as_str());
742                    }
743                    let resp = builder.send().await.map_err(|e| e.to_string())?;
744                    let status = resp.status().as_u16();
745                    let resp_body = resp.text().await.map_err(|e| e.to_string())?;
746                    Ok::<_, String>((status, resp_body))
747                })
748                .map_err(mlua::Error::external)?;
749
750            let result = lua_inner.create_table()?;
751            result.set("status", status)?;
752            result.set("body", resp_body)?;
753            Ok(result)
754        },
755    )?;
756    http.set("post", http_post_fn)?;
757
758    imp.set("http", http)?;
759
760    // ── Set the global ───────────────────────────────────────────
761    lua.globals().set("imp", imp)?;
762
763    Ok(())
764}
765
766/// Convert a Lua value to serde_json::Value.
767pub fn lua_value_to_json(value: Value) -> serde_json::Value {
768    match value {
769        Value::Nil => serde_json::Value::Null,
770        Value::Boolean(b) => serde_json::Value::Bool(b),
771        Value::Integer(i) => serde_json::Value::Number(serde_json::Number::from(i)),
772        Value::Number(n) => serde_json::Number::from_f64(n)
773            .map(serde_json::Value::Number)
774            .unwrap_or(serde_json::Value::Null),
775        Value::String(s) => {
776            serde_json::Value::String(s.to_str().map(|s| s.to_string()).unwrap_or_default())
777        }
778        Value::Table(t) => {
779            // Check if it's an array (sequential integer keys starting at 1)
780            let len = t.raw_len();
781            if len > 0 {
782                // Check if all keys 1..=len exist (it's an array)
783                let is_array = (1..=len).all(|i| {
784                    t.get::<Value>(i)
785                        .ok()
786                        .map(|v| !matches!(v, Value::Nil))
787                        .unwrap_or(false)
788                });
789                if is_array {
790                    let arr: Vec<serde_json::Value> = (1..=len)
791                        .filter_map(|i| t.get::<Value>(i).ok().map(lua_value_to_json))
792                        .collect();
793                    return serde_json::Value::Array(arr);
794                }
795            }
796
797            // Otherwise it's an object
798            let mut map = serde_json::Map::new();
799            if let Ok(pairs) = t.pairs::<String, Value>().collect::<Result<Vec<_>, _>>() {
800                for (k, v) in pairs {
801                    map.insert(k, lua_value_to_json(v));
802                }
803            }
804            serde_json::Value::Object(map)
805        }
806        _ => serde_json::Value::Null,
807    }
808}
809
810/// Convert a serde_json::Value to a Lua value.
811pub fn json_to_lua_value(lua: &Lua, value: &serde_json::Value) -> mlua::Result<Value> {
812    match value {
813        serde_json::Value::Null => Ok(Value::Nil),
814        serde_json::Value::Bool(b) => Ok(Value::Boolean(*b)),
815        serde_json::Value::Number(n) => {
816            if let Some(i) = n.as_i64() {
817                Ok(Value::Integer(i))
818            } else if let Some(f) = n.as_f64() {
819                Ok(Value::Number(f))
820            } else {
821                Ok(Value::Nil)
822            }
823        }
824        serde_json::Value::String(s) => Ok(Value::String(lua.create_string(s)?)),
825        serde_json::Value::Array(arr) => {
826            let table = lua.create_table()?;
827            for (i, v) in arr.iter().enumerate() {
828                table.set(i + 1, json_to_lua_value(lua, v)?)?;
829            }
830            Ok(Value::Table(table))
831        }
832        serde_json::Value::Object(map) => {
833            let table = lua.create_table()?;
834            for (k, v) in map {
835                table.set(k.as_str(), json_to_lua_value(lua, v)?)?;
836            }
837            Ok(Value::Table(table))
838        }
839    }
840}